commit e115934061d2d5cea7202c9162ee7de9aae84d2a
Author: wehub-resource-sync
Date: Mon Jul 13 12:08:12 2026 +0800
chore: import upstream snapshot with attribution
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 0000000..5d5454e
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,5 @@
+FROM node:24.16.0-bullseye
+
+RUN useradd -m -s /bin/bash vscode
+RUN mkdir -p /workspaces && chown -R vscode:vscode /workspaces
+WORKDIR /workspaces
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..a3bb780
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,18 @@
+{
+ "dockerComposeFile": "docker-compose.yml",
+ "service": "app",
+ "workspaceFolder": "/workspaces",
+ "customizations": {
+ "vscode": {
+ "extensions": [],
+ "settings": {
+ "terminal.integrated.profiles.linux": {
+ "bash": null
+ }
+ }
+ }
+ },
+ "postCreateCommand": "",
+ "features": { "ghcr.io/devcontainers/features/git:1": {} },
+ "remoteUser": "vscode"
+}
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
new file mode 100644
index 0000000..70ebf9b
--- /dev/null
+++ b/.devcontainer/docker-compose.yml
@@ -0,0 +1,62 @@
+services:
+ app:
+ build:
+ context: ..
+ dockerfile: .devcontainer/Dockerfile
+ # restart: always
+ links:
+ - mongodb
+ - meilisearch
+ # ports:
+ # - 3080:3080 # Change it to 9000:3080 to use nginx
+ extra_hosts: # if you are running APIs on docker you need access to, you will need to uncomment this line and next
+ - "host.docker.internal:host-gateway"
+
+ volumes:
+ # This is where VS Code should expect to find your project's source code and the value of "workspaceFolder" in .devcontainer/devcontainer.json
+ - ..:/workspaces:cached
+ # Uncomment the next line to use Docker from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker-compose for details.
+ # - /var/run/docker.sock:/var/run/docker.sock
+ environment:
+ - HOST=0.0.0.0
+ - MONGO_URI=mongodb://mongodb:27017/LibreChat
+ # - OPENAI_REVERSE_PROXY=http://host.docker.internal:8070/v1
+ - MEILI_HOST=http://meilisearch:7700
+
+ # Runs app on the same network as the service container, allows "forwardPorts" in devcontainer.json function.
+ # network_mode: service:another-service
+
+ # Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
+ # (Adding the "ports" property to this file will not forward from a Codespace.)
+
+ # Use a non-root user for all processes - See https://aka.ms/vscode-remote/containers/non-root for details.
+ user: vscode
+
+ # Overrides default command so things don't shut down after the process ends.
+ command: /bin/sh -c "while sleep 1000; do :; done"
+
+ mongodb:
+ container_name: chat-mongodb
+ expose:
+ - 27017
+ # ports:
+ # - 27018:27017
+ image: mongo
+ # restart: always
+ volumes:
+ - ./data-node:/data/db
+ command: mongod --noauth
+ meilisearch:
+ container_name: chat-meilisearch
+ image: getmeili/meilisearch:v1.5
+ # restart: always
+ expose:
+ - 7700
+ # Uncomment this to access meilisearch from outside docker
+ # ports:
+ # - 7700:7700 # if exposing these ports, make sure your master key is not the default value
+ environment:
+ - MEILI_NO_ANALYTICS=true
+ - MEILI_MASTER_KEY=5c71cf56d672d009e36070b5bc5e47b743535ae55c818ae3b735bb6ebfb4ba63
+ volumes:
+ - ./meili_data_v1.5:/meili_data
diff --git a/.do/gitnexus/Caddyfile b/.do/gitnexus/Caddyfile
new file mode 100644
index 0000000..3c5dac2
--- /dev/null
+++ b/.do/gitnexus/Caddyfile
@@ -0,0 +1,25 @@
+# Caddy reverse proxy with bearer token auth and automatic HTTPS.
+# The domain is supplied via environment variable GITNEXUS_DOMAIN,
+# and the auth token via API_TOKEN. Both are set in docker-compose.yml.
+
+{$GITNEXUS_DOMAIN} {
+ # Health check — unauthenticated so monitoring can probe it
+ @health path /health
+ handle @health {
+ reverse_proxy gitnexus:4747 {
+ rewrite /api/info
+ }
+ }
+
+ # All other routes require bearer token
+ @authed {
+ header Authorization "Bearer {$API_TOKEN}"
+ }
+
+ handle @authed {
+ reverse_proxy gitnexus:4747
+ }
+
+ # Reject unauthenticated requests
+ respond "Unauthorized" 401
+}
diff --git a/.do/gitnexus/Dockerfile b/.do/gitnexus/Dockerfile
new file mode 100644
index 0000000..8b7e538
--- /dev/null
+++ b/.do/gitnexus/Dockerfile
@@ -0,0 +1,46 @@
+# Long-lived GitNexus image for DigitalOcean droplet deployment.
+#
+# This image does NOT bake in the index data. Indexes are mounted from
+# the host at /indexes//.gitnexus/ and registered at container
+# startup. A fresh index only requires rsync + container restart — no
+# image rebuild on every push.
+
+FROM node:24.16.0-slim
+
+ARG GITNEXUS_VERSION=1.6.7
+# Pin the native DB to match the index workflow; gitnexus's ^0.17.0 range
+# would otherwise let the served image drift from the CI-produced index.
+ARG LADYBUG_VERSION=0.17.1
+
+# 1. Build native addons with Bookworm toolchain, then remove build tools.
+# curl stays for the docker healthcheck; Caddy lives in its own container.
+# LadybugDB is pinned nested under gitnexus so step 3's require() resolves it.
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends python3 make g++ curl \
+ && npm install -g gitnexus@${GITNEXUS_VERSION} \
+ && npm install --no-save --prefix /usr/local/lib/node_modules/gitnexus "@ladybugdb/core@${LADYBUG_VERSION}" \
+ && apt-get purge -y --auto-remove python3 make g++ \
+ && rm -rf /var/lib/apt/lists/* /root/.npm
+
+# 2. Upgrade libstdc++ from Trixie — @ladybugdb/core prebuilt binary needs
+# GLIBCXX_3.4.32 which Bookworm (3.4.31) doesn't ship.
+RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list \
+ && apt-get update \
+ && apt-get install -y -t trixie libstdc++6 \
+ && rm /etc/apt/sources.list.d/trixie.list \
+ && rm -rf /var/lib/apt/lists/*
+
+# 3. Pre-install LadybugDB FTS + vector extensions so ~/.kuzu/extension/
+# is baked into the image. gitnexus serve loads extensions with a
+# load-only policy and never installs them at runtime, so the cache
+# must already exist. (GitNexus loads the vector extension itself
+# via loadVectorExtension — no adapter patch needed.)
+COPY install-extensions.js /tmp/install-extensions.js
+RUN node /tmp/install-extensions.js && rm -rf /tmp/install-extensions.js /tmp/lbug-ext-install
+
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
+EXPOSE 4747
+
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/.do/gitnexus/docker-compose.yml b/.do/gitnexus/docker-compose.yml
new file mode 100644
index 0000000..7761a89
--- /dev/null
+++ b/.do/gitnexus/docker-compose.yml
@@ -0,0 +1,87 @@
+# GitNexus stack for the DigitalOcean droplet.
+#
+# Two services: the gitnexus server (bound to an internal network only)
+# and a Caddy reverse proxy that handles TLS + auth.
+#
+# Index data lives on the host at /opt/gitnexus/indexes/ and is
+# bind-mounted read-write into the gitnexus container. The deploy
+# workflow rsyncs fresh indexes into that directory and restarts
+# only the gitnexus container — Caddy keeps running undisturbed.
+#
+# Break-glass: if gitnexus is stuck unhealthy and you need to restart
+# just Caddy (e.g. to push an emergency Caddyfile fix), the
+# `depends_on: condition: service_healthy` would block:
+# docker compose up -d caddy
+# Use --no-deps to bypass the dependency check:
+# docker compose up -d --no-deps caddy
+
+name: gitnexus
+
+# Shared logging defaults applied to both services so the droplet's
+# disk doesn't fill up with unbounded json-file logs.
+x-logging: &default-logging
+ driver: json-file
+ options:
+ max-size: '50m'
+ max-file: '3'
+
+services:
+ gitnexus:
+ # Override via GITNEXUS_IMAGE in /opt/gitnexus/.env to use a fork or
+ # a pinned version tag like :v1.5.3 for reproducible rollbacks.
+ image: ${GITNEXUS_IMAGE:-ghcr.io/danny-avila/librechat-gitnexus:latest}
+ container_name: gitnexus
+ restart: unless-stopped
+ networks:
+ - gitnexus-net
+ volumes:
+ - /opt/gitnexus/indexes:/indexes
+ # memswap_limit equal to mem_limit disables swap for this container.
+ # Without it, Docker lets the process silently swap onto host disk,
+ # turning sub-second graph queries into multi-second ones. Hard
+ # OOM-kill is preferable — the container restarts via unless-stopped,
+ # the deploy health poll catches it, and the failure is explicit.
+ mem_limit: 1792m
+ memswap_limit: 1792m
+ logging: *default-logging
+ healthcheck:
+ test: ['CMD', 'curl', '-fsS', 'http://127.0.0.1:4747/api/info']
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 60s
+
+ caddy:
+ image: caddy:2-alpine
+ container_name: gitnexus-caddy
+ restart: unless-stopped
+ # service_healthy (not just service_started) ensures Caddy doesn't
+ # start routing traffic until gitnexus passes its initial healthcheck
+ # on a cold `compose up`. This only governs initial startup ordering —
+ # during force-recreates of gitnexus, Caddy stays up and may briefly
+ # return 502 while the new gitnexus container binds its port. The
+ # deploy workflow's health poll catches any sustained failure.
+ depends_on:
+ gitnexus:
+ condition: service_healthy
+ ports:
+ - '80:80'
+ - '443:443'
+ networks:
+ - gitnexus-net
+ volumes:
+ - /opt/gitnexus/Caddyfile:/etc/caddy/Caddyfile:ro
+ - caddy-data:/data
+ - caddy-config:/config
+ logging: *default-logging
+ environment:
+ GITNEXUS_DOMAIN: ${GITNEXUS_DOMAIN}
+ API_TOKEN: ${API_TOKEN}
+
+networks:
+ gitnexus-net:
+ driver: bridge
+
+volumes:
+ caddy-data:
+ caddy-config:
diff --git a/.do/gitnexus/entrypoint.sh b/.do/gitnexus/entrypoint.sh
new file mode 100644
index 0000000..a5f0e7e
--- /dev/null
+++ b/.do/gitnexus/entrypoint.sh
@@ -0,0 +1,48 @@
+#!/bin/sh
+set -e
+
+# Cap Node heap below the container's cgroup limit (1792m in compose),
+# leaving room for @ladybugdb/core's C++ heap and OS overhead. Native
+# allocations happen outside V8's view, so a slim V8 budget is the only
+# thing between a heavy query and a cgroup OOM-kill. Without this cap,
+# gitnexus defaults to --max-old-space-size=8192 and reserves memory
+# the container doesn't have.
+export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=1280}"
+
+# Register every index mounted under /indexes//.gitnexus/.
+# This is idempotent — re-registering an existing repo updates the
+# metadata pointer without touching the index data.
+#
+# Registration failure handling:
+# - main (LibreChat) and dev (LibreChat-dev) are critical. If either
+# fails to register, exit 1 so docker marks the container unhealthy
+# and the deploy workflow's readiness check surfaces the error.
+# - PR indexes (LibreChat-pr-*) are best-effort. A corrupt PR index
+# shouldn't take the whole server down.
+if [ -d /indexes ]; then
+ for dir in /indexes/*/; do
+ [ -d "$dir" ] || continue
+ name=$(basename "$dir")
+ [ -d "$dir.gitnexus" ] || continue
+ echo "Registering index: $name"
+ if ! gitnexus index "$dir" --allow-non-git; then
+ case "$name" in
+ LibreChat|LibreChat-dev)
+ echo "ERROR: failed to register critical index $name" >&2
+ exit 1
+ ;;
+ *)
+ echo "WARN: failed to register PR index $name — skipping" >&2
+ ;;
+ esac
+ fi
+ done
+else
+ echo "WARN: /indexes directory not mounted" >&2
+fi
+
+# Bind 0.0.0.0 inside the container so Caddy (in a separate container
+# on the same docker network) can reach gitnexus at gitnexus:4747.
+# docker-compose.yml intentionally does NOT expose port 4747 on the
+# host — only Caddy's 80/443 are published.
+exec gitnexus serve --host 0.0.0.0 --port 4747
diff --git a/.do/gitnexus/install-extensions.js b/.do/gitnexus/install-extensions.js
new file mode 100644
index 0000000..231741e
--- /dev/null
+++ b/.do/gitnexus/install-extensions.js
@@ -0,0 +1,46 @@
+/**
+ * Pre-install LadybugDB extensions (FTS + vector) into the Docker image's
+ * extension cache (~/.kuzu/extension/). Without this, gitnexus serve's
+ * lbug-adapter calls LOAD EXTENSION fts at runtime but fails silently
+ * because the extension was never installed, causing all BM25 and
+ * semantic queries via the query() tool to return empty.
+ *
+ * Workaround for upstream GitNexus 1.5.3 bug where the CI-produced
+ * .gitnexus/ artifact doesn't include the extension cache.
+ */
+
+const path = require('path');
+const fs = require('fs');
+
+// @ladybugdb/core lives under the globally-installed gitnexus package.
+// This path is stable across gitnexus versions because npm always nests
+// transitive deps under the installed package's node_modules.
+const lbugPath = '/usr/local/lib/node_modules/gitnexus/node_modules/@ladybugdb/core';
+const lbug = require(lbugPath);
+
+const tmpDir = '/tmp/lbug-ext-install';
+fs.mkdirSync(tmpDir, { recursive: true });
+
+// Open a throwaway database just to run INSTALL against. The extension
+// cache persists in ~/.kuzu/extension/ regardless of which database was
+// used to install it, so the throwaway db and tmpDir are deleted in the
+// Dockerfile after this script finishes.
+const db = new lbug.Database(path.join(tmpDir, 'db'), 0, false, false);
+const conn = new lbug.Connection(db);
+
+(async () => {
+ try {
+ await conn.query('INSTALL fts');
+ console.log('FTS extension installed');
+ } catch (err) {
+ console.error('FTS install failed:', err.message);
+ process.exit(1);
+ }
+ try {
+ await conn.query('INSTALL vector');
+ console.log('Vector extension installed');
+ } catch (err) {
+ console.error('Vector install failed:', err.message);
+ process.exit(1);
+ }
+})();
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..396f0da
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,17 @@
+**/.circleci
+**/.editorconfig
+**/.dockerignore
+**/.git
+**/.DS_Store
+**/.vscode
+**/node_modules
+
+# Specific patterns to ignore
+data-node
+meili_data*
+librechat*
+Dockerfile*
+docs
+
+# Ignore all hidden files
+.*
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..c8049f1
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,1125 @@
+#=====================================================================#
+# LibreChat Configuration #
+#=====================================================================#
+# Please refer to the reference documentation for assistance #
+# with configuring your LibreChat environment. #
+# #
+# https://www.librechat.ai/docs/configuration/dotenv #
+#=====================================================================#
+
+#==================================================#
+# Server Configuration #
+#==================================================#
+
+HOST=localhost
+PORT=3080
+
+MONGO_URI=mongodb://127.0.0.1:27017/LibreChat
+#The maximum number of connections in the connection pool. */
+MONGO_MAX_POOL_SIZE=
+#The minimum number of connections in the connection pool. */
+MONGO_MIN_POOL_SIZE=
+#The maximum number of connections that may be in the process of being established concurrently by the connection pool. */
+MONGO_MAX_CONNECTING=
+#The maximum number of milliseconds that a connection can remain idle in the pool before being removed and closed. */
+MONGO_MAX_IDLE_TIME_MS=
+#The maximum time in milliseconds that a thread can wait for a connection to become available. */
+MONGO_WAIT_QUEUE_TIMEOUT_MS=
+# Set to false to disable automatic index creation for all models associated with this connection. */
+MONGO_AUTO_INDEX=
+# Set to `false` to disable Mongoose automatically calling `createCollection()` on every model created on this connection. */
+MONGO_AUTO_CREATE=
+
+DOMAIN_CLIENT=http://localhost:3080
+DOMAIN_SERVER=http://localhost:3080
+
+# External admin panel base URL used for admin OAuth/SSO redirects.
+# Required when the admin panel is hosted separately from LibreChat.
+# May include a path. Do not include a trailing slash.
+# Example: https://admin.example.com/admin
+ADMIN_PANEL_URL=
+
+# Session encryption key for the bundled admin panel (min 32 characters).
+# Required when using the bundled admin panel in docker-compose/deploy-compose.
+# Generate a unique value before starting the stack:
+# openssl rand -hex 32
+ADMIN_PANEL_SESSION_SECRET=
+
+# Host port for the bundled admin panel (default docker-compose only).
+# In deploy-compose the panel is served at http://admin.localhost via nginx.
+# ADMIN_PANEL_PORT=3000
+
+NO_INDEX=true
+# Use the address that is at most n number of hops away from the Express application.
+# req.socket.remoteAddress is the first hop, and the rest are looked for in the X-Forwarded-For header from right to left.
+# A value of 0 means that the first untrusted address would be req.socket.remoteAddress, i.e. there is no reverse proxy.
+# Defaulted to 1.
+TRUST_PROXY=1
+
+# Minimum password length for user authentication
+# Default: 8
+# Note: When using LDAP authentication, you may want to set this to 1
+# to bypass local password validation, as LDAP servers handle their own
+# password policies.
+# MIN_PASSWORD_LENGTH=8
+
+# When enabled, the app will continue running after encountering uncaught exceptions
+# instead of exiting the process. Not recommended for production unless necessary.
+# CONTINUE_ON_UNCAUGHT_EXCEPTION=false
+
+#===============#
+# JSON Logging #
+#===============#
+
+# Use when process console logs in cloud deployment like GCP/AWS
+CONSOLE_JSON=false
+
+# The maximum length of a string in a JSON log object.
+# Default: 255
+# CONSOLE_JSON_STRING_LENGTH=255
+
+#===============#
+# Debug Logging #
+#===============#
+
+DEBUG_LOGGING=true
+DEBUG_CONSOLE=false
+# Set to false to disable file-backed Winston transports.
+LOG_TO_FILE=true
+# 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
+
+#=============#
+# Permissions #
+#=============#
+
+# UID=1000
+# GID=1000
+
+#==============#
+# Node Options #
+#==============#
+
+# NOTE: NODE_MAX_OLD_SPACE_SIZE is NOT recognized by Node.js directly.
+# This variable is used as a build argument for Docker or CI/CD workflows,
+# and is NOT used by Node.js to set the heap size at runtime.
+# To configure Node.js memory, use NODE_OPTIONS, e.g.:
+# NODE_OPTIONS="--max-old-space-size=6144"
+# See: https://nodejs.org/api/cli.html#--max-old-space-sizesize-in-mib
+NODE_MAX_OLD_SPACE_SIZE=6144
+
+#===============#
+# Configuration #
+#===============#
+# Use an absolute path, a relative path, or a URL
+
+# CONFIG_PATH="/alternative/path/to/librechat.yaml"
+
+# Deployment skills are loaded read-only at startup and exposed to all users
+# with the Skills capability enabled. Defaults to project root ./skill.
+# DEPLOYMENT_SKILLS_DIR=./skill
+
+#==================#
+# Langfuse Tracing #
+#==================#
+
+# Get Langfuse API keys for your project from the project settings page: https://cloud.langfuse.com
+
+# LANGFUSE_PUBLIC_KEY=
+# LANGFUSE_SECRET_KEY=
+# LANGFUSE_BASE_URL=
+
+# Optional Langfuse fanout for tenant-scoped Langfuse projects.
+# The fanout gateway is opt-in: add docker-compose.langfuse-fanout.yml,
+# deploy-compose.langfuse-fanout.yml, or enable helm langfuseFanout.
+# Tenant public/secret keys are read from LibreChat tenant app configuration.
+# Tenant Langfuse base URLs must be set in tenant app configuration and match
+# one of the known startup destinations. Tenant API keys can be added or changed
+# at runtime through tenant app configuration.
+# See otel/langfuse-fanout/README.md.
+# LANGFUSE_FANOUT_ENABLED=false
+# LANGFUSE_FANOUT_COLLECTOR_URL=http://langfuse-fanout-collector:4318
+# Emergency switch: unset/false defaults enabled; set true to keep central fanout export but skip tenant trace/score export.
+# LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED=false
+# Langfuse Cloud base URL options: https://cloud.langfuse.com (EU),
+# https://us.cloud.langfuse.com (US), https://jp.cloud.langfuse.com (JP).
+# Gateway-only central trace/media export URL. LibreChat feedback scores use
+# LANGFUSE_BASE_URL, so set both URLs to the same non-EU region when applicable.
+# LANGFUSE_FANOUT_CENTRAL_BASE_URL=https://cloud.langfuse.com
+# Gateway-only Basic auth header for central trace/media export. LibreChat feedback
+# scores use LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY instead.
+# LANGFUSE_FANOUT_CENTRAL_AUTH_HEADER=Basic
+# Compose's included gateway config supports the three listed destination keys.
+# Add custom keys only when the gateway is started with matching destination URLs.
+# LANGFUSE_FANOUT_TENANT_DESTINATIONS=eu=https://cloud.langfuse.com,us=https://us.cloud.langfuse.com,jp=https://jp.cloud.langfuse.com
+# Compose's collector config routes only these destination keys. The gateway
+# fails startup when LANGFUSE_FANOUT_TENANT_DESTINATIONS contains another key.
+# LANGFUSE_FANOUT_TRACE_DESTINATION_KEYS=eu,us,jp
+# Gateway base URL used to build one-time media upload URLs. Compose sets this
+# to its private service URL; Helm derives an internal service URL unless set.
+# LANGFUSE_FANOUT_PUBLIC_URL=http://langfuse-fanout-collector:4318
+# Internal gateway-to-collector trace endpoint. Compose sets this automatically.
+# LANGFUSE_FANOUT_TRACE_COLLECTOR_URL=http://langfuse-fanout-otel:4319
+# Redis-backed one-time upload plans let multiple gateway pods handle Langfuse
+# media create/upload requests. Compose sets this to its private Redis service.
+# LANGFUSE_FANOUT_REDIS_URI=redis://langfuse-fanout-redis:6379
+# LANGFUSE_FANOUT_REDIS_USERNAME=
+# LANGFUSE_FANOUT_REDIS_PASSWORD=
+# LANGFUSE_FANOUT_REDIS_KEY_PREFIX=langfuse-fanout
+# Internal collector receiver bind address. Helm uses 127.0.0.1 because the
+# collector is a sidecar; Compose uses 0.0.0.0 on the private fanout network.
+# LANGFUSE_FANOUT_OTEL_RECEIVER_ENDPOINT=0.0.0.0:4319
+# Static Compose collector destination URLs. Helm derives these from values.
+# LANGFUSE_FANOUT_TENANT_EU_BASE_URL=https://cloud.langfuse.com
+# LANGFUSE_FANOUT_TENANT_US_BASE_URL=https://us.cloud.langfuse.com
+# LANGFUSE_FANOUT_TENANT_JP_BASE_URL=https://jp.cloud.langfuse.com
+# LANGFUSE_FANOUT_UPSTREAM_TIMEOUT=30s
+# Optional bearer token for scraping the fanout gateway /metrics endpoint.
+# If unset, /metrics returns 401. The gateway also accepts METRICS_SECRET when present.
+# LANGFUSE_FANOUT_METRICS_SECRET=
+# LANGFUSE_FANOUT_MEMORY_LIMIT_MIB=256
+# LANGFUSE_FANOUT_MEMORY_SPIKE_LIMIT_MIB=64
+# LANGFUSE_FANOUT_BATCH_TIMEOUT=1s
+# LANGFUSE_FANOUT_BATCH_SEND_SIZE=128
+# LANGFUSE_FANOUT_METADATA_CARDINALITY_LIMIT=1000
+
+#=======================#
+# OpenTelemetry Tracing #
+#=======================#
+
+# Enables backend OpenTelemetry tracing. General backend visibility only;
+# use Langfuse for GenAI-specific prompt/model observability.
+# OTEL_TRACING_ENABLED=false
+# OTEL_SERVICE_NAME=librechat
+# OTEL_SERVICE_VERSION=
+# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
+# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=
+# OTEL_EXPORTER_OTLP_HEADERS=
+# OTEL_TRACES_EXPORTER=otlp
+# OTEL_TRACES_SAMPLER=parentbased_always_on
+# OTEL_LOG_LEVEL=INFO
+# OTEL_SDK_DISABLED=false
+# Enable Redis command-level spans. Disabled by default to keep backend traces high-level.
+# OTEL_IOREDIS_TRACING_ENABLED=false
+
+#===============================#
+# Real User Monitoring (Browser) #
+#===============================#
+
+# Enables browser Real User Monitoring. Disabled by default.
+# Currently supports HyperDX via the browser SDK.
+# RUM_ENABLED=false
+# RUM_PROVIDER=hyperdx
+# RUM_URL=http://localhost:4318
+# RUM_SERVICE_NAME=librechat-web
+# RUM_ENVIRONMENT=development
+
+# Public browser-token mode is suitable for OSS/self-hosted deployments.
+# Treat the token as public and restrict/rate-limit ingestion in your RUM backend.
+# RUM_AUTH_MODE=publicToken
+# RUM_PUBLIC_TOKEN=
+
+# Authenticated proxy mode sends browser telemetry to this LibreChat backend first.
+# The backend validates the LibreChat session, strips app auth, and forwards to the collector.
+# RUM_AUTH_MODE=proxy
+# RUM_PROXY_TARGET_URL=http://otel-collector:4318
+# RUM_PROXY_TIMEOUT_MS=10000
+
+# Optional comma-separated first-party HTTPS origins/URLs that should receive traceparent headers.
+# Wildcards and non-HTTPS targets are ignored.
+# RUM_TRACE_PROPAGATION_TARGETS=https://api.example.com
+
+# Privacy defaults: replay, console capture, and full network body capture stay off.
+# Console/network capture may collect sensitive browser logs, prompts, responses, or payloads.
+# RUM_DISABLE_REPLAY=true
+# RUM_CONSOLE_CAPTURE=false
+# RUM_ADVANCED_NETWORK_CAPTURE=false
+# RUM_SAMPLE_RATE=1
+
+#===================================================#
+# Endpoints #
+#===================================================#
+
+# ENDPOINTS=openAI,assistants,azureOpenAI,google,anthropic
+
+# Optional outbound proxy for server-side requests.
+# PROXY applies to both HTTP and HTTPS targets. When PROXY is unset, LibreChat honors
+# HTTP_PROXY, HTTPS_PROXY, and NO_PROXY/no_proxy for supported server-side clients.
+PROXY=
+# HTTP_PROXY=
+# HTTPS_PROXY=
+# NO_PROXY=
+
+#===================================#
+# Known Endpoints - librechat.yaml #
+#===================================#
+# https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints
+
+# ANYSCALE_API_KEY=
+# APIPIE_API_KEY=
+# COHERE_API_KEY=
+# DEEPSEEK_API_KEY=
+# DATABRICKS_API_KEY=
+# FIREWORKS_API_KEY=
+# GROQ_API_KEY=
+# HUGGINGFACE_TOKEN=
+# MISTRAL_API_KEY=
+# OPENROUTER_KEY=
+# PERPLEXITY_API_KEY=
+# SHUTTLEAI_API_KEY=
+# TOGETHERAI_API_KEY=
+# UNIFY_API_KEY=
+# XAI_API_KEY=
+
+#============#
+# Anthropic #
+#============#
+
+ANTHROPIC_API_KEY=user_provided
+# ANTHROPIC_MODELS=claude-fable-5,claude-opus-4-8,claude-opus-4-7,claude-sonnet-5,claude-sonnet-4-6,claude-opus-4-6,claude-opus-4-20250514,claude-3-7-sonnet-20250219,claude-3-5-sonnet-20241022,claude-3-5-haiku-20241022,claude-3-opus-20240229,claude-3-sonnet-20240229,claude-3-haiku-20240307
+# ANTHROPIC_REVERSE_PROXY=
+
+# Set to true to use Anthropic models through Google Vertex AI instead of direct API
+# ANTHROPIC_USE_VERTEX=
+# Supports regional locations like us-east5 and multi-region locations: us, eu, global
+# ANTHROPIC_VERTEX_REGION=us-east5
+
+#============#
+# Azure #
+#============#
+
+# Note: these variables are DEPRECATED
+# Use the `librechat.yaml` configuration for `azureOpenAI` instead
+# You may also continue to use them if you opt out of using the `librechat.yaml` configuration
+
+# AZURE_OPENAI_DEFAULT_MODEL=gpt-3.5-turbo # Deprecated
+# AZURE_OPENAI_MODELS=gpt-3.5-turbo,gpt-4 # Deprecated
+# AZURE_USE_MODEL_AS_DEPLOYMENT_NAME=TRUE # Deprecated
+# AZURE_API_KEY= # Deprecated
+# AZURE_OPENAI_API_INSTANCE_NAME= # Deprecated
+# AZURE_OPENAI_API_DEPLOYMENT_NAME= # Deprecated
+# AZURE_OPENAI_API_VERSION= # Deprecated
+# AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME= # Deprecated
+# AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME= # Deprecated
+
+#=================#
+# AWS Bedrock #
+#=================#
+# AWS Bedrock credentials
+#
+# Preferred for local development: configure an AWS profile in ~/.aws/config or
+# ~/.aws/credentials, then set BEDROCK_AWS_PROFILE. LibreChat passes this profile
+# to the AWS SDK for JavaScript credential provider chain.
+#
+# In deployed environments, prefer IAM roles or other short-term credentials
+# discoverable by the AWS SDK default credential provider chain. If neither
+# BEDROCK_AWS_PROFILE nor Bedrock-specific static credentials are set, the SDK
+# uses its default provider chain. AWS-standard environment variables still
+# follow AWS SDK precedence.
+#
+# Profiles can use IAM Identity Center, assume-role settings, or credential_process.
+# If you use credential_process, secure the config file and helper command, and do
+# not write secret material to stderr.
+#
+# AWS SDK credential chain:
+# https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html
+# Shared config/profile settings:
+# https://docs.aws.amazon.com/sdkref/latest/guide/settings-reference.html
+# credential_process security notes:
+# https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html
+
+# BEDROCK_AWS_DEFAULT_REGION=us-east-1 # A default region must be provided
+
+# AWS Profile
+# BEDROCK_AWS_PROFILE=your-profile-name
+
+# Static credentials (use only if profiles or IAM roles are not suitable)
+# BEDROCK_AWS_ACCESS_KEY_ID=someAccessKey
+# BEDROCK_AWS_SECRET_ACCESS_KEY=someSecretAccessKey
+# BEDROCK_AWS_SESSION_TOKEN=someSessionToken
+
+# Bedrock API key
+# BEDROCK_AWS_BEARER_TOKEN=yourBedrockApiKey
+
+# Note: This example list is not meant to be exhaustive. If omitted, all known, supported model IDs will be included for you.
+# BEDROCK_AWS_MODELS=anthropic.claude-fable-5,anthropic.claude-opus-4-8,anthropic.claude-opus-4-7,anthropic.claude-sonnet-5,anthropic.claude-sonnet-4-6,anthropic.claude-opus-4-6-v1,anthropic.claude-3-5-sonnet-20240620-v1:0,meta.llama3-1-8b-instruct-v1:0
+# Cross-region inference model IDs: us.anthropic.claude-fable-5,us.anthropic.claude-opus-4-8,us.anthropic.claude-opus-4-7,us.anthropic.claude-sonnet-5,us.anthropic.claude-sonnet-4-6,us.anthropic.claude-opus-4-6-v1,global.anthropic.claude-opus-4-6-v1
+
+# See all Bedrock model IDs here: https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html#model-ids-arns
+
+# Notes on specific models:
+# The following models are not support due to not supporting streaming:
+# ai21.j2-mid-v1
+
+# The following models are not support due to not supporting conversation history:
+# ai21.j2-ultra-v1, cohere.command-text-v14, cohere.command-light-text-v14
+
+# Claude Mythos-class models (anthropic.claude-fable-5, anthropic.claude-mythos-5) are inference-profile
+# only on Bedrock — use a profile ID (e.g. us.anthropic.claude-fable-5) — and require opting into Anthropic
+# data sharing via the Bedrock Data Retention API/console before they can be invoked.
+
+#============#
+# Google #
+#============#
+
+GOOGLE_KEY=user_provided
+
+# GOOGLE_REVERSE_PROXY=
+# Some reverse proxies do not support the X-goog-api-key header, uncomment to pass the API key in Authorization header instead.
+# GOOGLE_AUTH_HEADER=true
+
+# Gemini API (AI Studio)
+# GOOGLE_MODELS=gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite
+
+# Vertex AI
+# GOOGLE_MODELS=gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash-001,gemini-2.0-flash-lite-001
+
+# GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001
+
+# Google Cloud location for Vertex AI (used by both chat and image generation).
+# Supports regional locations like us-central1 and multi-region locations: us, eu, global.
+# GOOGLE_LOC=us-central1
+
+# Alternative region env var for Gemini Image Generation
+# GOOGLE_CLOUD_LOCATION=global
+
+# Vertex AI Service Account Configuration
+# Path to your Google Cloud service account JSON file
+# GOOGLE_SERVICE_KEY_FILE=/path/to/service-account.json
+
+# Google Safety Settings
+# NOTE: These settings apply to both Vertex AI and Gemini API (AI Studio)
+#
+# For Vertex AI:
+# To use the BLOCK_NONE setting, you need either:
+# (a) Access through an allowlist via your Google account team, or
+# (b) Switch to monthly invoiced billing: https://cloud.google.com/billing/docs/how-to/invoiced-billing
+#
+# For Gemini API (AI Studio):
+# BLOCK_NONE is available by default, no special account requirements.
+#
+# Available options: BLOCK_NONE, BLOCK_ONLY_HIGH, BLOCK_MEDIUM_AND_ABOVE, BLOCK_LOW_AND_ABOVE
+#
+# GOOGLE_SAFETY_SEXUALLY_EXPLICIT=BLOCK_ONLY_HIGH
+# GOOGLE_SAFETY_HATE_SPEECH=BLOCK_ONLY_HIGH
+# GOOGLE_SAFETY_HARASSMENT=BLOCK_ONLY_HIGH
+# GOOGLE_SAFETY_DANGEROUS_CONTENT=BLOCK_ONLY_HIGH
+# GOOGLE_SAFETY_CIVIC_INTEGRITY=BLOCK_ONLY_HIGH
+
+#========================#
+# Gemini Image Generation #
+#========================#
+
+# Gemini Image Generation Tool (for Agents)
+# Supports multiple authentication methods in priority order:
+# 1. User-provided API key (via GUI)
+# 2. GEMINI_API_KEY env var (admin-configured)
+# 3. GOOGLE_KEY env var (shared with Google chat endpoint)
+# 4. Vertex AI service account (via GOOGLE_SERVICE_KEY_FILE)
+
+# Option A: Use dedicated Gemini API key for image generation
+# GEMINI_API_KEY=your-gemini-api-key
+
+# Vertex AI model for image generation (defaults to gemini-2.5-flash-image)
+# GEMINI_IMAGE_MODEL=gemini-2.5-flash-image
+
+#============#
+# OpenAI #
+#============#
+
+OPENAI_API_KEY=user_provided
+# OPENAI_MODELS=gpt-5,gpt-5-codex,gpt-5-mini,gpt-5-nano,o3-pro,o3,o4-mini,gpt-4.1,gpt-4.1-mini,gpt-4.1-nano,o3-mini,o1-pro,o1,gpt-4o,gpt-4o-mini
+
+DEBUG_OPENAI=false
+
+# TITLE_CONVO=false
+# OPENAI_TITLE_MODEL=gpt-4o-mini
+
+# OPENAI_SUMMARIZE=true
+# OPENAI_SUMMARY_MODEL=gpt-4o-mini
+
+# OPENAI_FORCE_PROMPT=true
+
+# OPENAI_REVERSE_PROXY=
+
+# OPENAI_ORGANIZATION=
+
+#====================#
+# Assistants API #
+#====================#
+
+ASSISTANTS_API_KEY=user_provided
+# ASSISTANTS_BASE_URL=
+# ASSISTANTS_MODELS=gpt-4o,gpt-4o-mini,gpt-3.5-turbo-0125,gpt-3.5-turbo-16k-0613,gpt-3.5-turbo-16k,gpt-3.5-turbo,gpt-4,gpt-4-0314,gpt-4-32k-0314,gpt-4-0613,gpt-3.5-turbo-0613,gpt-3.5-turbo-1106,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview
+
+#==========================#
+# Azure Assistants API #
+#==========================#
+
+# Note: You should map your credentials with custom variables according to your Azure OpenAI Configuration
+# The models for Azure Assistants are also determined by your Azure OpenAI configuration.
+
+# More info, including how to enable use of Assistants with Azure here:
+# https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints/azure#using-assistants-with-azure
+
+CREDS_KEY=f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0
+CREDS_IV=e2341419ec3dd3d19b13a1a87fafcbfb
+
+# Azure AI Search
+#-----------------
+AZURE_AI_SEARCH_SERVICE_ENDPOINT=
+AZURE_AI_SEARCH_INDEX_NAME=
+AZURE_AI_SEARCH_API_KEY=
+
+AZURE_AI_SEARCH_API_VERSION=
+AZURE_AI_SEARCH_SEARCH_OPTION_QUERY_TYPE=
+AZURE_AI_SEARCH_SEARCH_OPTION_TOP=
+AZURE_AI_SEARCH_SEARCH_OPTION_SELECT=
+
+# OpenAI Image Tools Customization
+#----------------
+# IMAGE_GEN_OAI_API_KEY= # Create or reuse OpenAI API key for image generation tool
+# IMAGE_GEN_OAI_BASEURL= # Custom OpenAI base URL for image generation tool
+# IMAGE_GEN_OAI_AZURE_API_VERSION= # Custom Azure OpenAI deployments
+# IMAGE_GEN_OAI_MODEL=gpt-image-1 # OpenAI image model (e.g., gpt-image-1, gpt-image-1.5)
+# IMAGE_GEN_OAI_DESCRIPTION=
+# IMAGE_GEN_OAI_DESCRIPTION_WITH_FILES=Custom description for image generation tool when files are present
+# IMAGE_GEN_OAI_DESCRIPTION_NO_FILES=Custom description for image generation tool when no files are present
+# IMAGE_EDIT_OAI_DESCRIPTION=Custom description for image editing tool
+# IMAGE_GEN_OAI_PROMPT_DESCRIPTION=Custom prompt description for image generation tool
+# IMAGE_EDIT_OAI_PROMPT_DESCRIPTION=Custom prompt description for image editing tool
+
+# DALL·E
+#----------------
+# DALLE_API_KEY=
+# DALLE3_API_KEY=
+# DALLE2_API_KEY=
+# DALLE3_SYSTEM_PROMPT=
+# DALLE2_SYSTEM_PROMPT=
+# DALLE_REVERSE_PROXY=
+# DALLE3_BASEURL=
+# DALLE2_BASEURL=
+
+# DALL·E (via Azure OpenAI)
+# Note: requires some of the variables above to be set
+#----------------
+# DALLE3_AZURE_API_VERSION=
+# DALLE2_AZURE_API_VERSION=
+
+# Flux
+#-----------------
+FLUX_API_BASE_URL=https://api.us1.bfl.ai
+# FLUX_API_BASE_URL = 'https://api.bfl.ml';
+
+# Get your API key at https://api.us1.bfl.ai/auth/profile
+# FLUX_API_KEY=
+
+# Google
+#-----------------
+GOOGLE_SEARCH_API_KEY=
+GOOGLE_CSE_ID=
+
+# Stable Diffusion
+#-----------------
+SD_WEBUI_URL=http://host.docker.internal:7860
+
+# Tavily
+#-----------------
+TAVILY_API_KEY=
+
+# Traversaal
+#-----------------
+TRAVERSAAL_API_KEY=
+
+# WolframAlpha
+#-----------------
+WOLFRAM_APP_ID=
+
+# Zapier
+#-----------------
+ZAPIER_NLA_API_KEY=
+
+#==================================================#
+# Search #
+#==================================================#
+
+SEARCH=true
+MEILI_NO_ANALYTICS=true
+MEILI_HOST=http://0.0.0.0:7700
+MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt
+
+# Optional: Disable indexing, useful in a multi-node setup
+# where only one instance should perform an index sync.
+# MEILI_NO_SYNC=true
+
+#==================================================#
+# Speech to Text & Text to Speech #
+#==================================================#
+
+STT_API_KEY=
+TTS_API_KEY=
+
+#==================================================#
+# RAG #
+#==================================================#
+# More info: https://www.librechat.ai/docs/configuration/rag_api
+
+# RAG_API_URL=http://rag_api:8000
+# RAG_OPENAI_BASEURL=
+# RAG_OPENAI_API_KEY=
+# RAG_USE_FULL_CONTEXT=
+# EMBEDDINGS_PROVIDER=openai
+# EMBEDDINGS_MODEL=text-embedding-3-small
+
+#===================================================#
+# User System #
+#===================================================#
+
+#========================#
+# Moderation #
+#========================#
+
+OPENAI_MODERATION=false
+OPENAI_MODERATION_API_KEY=
+# OPENAI_MODERATION_REVERSE_PROXY=
+
+BAN_VIOLATIONS=true
+BAN_DURATION=1000 * 60 * 60 * 2
+BAN_INTERVAL=20
+
+LOGIN_VIOLATION_SCORE=1
+REGISTRATION_VIOLATION_SCORE=1
+CONCURRENT_VIOLATION_SCORE=1
+MESSAGE_VIOLATION_SCORE=1
+NON_BROWSER_VIOLATION_SCORE=20
+TTS_VIOLATION_SCORE=0
+STT_VIOLATION_SCORE=0
+FORK_VIOLATION_SCORE=0
+IMPORT_VIOLATION_SCORE=0
+FILE_UPLOAD_VIOLATION_SCORE=0
+
+LOGIN_MAX=7
+LOGIN_WINDOW=5
+REGISTER_MAX=5
+REGISTER_WINDOW=60
+
+LIMIT_CONCURRENT_MESSAGES=true
+CONCURRENT_MESSAGE_MAX=2
+
+LIMIT_MESSAGE_IP=true
+MESSAGE_IP_MAX=40
+MESSAGE_IP_WINDOW=1
+
+LIMIT_MESSAGE_USER=false
+MESSAGE_USER_MAX=40
+MESSAGE_USER_WINDOW=1
+
+ILLEGAL_MODEL_REQ_SCORE=5
+
+#========================#
+# Balance #
+#========================#
+
+# CHECK_BALANCE=false
+# START_BALANCE=20000 # note: the number of tokens that will be credited after registration.
+
+#========================#
+# Registration and Login #
+#========================#
+
+ALLOW_EMAIL_LOGIN=true
+ALLOW_REGISTRATION=true
+ALLOW_SOCIAL_LOGIN=false
+ALLOW_SOCIAL_REGISTRATION=false
+ALLOW_PASSWORD_RESET=false
+# ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out
+ALLOW_UNVERIFIED_EMAIL_LOGIN=true
+
+SESSION_EXPIRY=1000 * 60 * 15
+REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7
+# Overrides the Secure attribute for session/auth cookies when set to true or false;
+# leave unset to use the default NODE_ENV/DOMAIN_SERVER heuristic.
+# Set to false only for HTTP-only deployments where browsers drop Secure cookies.
+# SESSION_COOKIE_SECURE=false
+
+JWT_SECRET=16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef
+JWT_REFRESH_SECRET=eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418
+
+# Discord
+DISCORD_CLIENT_ID=
+DISCORD_CLIENT_SECRET=
+DISCORD_CALLBACK_URL=/oauth/discord/callback
+
+# Facebook
+FACEBOOK_CLIENT_ID=
+FACEBOOK_CLIENT_SECRET=
+FACEBOOK_CALLBACK_URL=/oauth/facebook/callback
+
+# GitHub
+GITHUB_CLIENT_ID=
+GITHUB_CLIENT_SECRET=
+GITHUB_CALLBACK_URL=/oauth/github/callback
+# GitHub Enterprise
+# GITHUB_ENTERPRISE_BASE_URL=
+# GITHUB_ENTERPRISE_USER_AGENT=
+
+# Google
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+GOOGLE_CALLBACK_URL=/oauth/google/callback
+
+# Apple
+APPLE_CLIENT_ID=
+APPLE_TEAM_ID=
+APPLE_KEY_ID=
+APPLE_PRIVATE_KEY_PATH=
+APPLE_CALLBACK_URL=/oauth/apple/callback
+
+# OpenID
+OPENID_CLIENT_ID=
+OPENID_CLIENT_SECRET=
+OPENID_ISSUER=
+OPENID_SESSION_SECRET=
+OPENID_SCOPE="openid profile email"
+OPENID_CALLBACK_URL=/oauth/openid/callback
+# Admin panel SSO uses ${DOMAIN_SERVER}/api/admin/oauth/openid/callback as the
+# OpenID provider redirect URI.
+OPENID_REQUIRED_ROLE=
+OPENID_REQUIRED_ROLE_TOKEN_KIND=
+OPENID_REQUIRED_ROLE_PARAMETER_PATH=
+OPENID_ADMIN_ROLE=
+OPENID_ADMIN_ROLE_PARAMETER_PATH=
+OPENID_ADMIN_ROLE_TOKEN_KIND=
+# Generic OpenID role sync maps non-admin IdP roles/groups to one LibreChat role.
+# ADMIN cannot be assigned by generic role sync; use OPENID_ADMIN_ROLE for admin elevation.
+# Role priority is ordered from most important to least important.
+OPENID_ROLE_SYNC_ENABLED=false
+OPENID_ROLE_SYNC_API_ENABLED=false
+OPENID_ROLE_SYNC_SOURCE=id
+OPENID_ROLE_SYNC_CLAIM=
+OPENID_ROLE_SYNC_ROLE_PRIORITY=
+# Fallback is authoritative when configured: if no priority role matches, this role is assigned.
+OPENID_ROLE_SYNC_FALLBACK_ROLE=
+# Set to determine which user info property returned from OpenID Provider to store as the User's username
+OPENID_USERNAME_CLAIM=
+# Set to determine which user info property returned from OpenID Provider to store as the User's name
+OPENID_NAME_CLAIM=
+# Set to determine which user info claim to use as the email/identifier for user matching (e.g., "upn" for Entra ID)
+# When not set, defaults to: email -> preferred_username -> upn
+OPENID_EMAIL_CLAIM=
+# Optional audience parameter for OpenID authorization requests and JWT validation.
+# If comma-separated values are provided, JWT validation accepts all values and
+# authorization requests use the first non-empty value.
+OPENID_AUDIENCE=
+# Optional audience parameter for OpenID refresh token requests.
+# Some providers, such as Auth0 custom APIs, require this to preserve
+# the intended access-token audience during refresh. Usually matches OPENID_AUDIENCE.
+OPENID_REFRESH_AUDIENCE=
+
+OPENID_BUTTON_LABEL=
+OPENID_IMAGE_URL=
+# Set to true to automatically redirect to the OpenID provider when a user visits the login page
+# This will bypass the login form completely for users, only use this if OpenID is your only authentication method
+OPENID_AUTO_REDIRECT=false
+# Set to true to use PKCE (Proof Key for Code Exchange) for OpenID authentication.
+# For public clients (no client secret), leave OPENID_CLIENT_SECRET empty and set this to true.
+OPENID_USE_PKCE=false
+#Set to true to reuse openid tokens for authentication management instead of using the mongodb session and the custom refresh token.
+OPENID_REUSE_TOKENS=
+#Max age a reused OpenID session token is served before LibreChat forces an IdP refresh. Default 900000 ms (15 min).
+#Accepts an arithmetic expression like SESSION_EXPIRY (e.g. 60 * 60 * 24 * 1000 for 24h).
+#Raise toward the IdP access-token lifetime when the IdP revokes the previous access token on refresh, so a still-valid token
+#is not rotated/revoked out from under downstream consumers (e.g. MCP servers that introspect the bearer).
+#When OPENID_REUSE_TOKENS=true, the OpenID session cookie maxAge is extended to at least this value.
+OPENID_REUSE_MAX_SESSION_AGE_MS=
+#By default, signing key verification results are cached in order to prevent excessive HTTP requests to the JWKS endpoint.
+#If a signing key matching the kid is found, this will be cached and the next time this kid is requested the signing key will be served from the cache.
+#Default is true.
+OPENID_JWKS_URL_CACHE_ENABLED=
+OPENID_JWKS_URL_CACHE_TIME= # 600000 ms eq to 10 minutes leave empty to disable caching
+#Set to true to trigger token exchange flow to acquire access token for the userinfo endpoint.
+OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED=
+OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE="user.read" # example for Scope Needed for Microsoft Graph API
+# Set to true to use the OpenID Connect end session endpoint for logout
+OPENID_USE_END_SESSION_ENDPOINT=
+# URL to redirect to after OpenID logout (defaults to ${DOMAIN_CLIENT}/login)
+OPENID_POST_LOGOUT_REDIRECT_URI=
+# Maximum logout URL length before using logout_hint instead of id_token_hint (default: 2000)
+OPENID_MAX_LOGOUT_URL_LENGTH=
+
+#========================#
+# SharePoint Integration #
+#========================#
+# Requires Entra ID (OpenID) authentication to be configured
+
+# Enable SharePoint file picker in chat and agent panels
+# ENABLE_SHAREPOINT_FILEPICKER=true
+
+# SharePoint tenant base URL (e.g., https://yourtenant.sharepoint.com)
+# SHAREPOINT_BASE_URL=https://yourtenant.sharepoint.com
+
+# Microsoft Graph API And SharePoint scopes for file picker
+# SHAREPOINT_PICKER_SHAREPOINT_SCOPE==https://yourtenant.sharepoint.com/AllSites.Read
+# SHAREPOINT_PICKER_GRAPH_SCOPE=Files.Read.All
+#========================#
+
+# SAML
+# Note: If OpenID is enabled, SAML authentication will be automatically disabled.
+SAML_ENTRY_POINT=
+SAML_ISSUER=
+SAML_CERT=
+SAML_CALLBACK_URL=/oauth/saml/callback
+SAML_SESSION_SECRET=
+
+# Attribute mappings (optional)
+SAML_EMAIL_CLAIM=
+SAML_USERNAME_CLAIM=
+SAML_GIVEN_NAME_CLAIM=
+SAML_FAMILY_NAME_CLAIM=
+SAML_PICTURE_CLAIM=
+SAML_NAME_CLAIM=
+
+# Logint buttion settings (optional)
+SAML_BUTTON_LABEL=
+SAML_IMAGE_URL=
+
+# Whether the SAML Response should be signed.
+# - If "true", the entire `SAML Response` will be signed.
+# - If "false" or unset, only the `SAML Assertion` will be signed (default behavior).
+# SAML_USE_AUTHN_RESPONSE_SIGNED=
+
+
+#===============================================#
+# Microsoft Graph API / Entra ID Integration #
+#===============================================#
+
+# Enable Entra ID people search integration in permissions/sharing system
+# When enabled, the people picker will search both local database and Entra ID
+USE_ENTRA_ID_FOR_PEOPLE_SEARCH=false
+
+# When enabled, entra id groups owners will be considered as members of the group
+ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS=false
+
+# Microsoft Graph API scopes needed for people/group search
+# Default scopes provide access to user profiles and group memberships
+OPENID_GRAPH_SCOPES=User.Read,People.Read,GroupMember.Read.All
+
+# LDAP
+LDAP_URL=
+LDAP_BIND_DN=
+LDAP_BIND_CREDENTIALS=
+LDAP_USER_SEARCH_BASE=
+#LDAP_SEARCH_FILTER="mail="
+LDAP_CA_CERT_PATH=
+# LDAP_TLS_REJECT_UNAUTHORIZED=
+# LDAP_STARTTLS=
+# LDAP_LOGIN_USES_USERNAME=true
+# LDAP_ID=
+# LDAP_USERNAME=
+# LDAP_EMAIL=
+# LDAP_FULL_NAME=
+
+#========================#
+# Email Password Reset #
+#========================#
+
+EMAIL_SERVICE=
+EMAIL_HOST=
+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=
+EMAIL_FROM=noreply@librechat.ai
+
+#========================#
+# Mailgun API #
+#========================#
+
+# MAILGUN_API_KEY=your-mailgun-api-key
+# MAILGUN_DOMAIN=mg.yourdomain.com
+# EMAIL_FROM=noreply@yourdomain.com
+# EMAIL_FROM_NAME="LibreChat"
+
+# # Optional: For EU region
+# MAILGUN_HOST=https://api.eu.mailgun.net
+
+#========================#
+# Firebase CDN #
+#========================#
+
+FIREBASE_API_KEY=
+FIREBASE_AUTH_DOMAIN=
+FIREBASE_PROJECT_ID=
+FIREBASE_STORAGE_BUCKET=
+FIREBASE_MESSAGING_SENDER_ID=
+FIREBASE_APP_ID=
+
+#========================#
+# S3 AWS Bucket #
+#========================#
+
+AWS_ENDPOINT_URL=
+AWS_ACCESS_KEY_ID=
+AWS_SECRET_ACCESS_KEY=
+AWS_REGION=
+AWS_BUCKET_NAME=
+# Required for path-style S3-compatible providers (MinIO, Hetzner, Backblaze B2, etc.)
+# that don't support virtual-hosted-style URLs (bucket.endpoint). Not needed for AWS S3.
+# AWS_FORCE_PATH_STYLE=false
+# Required for CloudFront signed cookies and signed download URLs
+# CLOUDFRONT_KEY_PAIR_ID=
+# CLOUDFRONT_PRIVATE_KEY=
+
+#========================#
+# Azure Blob Storage #
+#========================#
+
+AZURE_STORAGE_CONNECTION_STRING=
+AZURE_STORAGE_PUBLIC_ACCESS=false
+AZURE_CONTAINER_NAME=files
+
+#========================#
+# Shared Links #
+#========================#
+
+ALLOW_SHARED_LINKS=true
+# Allows unauthenticated access to shared links. Defaults to false (auth required) if not set.
+ALLOW_SHARED_LINKS_PUBLIC=false
+# Snapshot files referenced by a shared chat so viewers can preview/download them through
+# the shared link (instead of the owner's file ACL). Enabled by default; overrides the
+# `interface.sharedLinks.snapshotFiles` yaml setting when set.
+# SHARED_LINKS_SNAPSHOT_FILES=true
+
+#==============================#
+# Static File Cache Control #
+#==============================#
+
+# Leave commented out to use defaults: 1 day (86400 seconds) for s-maxage and 2 days (172800 seconds) for max-age
+# NODE_ENV must be set to production for these to take effect
+# STATIC_CACHE_MAX_AGE=172800
+# STATIC_CACHE_S_MAX_AGE=86400
+
+# If you have another service in front of your LibreChat doing compression, disable express based compression here
+# DISABLE_COMPRESSION=true
+
+# Serve precompressed Brotli versions of static app assets when available.
+# ENABLE_STATIC_ASSET_BROTLI=true
+
+# If you have gzipped version of uploaded image images in the same folder, this will enable gzip scan and serving of these images
+# Note: The images folder will be scanned on startup and a ma kept in memory. Be careful for large number of images.
+# ENABLE_IMAGE_OUTPUT_GZIP_SCAN=true
+
+#===================================================#
+# UI #
+#===================================================#
+
+APP_TITLE=LibreChat
+# CUSTOM_FOOTER="My custom footer"
+HELP_AND_FAQ_URL=https://librechat.ai
+
+# SHOW_BIRTHDAY_ICON=true
+
+# Google tag manager id
+#ANALYTICS_GTM_ID=user provided google tag manager id
+
+# limit conversation file imports to a certain number of bytes in size to avoid the container
+# maxing out memory limitations by unremarking this line and supplying a file size in bytes
+# such as the below example of 250 mib
+# CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES=262144000
+
+# Max size (bytes) of a code-execution artifact (docx/xlsx/csv/pptx/text/pdf) rendered as an
+# inline preview. Larger files fall back to download-only. Default: 2 MB (2097152). Note the
+# rendered HTML is independently capped at 512 KB, so very rich files may still skip preview.
+# FILE_PREVIEW_MAX_EXTRACT_BYTES=2097152
+
+
+#===============#
+# REDIS Options #
+#===============#
+
+# Enable Redis for caching and session storage
+# USE_REDIS=true
+# Enable Redis for resumable LLM streams (defaults to USE_REDIS value if not set)
+# Set to false to use in-memory storage for streams while keeping Redis for other caches
+# USE_REDIS_STREAMS=true
+
+# Single Redis instance
+# REDIS_URI=redis://127.0.0.1:6379
+
+# Redis cluster (multiple nodes)
+# REDIS_URI=redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
+# Enable Redis cluster mode when connecting to a cluster through a single URI
+# USE_REDIS_CLUSTER=true
+
+# Managed Redis services with a single endpoint may shard keys internally and reject multi-key DEL
+# Set to true to delete keys individually and avoid CROSSSLOT errors while keeping single-node mode
+# REDIS_CLUSTER_SAFE_DELETE=true
+
+# Redis with TLS/SSL encryption and CA certificate
+# REDIS_URI=rediss://127.0.0.1:6380
+# REDIS_CA=/path/to/ca-cert.pem
+
+# Elasticache may need to use an alternate dnsLookup for TLS connections. see "Special Note: Aws Elasticache Clusters with TLS" on this webpage: https://www.npmjs.com/package/ioredis
+# Enable alternative dnsLookup for redis
+# REDIS_USE_ALTERNATIVE_DNS_LOOKUP=true
+
+# Redis authentication (if required)
+# REDIS_USERNAME=your_redis_username
+# REDIS_PASSWORD=your_redis_password
+
+# Redis key prefix configuration
+# Use environment variable name for dynamic prefix (recommended for cloud deployments)
+# REDIS_KEY_PREFIX_VAR=K_REVISION
+# Or use static prefix directly
+# REDIS_KEY_PREFIX=librechat
+
+# Redis connection limits
+# REDIS_MAX_LISTENERS=40
+
+# Redis ping interval in seconds (0 = disabled, >0 = enabled)
+# When set to a positive integer, Redis clients will ping the server at this interval to keep connections alive
+# When unset or 0, no pinging is performed (recommended for most use cases)
+# REDIS_PING_INTERVAL=300
+
+# Force specific cache namespaces to use in-memory storage even when Redis is enabled
+# Comma-separated list of CacheKeys
+# Defaults to CONFIG_STORE,APP_CONFIG so YAML-derived config stays per-container (safe for blue/green deployments)
+# Set to empty string to force all namespaces through Redis: FORCED_IN_MEMORY_CACHE_NAMESPACES=
+# FORCED_IN_MEMORY_CACHE_NAMESPACES=CONFIG_STORE,APP_CONFIG
+
+# TTL in milliseconds for cached group memberships used in ACL permission checks (default: 300000 / 5 minutes; 0 disables)
+# Membership changes invalidate affected entries immediately; the TTL bounds staleness from cross-process races.
+# USER_PRINCIPALS_CACHE_TTL_MS=300000
+# Redis lock TTL in milliseconds for cross-container cache builds (default: 5000)
+# 0 disables build locking only; the delayed stale-rewrite eviction pass still runs on Redis-backed stores.
+# Only used when the USER_PRINCIPALS namespace is Redis-backed; non-Redis deployments use in-process deduplication.
+# USER_PRINCIPALS_LOCK_TTL_MS=5000
+# Maximum time in milliseconds to wait for another container holding the lock to fill the cache
+# before falling back to a direct database read (default: USER_PRINCIPALS_LOCK_TTL_MS)
+# USER_PRINCIPALS_LOCK_WAIT_MS=5000
+
+# Leader Election Configuration (for multi-instance deployments with Redis)
+# Duration in seconds that the leader lease is valid before it expires (default: 25)
+# LEADER_LEASE_DURATION=25
+# Interval in seconds at which the leader renews its lease (default: 10)
+# LEADER_RENEW_INTERVAL=10
+# Maximum number of retry attempts when renewing the lease fails (default: 3)
+# LEADER_RENEW_ATTEMPTS=3
+# Delay in seconds between retry attempts when renewing the lease (default: 0.5)
+# LEADER_RENEW_RETRY_DELAY=0.5
+
+#==================================================#
+# Others #
+#==================================================#
+# You should leave the following commented out #
+
+# NODE_ENV=
+
+# E2E_USER_EMAIL=
+# E2E_USER_PASSWORD=
+
+#=====================================================#
+# Cache Headers #
+#=====================================================#
+# Headers that control caching of the index.html #
+# Default configuration prevents caching to ensure #
+# users always get the latest version. Customize #
+# only if you understand caching implications. #
+
+# INDEX_CACHE_CONTROL=no-cache, no-store, must-revalidate
+# INDEX_PRAGMA=no-cache
+# INDEX_EXPIRES=0
+
+# no-cache: Forces validation with server before using cached version
+# no-store: Prevents storing the response entirely
+# must-revalidate: Prevents using stale content when offline
+
+#=====================================================#
+# OpenWeather #
+#=====================================================#
+OPENWEATHER_API_KEY=
+
+#======================#
+# Web Search #
+#======================#
+
+# Note: All of the following variable names can be customized.
+# Omit values to allow user to provide them.
+
+# For more information on configuration values, see:
+# https://librechat.ai/docs/features/web_search
+
+# Search Provider (Required)
+# SERPER_API_KEY=your_serper_api_key
+
+# Tavily (Search Provider and/or Scraper)
+# TAVILY_API_KEY=your_tavily_api_key
+
+# Scraper (Required)
+# FIRECRAWL_API_KEY=your_firecrawl_api_key
+# Optional: Custom Firecrawl API URL
+# FIRECRAWL_API_URL=your_firecrawl_api_url
+
+# Reranker (Required)
+# JINA_API_KEY=your_jina_api_key
+# or
+# COHERE_API_KEY=your_cohere_api_key
+
+#======================#
+# MCP Configuration #
+#======================#
+
+# Treat 401/403 responses as OAuth requirement when no oauth metadata found
+# MCP_OAUTH_ON_AUTH_ERROR=true
+
+# Timeout for OAuth detection requests in milliseconds
+# MCP_OAUTH_DETECTION_TIMEOUT=5000
+
+# How long to wait (ms) for a user to complete the OAuth flow before timing out (default: 10 minutes)
+# MCP_OAUTH_HANDLING_TIMEOUT=600000
+
+# TTL (ms) for OAuth flow state; must outlive MCP_OAUTH_HANDLING_TIMEOUT (default: 15 minutes)
+# MCP_OAUTH_FLOW_TTL=900000
+
+# Cache connection status checks for this many milliseconds to avoid expensive verification
+# MCP_CONNECTION_CHECK_TTL=60000
+
+# Max bytes allowed in a non-GET streamable HTTP MCP response before rejecting it.
+# Set to 0 to disable. Default: 16777216 (16 MiB)
+# MCP_STREAMABLE_HTTP_MAX_RESPONSE_BYTES=16777216
+
+# Max bytes allowed in a single SSE line for non-GET streamable HTTP MCP responses.
+# Set to 0 to disable. Default: 5242880 (5 MiB)
+# MCP_STREAMABLE_HTTP_MAX_LINE_BYTES=5242880
+
+# Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it)
+# When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration
+# MCP_SKIP_CODE_CHALLENGE_CHECK=false
+
+# Circuit breaker: max connect/disconnect cycles before tripping (per server)
+# MCP_CB_MAX_CYCLES=7
+
+# Circuit breaker: sliding window (ms) for counting cycles
+# MCP_CB_CYCLE_WINDOW_MS=45000
+
+# Circuit breaker: cooldown (ms) after the cycle breaker trips
+# MCP_CB_CYCLE_COOLDOWN_MS=15000
+
+# Circuit breaker: max consecutive failed connection rounds before backoff
+# MCP_CB_MAX_FAILED_ROUNDS=3
+
+# Circuit breaker: sliding window (ms) for counting failed rounds
+# MCP_CB_FAILED_WINDOW_MS=120000
+
+# Circuit breaker: base backoff (ms) after failed round threshold is reached
+# MCP_CB_BASE_BACKOFF_MS=30000
+
+# Circuit breaker: max backoff cap (ms) for exponential backoff
+# MCP_CB_MAX_BACKOFF_MS=300000
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..725ac8b
--- /dev/null
+++ b/.gitattributes
@@ -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
diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..cb767cb
--- /dev/null
+++ b/.github/CODE_OF_CONDUCT.md
@@ -0,0 +1,132 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the
+ overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or
+ advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email
+ address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement here on GitHub or
+on the official [Discord Server](https://discord.librechat.ai).
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.0, available at
+https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct
+enforcement ladder](https://github.com/mozilla/diversity).
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see the FAQ at
+https://www.contributor-covenant.org/faq. Translations are available at
+https://www.contributor-covenant.org/translations.
+
+---
+
+## [Go Back to ReadMe](../README.md)
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
new file mode 100644
index 0000000..6524947
--- /dev/null
+++ b/.github/CONTRIBUTING.md
@@ -0,0 +1,159 @@
+# Contributor Guidelines
+
+Thank you to all the contributors who have helped make this project possible! We welcome various types of contributions, such as bug reports, documentation improvements, feature requests, and code contributions.
+
+## Contributing Guidelines
+
+If the feature you would like to contribute has not already received prior approval from the project maintainers (i.e., the feature is currently on the [roadmap](https://github.com/users/danny-avila/projects/2)), please submit a request in the [Feature Requests & Suggestions category](https://github.com/danny-avila/LibreChat/discussions/new?category=feature-requests-suggestions) of the discussions board before beginning work on it. The requests should include specific implementation details, including areas of the application that will be affected by the change (including designs if applicable), and any other relevant information that might be required for a speedy review. However, proposals are not required for small changes, bug fixes, or documentation improvements. Small changes and bug fixes should be tied to an [issue](https://github.com/danny-avila/LibreChat/issues) and included in the corresponding pull request for tracking purposes.
+
+Please note that a pull request involving a feature that has not been reviewed and approved by the project maintainers may be rejected. We appreciate your understanding and cooperation.
+
+If you would like to discuss the changes you wish to make, join our [Discord community](https://discord.librechat.ai), where you can engage with other contributors and seek guidance from the community.
+
+## Our Standards
+
+We strive to maintain a positive and inclusive environment within our project community. We expect all contributors to adhere to the following standards:
+
+- Using welcoming and inclusive language.
+- Being respectful of differing viewpoints and experiences.
+- Gracefully accepting constructive criticism.
+- Focusing on what is best for the community.
+- Showing empathy towards other community members.
+
+Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that do not align with these standards.
+
+## To contribute to this project, please adhere to the following guidelines:
+
+## 1. Development Setup
+
+1. Use Node.js v24.16.0.
+2. Run `npm run smart-reinstall` to install dependencies (uses Turborepo). Use `npm run reinstall` for a clean install, or `npm ci` for a fresh lockfile-based install.
+3. Build all compiled code: `npm run build`.
+4. Setup and run unit tests:
+ - Copy `.env.test`: `cp api/test/.env.test.example api/test/.env.test`.
+ - Run backend unit tests: `npm run test:api`.
+ - Run frontend unit tests: `npm run test:client`.
+5. Setup and run integration tests:
+ - Create `.env`: `cp .env.example .env`.
+ - Install [MongoDB Community Edition](https://www.mongodb.com/docs/manual/administration/install-community/), ensure that `mongosh` connects to your local instance.
+ - Run: `npx install playwright`, then `npx playwright install`.
+ - Copy `config.local`: `cp e2e/config.local.example.ts e2e/config.local.ts`.
+ - Copy `librechat.yaml`: `cp librechat.example.yaml librechat.yaml`.
+ - Run: `npm run e2e`.
+
+## 2. Development Notes
+
+1. Before starting work, make sure your main branch has the latest commits with `npm run update`.
+2. Run linting command to find errors: `npm run lint`. Alternatively, ensure husky pre-commit checks are functioning.
+3. After your changes, reinstall packages in your current branch using `npm run reinstall` and ensure everything still works.
+ - Restart the ESLint server ("ESLint: Restart ESLint Server" in VS Code command bar) and your IDE after reinstalling or updating.
+4. Clear web app localStorage and cookies before and after changes.
+5. To check for introduced errors, build all compiled code: `npm run build`.
+6. Run backend unit tests: `npm run test:api`.
+7. Run frontend unit tests: `npm run test:client`.
+8. Run integration tests: `npm run e2e`.
+
+## 3. Git Workflow
+
+We utilize a GitFlow workflow to manage changes to this project's codebase. Follow these general steps when contributing code:
+
+1. Fork the repository and create a new branch with a descriptive slash-based name (e.g., `new/feature/x`).
+2. Implement your changes and ensure that all tests pass.
+3. Commit your changes using conventional commit messages with GitFlow flags. Begin the commit message with a tag indicating the change type, such as "feat" (new feature), "fix" (bug fix), "docs" (documentation), or "refactor" (code refactoring), followed by a brief summary of the changes (e.g., `feat: Add new feature X to the project`).
+4. Submit a pull request with a clear and concise description of your changes and the reasons behind them.
+5. We will review your pull request, provide feedback as needed, and eventually merge the approved changes into the main branch.
+
+## 4. Commit Message Format
+
+We follow the [semantic format](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716) for commit messages.
+
+### Example
+
+```
+feat: add hat wobble
+^--^ ^------------^
+| |
+| +-> Summary in present tense.
+|
++-------> Type: chore, docs, feat, fix, refactor, style, or test.
+```
+
+### Commit Guidelines
+- Do your best to reduce the number of commits, organizing them as much possible. Look into [squashing commits](https://www.freecodecamp.org/news/git-squash-commits/) in order to keep a neat history.
+- For those that care about maximizing commits for stats, adhere to the above as I 'squash and merge' an unorganized and/or unformatted commit history, which reduces the number of your commits to 1,:
+```
+* Update Br.tsx
+
+* Update Es.tsx
+
+* Update Br.tsx
+```
+
+
+## 5. Pull Request Process
+
+When submitting a pull request, please follow these guidelines:
+
+- Ensure that any installation or build dependencies are removed before the end of the layer when doing a build.
+- Update the README.md with details of changes to the interface, including new environment variables, exposed ports, useful file locations, and container parameters.
+- Increase the version numbers in any example files and the README.md to reflect the new version that the pull request represents. We use [SemVer](http://semver.org/) for versioning.
+
+Ensure that your changes meet the following criteria:
+
+- All tests pass as highlighted [above](#1-development-notes).
+- The code is well-formatted and adheres to our coding standards.
+- The commit history is clean and easy to follow. You can use `git rebase` or `git merge --squash` to clean your commit history before submitting the pull request.
+- The pull request description clearly outlines the changes and the reasons behind them. Be sure to include the steps to test the pull request.
+
+## 6. Naming Conventions
+
+Apply the following naming conventions to branches, labels, and other Git-related entities:
+
+- **Branch names:** Descriptive and slash-based (e.g., `new/feature/x`).
+- **Labels:** Descriptive and kebab case (e.g., `bug-fix`).
+- **JS/TS:** Directories and file names: Descriptive and camelCase. First letter uppercased for React files (e.g., `helperFunction.ts, ReactComponent.tsx`).
+- **Docs:** Directories and file names: Descriptive and snake_case (e.g., `config_files.md`).
+
+## 7. Coding Standards
+
+For detailed coding conventions, workspace boundaries, and architecture guidance, refer to the [`AGENTS.md`](../AGENTS.md) file at the project root. It covers code style, type safety, import ordering, iteration/performance expectations, frontend rules, testing, and development commands.
+
+## 8. TypeScript Conversion
+
+1. **Original State**: The project was initially developed entirely in JavaScript (JS).
+
+2. **Frontend**: Fully transitioned to TypeScript.
+
+3. **Backend**:
+ - The legacy Express.js server remains in `/api` as JavaScript.
+ - All new backend code is written in TypeScript under `/packages/api`, which is compiled and consumed by `/api`.
+ - Shared database logic lives in `/packages/data-schemas` (TypeScript).
+ - Shared frontend/backend API types and services live in `/packages/data-provider` (TypeScript).
+ - Minimize direct changes to `/api`; prefer adding TypeScript code to `/packages/api` and importing it.
+
+## 9. Module Import Conventions
+
+Imports are organized into three sections (in order):
+
+1. **Package imports** — sorted from shortest to longest line length.
+ - `react` is always the first import.
+ - Multi-line (stacked) imports count their total character length across all lines for sorting.
+
+2. **`import type` imports** — sorted from longest to shortest line length.
+ - Package type imports come first, then local type imports.
+ - Line length sorting resets between the package and local sub-groups.
+
+3. **Local/project imports** — sorted from longest to shortest line length.
+ - Multi-line (stacked) imports count their total character length across all lines for sorting.
+ - Imports with alias `~` are treated the same as relative imports with respect to line length.
+
+- Consolidate value imports from the same module as much as possible.
+- Always use standalone `import type { ... }` for type imports; never use inline `type` keyword inside value imports (e.g., `import { Foo, type Bar }` is wrong).
+
+**Note:** ESLint will automatically enforce these import conventions when you run `npm run lint --fix` or through pre-commit hooks.
+
+For the full set of coding standards, see [`AGENTS.md`](../AGENTS.md).
+
+---
+
+## [Go Back to ReadMe](../README.md)
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..37ef799
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,13 @@
+# These are supported funding model platforms
+
+github: [danny-avila]
+patreon: # Replace with a single Patreon username
+open_collective: # Replace with a single Open Collective username
+ko_fi: # Replace with a single Ko-fi username
+tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
+community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
+liberapay: # Replace with a single Liberapay username
+issuehunt: # Replace with a single IssueHunt username
+otechie: # Replace with a single Otechie username
+lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
+custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml
new file mode 100644
index 0000000..e7ef45f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml
@@ -0,0 +1,93 @@
+name: Bug Report
+description: File a bug report
+title: "[Bug]: "
+labels: ["🐛 bug"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to fill out this bug report!
+
+ Before submitting, please:
+ - Search existing [Issues and Discussions](https://github.com/danny-avila/LibreChat/discussions) to see if your bug has already been reported
+ - Use [Discussions](https://github.com/danny-avila/LibreChat/discussions) instead of Issues for:
+ - General inquiries
+ - Help with setup
+ - Questions about whether you're experiencing a bug
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: What happened?
+ description: Also tell us, what did you expect to happen?
+ placeholder: Please give as many details as possible
+ validations:
+ required: true
+ - type: textarea
+ id: version-info
+ attributes:
+ label: Version Information
+ description: |
+ In LibreChat, open **Settings → About** and click **Copy diagnostics**, then paste the result here.
+ This captures the exact version, commit, branch, and build date so maintainers can pinpoint the build you're running.
+
+ If the About panel is unavailable (older version / self-hosted with it disabled), please provide as much of the following as possible instead:
+ - Docker: `docker images | grep librechat` (image tag) and `docker inspect | grep -i "\"Commit\\|BUILD_"` if build args were set
+ - Source: `git rev-parse HEAD` and `git rev-parse --abbrev-ref HEAD`
+ placeholder: Paste the diagnostics block here
+ validations:
+ required: true
+ - type: textarea
+ id: steps-to-reproduce
+ attributes:
+ label: Steps to Reproduce
+ description: Please list the steps needed to reproduce the issue.
+ placeholder: "1. Step 1\n2. Step 2\n3. Step 3"
+ validations:
+ required: true
+ - type: dropdown
+ id: browsers
+ attributes:
+ label: What browsers are you seeing the problem on?
+ multiple: true
+ options:
+ - Firefox
+ - Chrome
+ - Safari
+ - Microsoft Edge
+ - Mobile (iOS)
+ - Mobile (Android)
+ - type: textarea
+ id: logs
+ attributes:
+ label: Relevant log output
+ description: |
+ Please paste relevant logs that were created when reproducing the error.
+
+ Log locations:
+ - Docker: Project root directory ./logs
+ - npm: ./api/logs
+
+ There are two types of logs that can help diagnose the issue:
+ - debug logs (debug-YYYY-MM-DD.log)
+ - error logs (error-YYYY-MM-DD.log)
+
+ Error logs contain exact stack traces and are especially helpful, but both can provide valuable information.
+ Please only include the relevant portions of logs that correspond to when you reproduced the error.
+
+ For UI-related issues, browser console logs can be very helpful. You can provide these as screenshots or paste the text here.
+ render: shell
+ validations:
+ required: true
+ - type: textarea
+ id: screenshots
+ attributes:
+ label: Screenshots
+ description: If applicable, add screenshots to help explain your problem. You can drag and drop, paste images directly here or link to them.
+ - type: checkboxes
+ id: terms
+ attributes:
+ label: Code of Conduct
+ description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/.github/CODE_OF_CONDUCT.md)
+ options:
+ - label: I agree to follow this project's Code of Conduct
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
new file mode 100644
index 0000000..613c9e0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
@@ -0,0 +1,49 @@
+name: Feature Request
+description: File a feature request
+title: "[Enhancement]: "
+labels: ["✨ enhancement"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thank you for taking the time to fill this out!
+ - type: textarea
+ id: what
+ attributes:
+ label: What features would you like to see added?
+ description: Please provide as many details as possible.
+ placeholder: Please provide as many details as possible.
+ validations:
+ required: true
+ - type: textarea
+ id: details
+ attributes:
+ label: More details
+ description: Please provide additional details if needed.
+ placeholder: Please provide additional details if needed.
+ validations:
+ required: true
+ - type: dropdown
+ id: subject
+ attributes:
+ label: Which components are impacted by your request?
+ multiple: true
+ options:
+ - General
+ - UI
+ - Endpoints
+ - Plugins
+ - Other
+ - type: textarea
+ id: screenshots
+ attributes:
+ label: Pictures
+ description: If relevant, please include images to help clarify your request. You can drag and drop images directly here, paste them, or provide a link to them.
+ - type: checkboxes
+ id: terms
+ attributes:
+ label: Code of Conduct
+ description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/.github/CODE_OF_CONDUCT.md)
+ options:
+ - label: I agree to follow this project's Code of Conduct
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/LOCIZE_TRANSLATION_ACCESS_REQUEST.yml b/.github/ISSUE_TEMPLATE/LOCIZE_TRANSLATION_ACCESS_REQUEST.yml
new file mode 100644
index 0000000..49b01a8
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/LOCIZE_TRANSLATION_ACCESS_REQUEST.yml
@@ -0,0 +1,42 @@
+name: Locize Translation Access Request
+description: Request access to an additional language in Locize for LibreChat translations.
+title: "Locize Access Request: "
+labels: ["🌍 i18n", "🔑 access request"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thank you for your interest in contributing to LibreChat translations!
+ Please fill out the form below to request access to an additional language in **Locize**.
+
+ **🔗 Available Languages:** [View the list here](https://www.librechat.ai/docs/translation)
+
+ **📌 Note:** Ensure that the requested language is supported before submitting your request.
+ - type: input
+ id: account_name
+ attributes:
+ label: Locize Account Name
+ description: Please provide your Locize account name (e.g., John Doe).
+ placeholder: e.g., John Doe
+ validations:
+ required: true
+ - type: input
+ id: language_requested
+ attributes:
+ label: Language Code (ISO 639-1)
+ description: |
+ Enter the **ISO 639-1** language code for the language you want to translate into.
+ Example: `es` for Spanish, `zh-Hant` for Traditional Chinese.
+
+ **🔗 Reference:** [Available Languages](https://www.librechat.ai/docs/translation)
+ placeholder: e.g., es
+ validations:
+ required: true
+ - type: checkboxes
+ id: agreement
+ attributes:
+ label: Agreement
+ description: By submitting this request, you confirm that you will contribute responsibly and adhere to the project guidelines.
+ options:
+ - label: I agree to use my access solely for contributing to LibreChat translations.
+ required: true
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/NEW-LANGUAGE-REQUEST.yml b/.github/ISSUE_TEMPLATE/NEW-LANGUAGE-REQUEST.yml
new file mode 100644
index 0000000..5fddced
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/NEW-LANGUAGE-REQUEST.yml
@@ -0,0 +1,33 @@
+name: New Language Request
+description: Request to add a new language for LibreChat translations.
+title: "New Language Request: "
+labels: ["✨ enhancement", "🌍 i18n"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thank you for taking the time to submit a new language request! Please fill out the following details so we can review your request.
+ - type: input
+ id: language_name
+ attributes:
+ label: Language Name
+ description: Please provide the full name of the language (e.g., Spanish, Mandarin).
+ placeholder: e.g., Spanish
+ validations:
+ required: true
+ - type: input
+ id: iso_code
+ attributes:
+ label: ISO 639-1 Code
+ description: Please provide the ISO 639-1 code for the language (e.g., es for Spanish). You can refer to [this list](https://www.w3schools.com/tags/ref_language_codes.asp) for valid codes.
+ placeholder: e.g., es
+ validations:
+ required: true
+ - type: checkboxes
+ id: terms
+ attributes:
+ label: Code of Conduct
+ description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/.github/CODE_OF_CONDUCT.md).
+ options:
+ - label: I agree to follow this project's Code of Conduct
+ required: true
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
new file mode 100644
index 0000000..b01e04e
--- /dev/null
+++ b/.github/SECURITY.md
@@ -0,0 +1,63 @@
+# Security Policy
+
+At LibreChat, we prioritize the security of our project and value the contributions of security researchers in helping us improve the security of our codebase. If you discover a security vulnerability within our project, we appreciate your responsible disclosure. Please follow the guidelines below to report any vulnerabilities to us:
+
+**Note: Only report sensitive vulnerability details via the appropriate private communication channels mentioned below. Public channels, such as GitHub issues and Discord, should be used for initiating contact and establishing private communication channels.**
+
+## Communication Channels
+
+When reporting a security vulnerability, you have the following options to reach out to us:
+
+- **Option 1: GitHub Security Advisory System**: We encourage you to use GitHub's Security Advisory system to report any security vulnerabilities you find. This allows us to receive vulnerability reports directly through GitHub. For more information on how to submit a security advisory report, please refer to the [GitHub Security Advisories documentation](https://docs.github.com/en/code-security/getting-started-with-security-vulnerability-alerts/about-github-security-advisories).
+
+- **Option 2: GitHub Issues**: You can initiate first contact via GitHub Issues. However, please note that initial contact through GitHub Issues should not include any sensitive details.
+
+- **Option 3: Discord Server**: You can join our [Discord community](https://discord.librechat.ai) and initiate first contact in the `#issues` channel. However, please ensure that initial contact through Discord does not include any sensitive details.
+
+_After the initial contact, we will establish a private communication channel for further discussion._
+
+### When submitting a vulnerability report, please provide us with the following information:
+
+- A clear description of the vulnerability, including steps to reproduce it.
+- The version(s) of the project affected by the vulnerability.
+- Any additional information that may be useful for understanding and addressing the issue.
+
+We strive to acknowledge vulnerability reports within 72 hours and will keep you informed of the progress towards resolution.
+
+## Security Updates and Patching
+
+We are committed to maintaining the security of our open-source project, LibreChat, and promptly addressing any identified vulnerabilities. To ensure the security of our project, we adhere to the following practices:
+
+- We prioritize security updates for the current major release of our software.
+- We actively monitor the GitHub Security Advisory system and the `#issues` channel on Discord for any vulnerability reports.
+- We promptly review and validate reported vulnerabilities and take appropriate actions to address them.
+- We release security patches and updates in a timely manner to mitigate any identified vulnerabilities.
+
+Please note that as a security-conscious community, we may not always disclose detailed information about security issues until we have determined that doing so would not put our users or the project at risk. We appreciate your understanding and cooperation in these matters.
+
+## Scope
+
+This security policy applies to the following GitHub repository:
+
+- Repository: [LibreChat](https://github.librechat.ai)
+
+## Contact
+
+If you have any questions or concerns regarding the security of our project, please join our [Discord community](https://discord.librechat.ai) and report them in the appropriate channel. You can also reach out to us by [opening an issue](https://github.com/danny-avila/LibreChat/issues/new) on GitHub. Please note that the response time may vary depending on the nature and severity of the inquiry.
+
+## Acknowledgments
+
+We would like to express our gratitude to the security researchers and community members who help us improve the security of our project. Your contributions are invaluable, and we sincerely appreciate your efforts.
+
+## Bug Bounty Program
+
+We currently do not have a bug bounty program in place. However, we welcome and appreciate any
+
+ security-related contributions through pull requests (PRs) that address vulnerabilities in our codebase. We believe in the power of collaboration to improve the security of our project and invite you to join us in making it more robust.
+
+**Reference**
+- https://cheatsheetseries.owasp.org/cheatsheets/Vulnerability_Disclosure_Cheat_Sheet.html
+
+---
+
+## [Go Back to ReadMe](../README.md)
diff --git a/.github/configuration-release.json b/.github/configuration-release.json
new file mode 100644
index 0000000..68fe80e
--- /dev/null
+++ b/.github/configuration-release.json
@@ -0,0 +1,60 @@
+{
+ "categories": [
+ {
+ "title": "### ✨ New Features",
+ "labels": ["feat"]
+ },
+ {
+ "title": "### 🌍 Internationalization",
+ "labels": ["i18n"]
+ },
+ {
+ "title": "### 👐 Accessibility",
+ "labels": ["a11y"]
+ },
+ {
+ "title": "### 🔧 Fixes",
+ "labels": ["Fix", "fix"]
+ },
+ {
+ "title": "### ⚙️ Other Changes",
+ "labels": ["ci", "style", "docs", "refactor", "chore"]
+ }
+ ],
+ "ignore_labels": [
+ "🔁 duplicate",
+ "📊 analytics",
+ "🌱 good first issue",
+ "🔍 investigation",
+ "🙏 help wanted",
+ "❌ invalid",
+ "❓ question",
+ "🚫 wontfix",
+ "🚀 release",
+ "version"
+ ],
+ "base_branches": ["main"],
+ "sort": {
+ "order": "ASC",
+ "on_property": "mergedAt"
+ },
+ "label_extractor": [
+ {
+ "pattern": "^(?:[^A-Za-z0-9]*)(feat|fix|chore|docs|refactor|ci|style|a11y|i18n)\\s*:",
+ "target": "$1",
+ "flags": "i",
+ "on_property": "title",
+ "method": "match"
+ },
+ {
+ "pattern": "^(?:[^A-Za-z0-9]*)(v\\d+\\.\\d+\\.\\d+(?:-rc\\d+)?).*",
+ "target": "version",
+ "flags": "i",
+ "on_property": "title",
+ "method": "match"
+ }
+ ],
+ "template": "## [#{{TO_TAG}}] - #{{TO_TAG_DATE}}\n\nChanges from #{{FROM_TAG}} to #{{TO_TAG}}.\n\n#{{CHANGELOG}}\n\n[See full release details][release-#{{TO_TAG}}]\n\n[release-#{{TO_TAG}}]: https://github.com/#{{OWNER}}/#{{REPO}}/releases/tag/#{{TO_TAG}}\n\n---",
+ "pr_template": "- #{{TITLE}} by **@#{{AUTHOR}}** in [##{{NUMBER}}](#{{URL}})",
+ "empty_template": "- no changes"
+}
\ No newline at end of file
diff --git a/.github/configuration-unreleased.json b/.github/configuration-unreleased.json
new file mode 100644
index 0000000..29eaf5e
--- /dev/null
+++ b/.github/configuration-unreleased.json
@@ -0,0 +1,68 @@
+{
+ "categories": [
+ {
+ "title": "### ✨ New Features",
+ "labels": ["feat"]
+ },
+ {
+ "title": "### 🌍 Internationalization",
+ "labels": ["i18n"]
+ },
+ {
+ "title": "### 👐 Accessibility",
+ "labels": ["a11y"]
+ },
+ {
+ "title": "### 🔧 Fixes",
+ "labels": ["Fix", "fix"]
+ },
+ {
+ "title": "### ⚙️ Other Changes",
+ "labels": ["ci", "style", "docs", "refactor", "chore"]
+ }
+ ],
+ "ignore_labels": [
+ "🔁 duplicate",
+ "📊 analytics",
+ "🌱 good first issue",
+ "🔍 investigation",
+ "🙏 help wanted",
+ "❌ invalid",
+ "❓ question",
+ "🚫 wontfix",
+ "🚀 release",
+ "version",
+ "action"
+ ],
+ "base_branches": ["main"],
+ "sort": {
+ "order": "ASC",
+ "on_property": "mergedAt"
+ },
+ "label_extractor": [
+ {
+ "pattern": "^(?:[^A-Za-z0-9]*)(feat|fix|chore|docs|refactor|ci|style|a11y|i18n)\\s*:",
+ "target": "$1",
+ "flags": "i",
+ "on_property": "title",
+ "method": "match"
+ },
+ {
+ "pattern": "^(?:[^A-Za-z0-9]*)(v\\d+\\.\\d+\\.\\d+(?:-rc\\d+)?).*",
+ "target": "version",
+ "flags": "i",
+ "on_property": "title",
+ "method": "match"
+ },
+ {
+ "pattern": "^(?:[^A-Za-z0-9]*)(action)\\b.*",
+ "target": "action",
+ "flags": "i",
+ "on_property": "title",
+ "method": "match"
+ }
+ ],
+ "template": "## [Unreleased]\n\n#{{CHANGELOG}}\n\n---",
+ "pr_template": "- #{{TITLE}} by **@#{{AUTHOR}}** in [##{{NUMBER}}](#{{URL}})",
+ "empty_template": "- no changes"
+}
\ No newline at end of file
diff --git a/.github/playwright.yml b/.github/playwright.yml
new file mode 100644
index 0000000..27f026a
--- /dev/null
+++ b/.github/playwright.yml
@@ -0,0 +1,72 @@
+# name: Playwright Tests
+# on:
+# pull_request:
+# branches:
+# - main
+# - dev
+# - release/*
+# paths:
+# - 'api/**'
+# - 'client/**'
+# - 'packages/**'
+# - 'e2e/**'
+# jobs:
+# tests_e2e:
+# name: Run Playwright tests
+# if: github.event.pull_request.head.repo.full_name == 'danny-avila/LibreChat'
+# timeout-minutes: 60
+# runs-on: ubuntu-latest
+# env:
+# NODE_ENV: CI
+# CI: true
+# SEARCH: false
+# BINGAI_TOKEN: user_provided
+# CHATGPT_TOKEN: user_provided
+# MONGO_URI: ${{ secrets.MONGO_URI }}
+# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+# E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
+# E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
+# JWT_SECRET: ${{ secrets.JWT_SECRET }}
+# JWT_REFRESH_SECRET: ${{ secrets.JWT_REFRESH_SECRET }}
+# CREDS_KEY: ${{ secrets.CREDS_KEY }}
+# CREDS_IV: ${{ secrets.CREDS_IV }}
+# DOMAIN_CLIENT: ${{ secrets.DOMAIN_CLIENT }}
+# DOMAIN_SERVER: ${{ secrets.DOMAIN_SERVER }}
+# PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 # Skip downloading during npm install
+# PLAYWRIGHT_BROWSERS_PATH: 0 # Places binaries to node_modules/@playwright/test
+# TITLE_CONVO: false
+# steps:
+# - uses: actions/checkout@v4
+# - uses: actions/setup-node@v4
+# with:
+# node-version: 24.16.0
+# cache: 'npm'
+
+# - name: Install global dependencies
+# run: npm ci
+
+# # - name: Remove sharp dependency
+# # run: rm -rf node_modules/sharp
+
+# # - name: Install sharp with linux dependencies
+# # run: cd api && SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install --arch=x64 --platform=linux --libc=glibc sharp
+
+# - name: Build Client
+# run: npm run frontend
+
+# - name: Install Playwright
+# run: |
+# npx playwright install-deps
+# npm install -D @playwright/test@latest
+# npx playwright install chromium
+
+# - name: Run Playwright tests
+# run: npm run e2e:ci
+
+# - name: Upload playwright report
+# uses: actions/upload-artifact@v3
+# if: always()
+# with:
+# name: playwright-report
+# path: e2e/playwright-report/
+# retention-days: 30
\ No newline at end of file
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..cb63778
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,41 @@
+# Pull Request Template
+
+⚠️ Before Submitting a PR, Please Review:
+- Please ensure that you have thoroughly read and understood the [Contributing Docs](https://github.com/danny-avila/LibreChat/blob/main/.github/CONTRIBUTING.md) before submitting your Pull Request.
+
+⚠️ Documentation Updates Notice:
+- Kindly note that documentation updates are managed in this repository: [librechat.ai](https://github.com/LibreChat-AI/librechat.ai)
+
+## Summary
+
+Please provide a brief summary of your changes and the related issue. Include any motivation and context that is relevant to your changes. If there are any dependencies necessary for your changes, please list them here.
+
+## Change Type
+
+Please delete any irrelevant options.
+
+- [ ] Bug fix (non-breaking change which fixes an issue)
+- [ ] New feature (non-breaking change which adds functionality)
+- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
+- [ ] This change requires a documentation update
+- [ ] Translation update
+
+## Testing
+
+Please describe your test process and include instructions so that we can reproduce your test. If there are any important variables for your testing configuration, list them here.
+
+### **Test Configuration**:
+
+## Checklist
+
+Please delete any irrelevant options.
+
+- [ ] My code adheres to this project's style guidelines
+- [ ] I have performed a self-review of my own code
+- [ ] I have commented in any complex areas of my code
+- [ ] I have made pertinent documentation changes
+- [ ] My changes do not introduce new warnings
+- [ ] I have written tests demonstrating that my changes are effective or that my feature works
+- [ ] Local unit tests pass with my changes
+- [ ] Any changes dependent on mine have been merged and published in downstream modules.
+- [ ] A pull request for updating the documentation has been submitted.
diff --git a/.github/scripts/sync-helm-chart-tags.sh b/.github/scripts/sync-helm-chart-tags.sh
new file mode 100755
index 0000000..f54048a
--- /dev/null
+++ b/.github/scripts/sync-helm-chart-tags.sh
@@ -0,0 +1,237 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+CHART_PATH="${CHART_PATH:-helm/librechat/Chart.yaml}"
+DEFAULT_BRANCH="${DEFAULT_BRANCH:-main}"
+BASE_REF="${BASE_REF:-refs/remotes/origin/${DEFAULT_BRANCH}}"
+BACKFILL_FROM_VERSION="${BACKFILL_FROM_VERSION:-1.9.0}"
+PUSH_TAGS="${PUSH_TAGS:-false}"
+TAG_PREFIX="${TAG_PREFIX:-chart-}"
+GITHUB_SERVER_URL="${GITHUB_SERVER_URL:-https://github.com}"
+DISPATCH_WORKFLOW="${DISPATCH_WORKFLOW:-}"
+RELEASE_EXISTING_TAG="${RELEASE_EXISTING_TAG:-}"
+SEMVER_REGEX='^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(-[0-9A-Za-z-]+([.][0-9A-Za-z-]+)*)?([+][0-9A-Za-z-]+([.][0-9A-Za-z-]+)*)?$'
+
+fail() {
+ printf '::error::%s\n' "$1" >&2
+ exit 1
+}
+
+git_auth_header() {
+ token="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')"
+ printf 'AUTHORIZATION: basic %s' "$token"
+}
+
+git_with_auth() {
+ if [ -n "${GITHUB_TOKEN:-}" ]; then
+ git -c "http.extraheader=$(git_auth_header)" "$@"
+ return
+ fi
+
+ git "$@"
+}
+
+dispatch_release() {
+ tag="$1"
+
+ if [ -z "$DISPATCH_WORKFLOW" ]; then
+ return
+ fi
+
+ if [ -z "${GITHUB_REPOSITORY:-}" ]; then
+ fail "GITHUB_REPOSITORY is required to dispatch ${DISPATCH_WORKFLOW}"
+ fi
+
+ if [[ ! "$GITHUB_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
+ fail "Unexpected repository name: ${GITHUB_REPOSITORY}"
+ fi
+
+ if [[ ! "$DISPATCH_WORKFLOW" =~ ^[A-Za-z0-9_.-]+[.]ya?ml$ ]]; then
+ fail "Unexpected workflow file: ${DISPATCH_WORKFLOW}"
+ fi
+
+ token="${GH_TOKEN:-${GITHUB_TOKEN:-}}"
+ if [ -z "$token" ]; then
+ fail "GH_TOKEN or GITHUB_TOKEN is required to dispatch ${DISPATCH_WORKFLOW}"
+ fi
+
+ command -v gh >/dev/null ||
+ fail "GitHub CLI is required to dispatch ${DISPATCH_WORKFLOW}"
+
+ GH_TOKEN="$token" gh workflow run "$DISPATCH_WORKFLOW" \
+ --repo "$GITHUB_REPOSITORY" \
+ --ref "$DEFAULT_BRANCH" \
+ -f "chart_tag=${tag}"
+}
+
+version_less_than() {
+ left="${1%%[-+]*}"
+ right="${2%%[-+]*}"
+
+ IFS=. read -r left_major left_minor left_patch <<<"$left"
+ IFS=. read -r right_major right_minor right_patch <<<"$right"
+
+ if (( left_major != right_major )); then
+ (( left_major < right_major ))
+ return
+ fi
+
+ if (( left_minor != right_minor )); then
+ (( left_minor < right_minor ))
+ return
+ fi
+
+ (( left_patch < right_patch ))
+}
+
+validate_chart_tag() {
+ tag="$1"
+ version="${tag#${TAG_PREFIX}}"
+
+ git check-ref-format "refs/tags/${tag}" >/dev/null ||
+ fail "Refusing to use invalid tag ${tag}"
+
+ if [[ "$tag" != "${TAG_PREFIX}"* || ! "$version" =~ $SEMVER_REGEX ]]; then
+ fail "Chart tags must use the form ${TAG_PREFIX}, for example ${TAG_PREFIX}2.0.5"
+ fi
+}
+
+dispatch_existing_tag() {
+ tag="$1"
+
+ if [ -z "$tag" ]; then
+ return
+ fi
+
+ validate_chart_tag "$tag"
+
+ if [ "$PUSH_TAGS" != "true" ]; then
+ printf 'Would dispatch release workflow for existing %s.\n' "$tag"
+ return
+ fi
+
+ if ! git_with_auth ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then
+ fail "Remote tag ${tag} does not exist"
+ fi
+
+ printf 'Dispatching release workflow for existing %s.\n' "$tag"
+ dispatch_release "$tag"
+}
+
+chart_version_at() {
+ git show "${1}:${CHART_PATH}" 2>/dev/null | awk '
+ /^version:[[:space:]]*/ {
+ value = $0
+ sub(/^version:[[:space:]]*/, "", value)
+ sub(/[[:space:]]*#.*/, "", value)
+ gsub(/^[[:space:]"'\''"]+|[[:space:]"'\''"]+$/, "", value)
+ print value
+ exit
+ }
+ '
+}
+
+case "$PUSH_TAGS" in
+ true | false) ;;
+ *) fail "PUSH_TAGS must be true or false" ;;
+esac
+
+if [[ ! "$BACKFILL_FROM_VERSION" =~ $SEMVER_REGEX ]]; then
+ fail "BACKFILL_FROM_VERSION must be a valid SemVer value"
+fi
+
+git rev-parse --verify "${BASE_REF}^{commit}" >/dev/null ||
+ fail "Unable to resolve ${BASE_REF}; fetch ${DEFAULT_BRANCH} before running this script"
+
+history_file="$(mktemp)"
+versions_file="$(mktemp)"
+seen_file="$(mktemp)"
+missing_file="$(mktemp)"
+cleanup() {
+ rm -f "$history_file" "$versions_file" "$seen_file" "$missing_file"
+}
+trap cleanup EXIT
+
+git log --first-parent --reverse --format=%H "$BASE_REF" -- "$CHART_PATH" >"$history_file"
+
+if [ ! -s "$history_file" ]; then
+ fail "No history found for ${CHART_PATH} on ${BASE_REF}"
+fi
+
+while IFS= read -r commit; do
+ version="$(chart_version_at "$commit")"
+
+ if [ -z "$version" ]; then
+ continue
+ fi
+
+ if [[ ! "$version" =~ $SEMVER_REGEX ]]; then
+ fail "${CHART_PATH} has invalid SemVer '${version}' at ${commit}"
+ fi
+
+ if version_less_than "$version" "$BACKFILL_FROM_VERSION"; then
+ continue
+ fi
+
+ if grep -Fqx "$version" "$seen_file"; then
+ continue
+ fi
+
+ printf '%s\n' "$version" >>"$seen_file"
+ printf '%s\t%s\n' "$version" "$commit" >>"$versions_file"
+done <"$history_file"
+
+if [ ! -s "$versions_file" ]; then
+ fail "No chart versions found in ${CHART_PATH}"
+fi
+
+while IFS="$(printf '\t')" read -r version commit; do
+ tag="${TAG_PREFIX}${version}"
+
+ validate_chart_tag "$tag"
+
+ if git rev-parse --quiet --verify "refs/tags/${tag}" >/dev/null; then
+ continue
+ fi
+
+ printf '%s\t%s\n' "$tag" "$commit" >>"$missing_file"
+done <"$versions_file"
+
+if [ ! -s "$missing_file" ]; then
+ printf 'All chart versions on %s already have %s tags.\n' "$BASE_REF" "$TAG_PREFIX"
+ dispatch_existing_tag "$RELEASE_EXISTING_TAG"
+ exit 0
+fi
+
+while IFS="$(printf '\t')" read -r tag commit; do
+ short_commit="$(git rev-parse --short "$commit")"
+
+ if [ "$PUSH_TAGS" != "true" ]; then
+ printf 'Would create %s at %s.\n' "$tag" "$short_commit"
+ continue
+ fi
+
+ if git_with_auth ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then
+ printf 'Remote tag %s already exists; dispatching release workflow.\n' "$tag"
+ dispatch_release "$tag"
+ continue
+ fi
+
+ git tag "$tag" "$commit"
+
+ if git_with_auth push origin "refs/tags/${tag}"; then
+ printf 'Created %s at %s.\n' "$tag" "$short_commit"
+ dispatch_release "$tag"
+ continue
+ fi
+
+ if git_with_auth ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then
+ printf 'Remote tag %s was created concurrently; dispatching release workflow.\n' "$tag"
+ dispatch_release "$tag"
+ continue
+ fi
+
+ fail "Failed to push ${tag}"
+done <"$missing_file"
+
+dispatch_existing_tag "$RELEASE_EXISTING_TAG"
diff --git a/.github/workflows/a11y.yml b/.github/workflows/a11y.yml
new file mode 100644
index 0000000..344592c
--- /dev/null
+++ b/.github/workflows/a11y.yml
@@ -0,0 +1,30 @@
+name: Lint for accessibility issues
+
+on:
+ pull_request:
+ paths:
+ - 'client/src/**'
+ workflow_dispatch:
+ inputs:
+ run_workflow:
+ description: 'Set to true to run this workflow'
+ required: true
+ default: 'false'
+
+permissions:
+ contents: read
+ pull-requests: write
+
+jobs:
+ axe-linter:
+ runs-on: ubuntu-latest
+ if: >
+ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'danny-avila/LibreChat') ||
+ (github.event_name == 'workflow_dispatch' && github.event.inputs.run_workflow == 'true')
+
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dequelabs/axe-linter-action@v1
+ with:
+ api_key: ${{ secrets.AXE_LINTER_API_KEY }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/agents-integration-tests.yml b/.github/workflows/agents-integration-tests.yml
new file mode 100644
index 0000000..8e841da
--- /dev/null
+++ b/.github/workflows/agents-integration-tests.yml
@@ -0,0 +1,90 @@
+name: Agents Integration Tests
+
+# Runs the packages/api `src/agents/**` integration specs (e.g. the durable HITL
+# checkpointer against a real in-process MongoDB via mongodb-memory-server). These
+# are `*.integration.spec.ts`, which `test:ci` deliberately excludes — without this
+# job they run nowhere and their regressions guard nothing.
+on:
+ pull_request:
+ branches:
+ - main
+ - dev
+ - dev-staging
+ - release/*
+ paths:
+ - 'packages/api/src/agents/**'
+ - 'packages/api/package.json'
+ - '.github/workflows/agents-integration-tests.yml'
+
+permissions:
+ contents: read
+
+jobs:
+ agents_integration_tests:
+ name: Integration Tests that use in-process MongoDB
+ timeout-minutes: 20
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - 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 }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Restore data-provider build cache
+ id: cache-data-provider
+ uses: actions/cache@v4
+ with:
+ path: packages/data-provider/dist
+ key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-provider
+ if: steps.cache-data-provider.outputs.cache-hit != 'true'
+ run: npm run build:data-provider
+
+ - name: Restore data-schemas build cache
+ id: cache-data-schemas
+ uses: actions/cache@v4
+ with:
+ path: packages/data-schemas/dist
+ key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-schemas
+ if: steps.cache-data-schemas.outputs.cache-hit != 'true'
+ run: npm run build:data-schemas
+
+ - name: Restore api build cache
+ id: cache-api
+ uses: actions/cache@v4
+ with:
+ path: packages/api/dist
+ key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
+
+ - name: Build api
+ if: steps.cache-api.outputs.cache-hit != 'true'
+ run: npm run build:api
+
+ - name: Run agents integration tests (in-process MongoDB)
+ working-directory: packages/api
+ env:
+ NODE_ENV: test
+ run: npm run test:agents-integration
diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml
new file mode 100644
index 0000000..46a698c
--- /dev/null
+++ b/.github/workflows/backend-review.yml
@@ -0,0 +1,426 @@
+name: Backend Unit Tests
+on:
+ pull_request:
+ paths:
+ - 'api/**'
+ - 'packages/**'
+
+permissions:
+ contents: read
+
+env:
+ NODE_ENV: CI
+ NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
+
+jobs:
+ build:
+ name: Build packages
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - 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 }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Restore data-provider build cache
+ id: cache-data-provider
+ uses: actions/cache@v4
+ with:
+ path: packages/data-provider/dist
+ key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-provider
+ if: steps.cache-data-provider.outputs.cache-hit != 'true'
+ run: npm run build:data-provider
+
+ - name: Restore data-schemas build cache
+ id: cache-data-schemas
+ uses: actions/cache@v4
+ with:
+ path: packages/data-schemas/dist
+ key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-schemas
+ if: steps.cache-data-schemas.outputs.cache-hit != 'true'
+ run: npm run build:data-schemas
+
+ - name: Restore api build cache
+ id: cache-api
+ uses: actions/cache@v4
+ with:
+ path: packages/api/dist
+ key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
+
+ - name: Build api
+ if: steps.cache-api.outputs.cache-hit != 'true'
+ run: npm run build:api
+
+ - name: Upload data-provider build
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+ retention-days: 2
+
+ - name: Upload data-schemas build
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-data-schemas
+ path: packages/data-schemas/dist
+ retention-days: 2
+
+ - name: Upload api build
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-api
+ path: packages/api/dist
+ retention-days: 2
+
+ typecheck:
+ name: TypeScript type checks
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - 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 }}-24.16.0-${{ 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
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - 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 }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Download data-schemas build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-schemas
+ path: packages/data-schemas/dist
+
+ - name: Rebuild @librechat/api and check for circular dependencies
+ run: |
+ output=$(npm run build:api 2>&1)
+ echo "$output"
+ if echo "$output" | grep -q "Circular depend"; then
+ echo "Error: Circular dependency detected in @librechat/api!"
+ exit 1
+ fi
+
+ - name: Detect circular dependencies in rollup
+ working-directory: ./packages/data-provider
+ run: |
+ output=$(npm run rollup:api)
+ echo "$output"
+ if echo "$output" | grep -q "Circular dependency"; then
+ echo "Error: Circular dependency detected!"
+ exit 1
+ fi
+
+ test-api:
+ name: 'Tests: api (shard ${{ matrix.shard }}/3)'
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [1, 2, 3]
+ env:
+ MONGO_URI: ${{ secrets.MONGO_URI }}
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+ JWT_SECRET: ${{ secrets.JWT_SECRET }}
+ CREDS_KEY: ${{ secrets.CREDS_KEY }}
+ CREDS_IV: ${{ secrets.CREDS_IV }}
+ BAN_VIOLATIONS: ${{ secrets.BAN_VIOLATIONS }}
+ BAN_DURATION: ${{ secrets.BAN_DURATION }}
+ BAN_INTERVAL: ${{ secrets.BAN_INTERVAL }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - 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 }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Download data-schemas build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-schemas
+ path: packages/data-schemas/dist
+
+ - name: Download api build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-api
+ path: packages/api/dist
+
+ - name: Create empty auth.json file
+ run: |
+ mkdir -p api/data
+ echo '{}' > api/data/auth.json
+
+ - name: Prepare .env.test file
+ run: cp api/test/.env.test.example api/test/.env.test
+
+ - name: Run unit tests (shard ${{ matrix.shard }}/3)
+ run: cd api && npm run test:ci -- --shard=${{ matrix.shard }}/3
+
+ test-data-provider:
+ name: 'Tests: data-provider'
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - 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 }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Run unit tests
+ run: cd packages/data-provider && npm run test:ci
+
+ test-data-schemas:
+ name: 'Tests: data-schemas'
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - 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 }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Download data-schemas build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-schemas
+ path: packages/data-schemas/dist
+
+ - name: Run unit tests
+ run: cd packages/data-schemas && npm run test:ci
+
+ test-packages-api:
+ name: 'Tests: @librechat/api (shard ${{ matrix.shard }}/4)'
+ needs: build
+ runs-on: ubuntu-latest
+ # Suite typically completes in ~5 min on a warm runner, but tail-latency
+ # cancellations have started showing up: tests are actively passing right
+ # up to the timeout, then the job is killed mid-suite. Sharding splits the
+ # suite across runners; per-shard headroom still absorbs runner variance.
+ timeout-minutes: 20
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [1, 2, 3, 4]
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - 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 }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Download data-schemas build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-schemas
+ path: packages/data-schemas/dist
+
+ - name: Download api build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-api
+ path: packages/api/dist
+
+ - name: Run unit tests (shard ${{ matrix.shard }}/4)
+ run: cd packages/api && npm run test:ci -- --shard=${{ matrix.shard }}/4
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..9210b80
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,41 @@
+name: Linux_Container_Workflow
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ RUNNER_VERSION: 2.293.0
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+ steps:
+ # checkout the repo
+ - name: 'Checkout GitHub Action'
+ uses: actions/checkout@v4
+
+ - name: 'Login via Azure CLI'
+ uses: azure/login@v2
+ with:
+ creds: ${{ secrets.AZURE_CREDENTIALS }}
+
+ - name: 'Build GitHub Runner container image'
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ secrets.REGISTRY_LOGIN_SERVER }}
+ username: ${{ secrets.REGISTRY_USERNAME }}
+ password: ${{ secrets.REGISTRY_PASSWORD }}
+ - run: |
+ docker build --build-arg RUNNER_VERSION=${{ env.RUNNER_VERSION }} -t ${{ secrets.REGISTRY_LOGIN_SERVER }}/pwd9000-github-runner-lin:${{ env.RUNNER_VERSION }} .
+
+ - name: 'Push container image to ACR'
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ secrets.REGISTRY_LOGIN_SERVER }}
+ username: ${{ secrets.REGISTRY_USERNAME }}
+ password: ${{ secrets.REGISTRY_PASSWORD }}
+ - run: |
+ docker push ${{ secrets.REGISTRY_LOGIN_SERVER }}/pwd9000-github-runner-lin:${{ env.RUNNER_VERSION }}
diff --git a/.github/workflows/cache-integration-tests.yml b/.github/workflows/cache-integration-tests.yml
new file mode 100644
index 0000000..1a70e4b
--- /dev/null
+++ b/.github/workflows/cache-integration-tests.yml
@@ -0,0 +1,133 @@
+name: Cache Integration Tests
+
+on:
+ pull_request:
+ branches:
+ - main
+ - dev
+ - dev-staging
+ - release/*
+ paths:
+ - 'packages/api/src/cache/**'
+ - 'packages/api/src/cluster/**'
+ - 'packages/api/src/mcp/**'
+ - 'packages/api/src/stream/**'
+ - 'redis-config/**'
+ - '.github/workflows/cache-integration-tests.yml'
+
+permissions:
+ contents: read
+
+jobs:
+ cache_integration_tests:
+ name: Integration Tests that use actual Redis Cache
+ timeout-minutes: 30
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - name: Install Redis tools
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y redis-server redis-tools
+
+ - name: Start Single Redis Instance
+ run: |
+ redis-server --daemonize yes --port 6379
+ sleep 2
+ # Verify single Redis is running
+ redis-cli -p 6379 ping || exit 1
+
+ - name: Start Redis Cluster
+ working-directory: redis-config
+ run: |
+ chmod +x start-cluster.sh stop-cluster.sh
+ ./start-cluster.sh
+ sleep 10
+ # Verify cluster is running
+ redis-cli -p 7001 cluster info || exit 1
+ redis-cli -p 7002 cluster info || exit 1
+ redis-cli -p 7003 cluster info || exit 1
+
+ - 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 }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Restore data-provider build cache
+ id: cache-data-provider
+ uses: actions/cache@v4
+ with:
+ path: packages/data-provider/dist
+ key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-provider
+ if: steps.cache-data-provider.outputs.cache-hit != 'true'
+ run: npm run build:data-provider
+
+ - name: Restore data-schemas build cache
+ id: cache-data-schemas
+ uses: actions/cache@v4
+ with:
+ path: packages/data-schemas/dist
+ key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-schemas
+ if: steps.cache-data-schemas.outputs.cache-hit != 'true'
+ run: npm run build:data-schemas
+
+ - name: Restore api build cache
+ id: cache-api
+ uses: actions/cache@v4
+ with:
+ path: packages/api/dist
+ key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
+
+ - name: Build api
+ if: steps.cache-api.outputs.cache-hit != 'true'
+ run: npm run build:api
+
+ - name: Run all cache integration tests (Single Redis Node)
+ working-directory: packages/api
+ env:
+ NODE_ENV: test
+ USE_REDIS: true
+ USE_REDIS_CLUSTER: false
+ REDIS_URI: redis://127.0.0.1:6379
+ run: npm run test:cache-integration
+
+ - name: Run all cache integration tests (Redis Cluster)
+ working-directory: packages/api
+ env:
+ NODE_ENV: test
+ USE_REDIS: true
+ USE_REDIS_CLUSTER: true
+ REDIS_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
+ run: npm run test:cache-integration
+
+ - name: Stop Redis Cluster
+ if: always()
+ working-directory: redis-config
+ run: ./stop-cluster.sh || true
+
+ - name: Stop Single Redis Instance
+ if: always()
+ run: redis-cli -p 6379 shutdown || true
diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml
new file mode 100644
index 0000000..e4dc8c5
--- /dev/null
+++ b/.github/workflows/client.yml
@@ -0,0 +1,94 @@
+name: Publish `@librechat/client` to NPM
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'packages/client/package.json'
+ workflow_dispatch:
+ inputs:
+ reason:
+ description: 'Reason for manual trigger'
+ required: false
+ default: 'Manual publish requested'
+
+permissions:
+ contents: read
+
+jobs:
+ pack:
+ runs-on: ubuntu-latest
+ outputs:
+ skip: ${{ steps.check.outputs.skip }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - name: Install client dependencies
+ run: cd packages/client && npm ci
+
+ - name: Build client
+ run: cd packages/client && npm run build
+
+ - name: Check version change
+ id: check
+ working-directory: packages/client
+ run: |
+ PACKAGE_VERSION=$(node -p "require('./package.json').version")
+ PUBLISHED_VERSION=$(npm view @librechat/client version 2>/dev/null || echo "0.0.0")
+ if [ "$PACKAGE_VERSION" = "$PUBLISHED_VERSION" ]; then
+ echo "No version change, skipping publish"
+ echo "skip=true" >> $GITHUB_OUTPUT
+ else
+ echo "Version changed, proceeding with publish"
+ echo "skip=false" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Pack package
+ if: steps.check.outputs.skip != 'true'
+ working-directory: packages/client
+ run: |
+ mkdir -p "$GITHUB_WORKSPACE/npm-package"
+ npm pack --pack-destination "$GITHUB_WORKSPACE/npm-package"
+
+ - name: Upload package
+ if: steps.check.outputs.skip != 'true'
+ uses: actions/upload-artifact@v4
+ with:
+ name: librechat-client-package
+ path: npm-package/*.tgz
+ if-no-files-found: error
+ retention-days: 2
+
+ publish-npm:
+ needs: pack
+ if: github.ref == 'refs/heads/main' && needs.pack.outputs.skip != 'true'
+ runs-on: ubuntu-latest
+ environment: publish # Must match npm trusted publisher config
+ permissions:
+ contents: read
+ id-token: write # Required for OIDC trusted publishing
+ steps:
+ - name: Use Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Install npm with OIDC support
+ run: npm install -g npm@11.14.1 --ignore-scripts
+
+ - name: Download package
+ uses: actions/download-artifact@v4
+ with:
+ name: librechat-client-package
+ path: npm-package
+
+ - name: Publish
+ working-directory: npm-package
+ run: npm publish *.tgz --access public --provenance
diff --git a/.github/workflows/config-review.yml b/.github/workflows/config-review.yml
new file mode 100644
index 0000000..fc25989
--- /dev/null
+++ b/.github/workflows/config-review.yml
@@ -0,0 +1,88 @@
+name: Config Migration Tests
+on:
+ pull_request:
+ paths:
+ - 'config/**'
+ - 'api/models/**'
+ - 'api/db/**'
+ - 'packages/data-schemas/src/**'
+ - 'packages/data-provider/src/**'
+ - 'packages/api/src/acl/**'
+ - 'packages/api/src/shared-links/**'
+
+env:
+ NODE_ENV: CI
+ NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
+
+jobs:
+ test-config:
+ name: 'Tests: config migrations'
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - 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: Restore data-provider build cache
+ id: cache-data-provider
+ uses: actions/cache@v4
+ with:
+ path: packages/data-provider/dist
+ key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-provider
+ if: steps.cache-data-provider.outputs.cache-hit != 'true'
+ run: npm run build:data-provider
+
+ - name: Restore data-schemas build cache
+ id: cache-data-schemas
+ uses: actions/cache@v4
+ with:
+ path: packages/data-schemas/dist
+ key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-schemas
+ if: steps.cache-data-schemas.outputs.cache-hit != 'true'
+ run: npm run build:data-schemas
+
+ - name: Restore api build cache
+ id: cache-api
+ uses: actions/cache@v4
+ with:
+ path: packages/api/dist
+ key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
+
+ - name: Build api
+ if: steps.cache-api.outputs.cache-hit != 'true'
+ run: npm run build:api
+
+ - name: Create empty auth.json file
+ run: |
+ mkdir -p api/data
+ echo '{}' > api/data/auth.json
+
+ - name: Prepare .env.test file
+ run: cp api/test/.env.test.example api/test/.env.test
+
+ - name: Run config migration tests
+ run: npm run test:config
diff --git a/.github/workflows/data-provider.yml b/.github/workflows/data-provider.yml
new file mode 100644
index 0000000..eae746e
--- /dev/null
+++ b/.github/workflows/data-provider.yml
@@ -0,0 +1,67 @@
+name: Publish `librechat-data-provider` to NPM
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'packages/data-provider/package.json'
+ workflow_dispatch:
+ inputs:
+ reason:
+ description: 'Reason for manual trigger'
+ required: false
+ default: 'Manual publish requested'
+
+permissions:
+ contents: read
+
+jobs:
+ pack:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+ - run: cd packages/data-provider && npm ci
+ - run: cd packages/data-provider && npm run build
+ - name: Pack package
+ run: |
+ mkdir -p npm-package
+ cd packages/data-provider
+ npm pack --pack-destination "$GITHUB_WORKSPACE/npm-package"
+ - name: Upload package
+ uses: actions/upload-artifact@v4
+ with:
+ name: librechat-data-provider-package
+ path: npm-package/*.tgz
+ if-no-files-found: error
+ retention-days: 2
+
+ publish-npm:
+ needs: pack
+ if: github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ environment: publish # Must match npm trusted publisher config
+ permissions:
+ contents: read
+ id-token: write # Required for OIDC trusted publishing
+ steps:
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Install npm with OIDC support
+ run: npm install -g npm@11.14.1 --ignore-scripts
+
+ - name: Download package
+ uses: actions/download-artifact@v4
+ with:
+ name: librechat-data-provider-package
+ path: npm-package
+
+ - name: Publish package
+ working-directory: npm-package
+ run: npm publish *.tgz --provenance
diff --git a/.github/workflows/data-schemas.yml b/.github/workflows/data-schemas.yml
new file mode 100644
index 0000000..bb8f90e
--- /dev/null
+++ b/.github/workflows/data-schemas.yml
@@ -0,0 +1,94 @@
+name: Publish `@librechat/data-schemas` to NPM
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'packages/data-schemas/package.json'
+ workflow_dispatch:
+ inputs:
+ reason:
+ description: 'Reason for manual trigger'
+ required: false
+ default: 'Manual publish requested'
+
+permissions:
+ contents: read
+
+jobs:
+ pack:
+ runs-on: ubuntu-latest
+ outputs:
+ skip: ${{ steps.check.outputs.skip }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - name: Install dependencies
+ run: cd packages/data-schemas && npm ci
+
+ - name: Build
+ run: cd packages/data-schemas && npm run build
+
+ - name: Check version change
+ id: check
+ working-directory: packages/data-schemas
+ run: |
+ PACKAGE_VERSION=$(node -p "require('./package.json').version")
+ PUBLISHED_VERSION=$(npm view @librechat/data-schemas version 2>/dev/null || echo "0.0.0")
+ if [ "$PACKAGE_VERSION" = "$PUBLISHED_VERSION" ]; then
+ echo "No version change, skipping publish"
+ echo "skip=true" >> $GITHUB_OUTPUT
+ else
+ echo "Version changed, proceeding with publish"
+ echo "skip=false" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Pack package
+ if: steps.check.outputs.skip != 'true'
+ working-directory: packages/data-schemas
+ run: |
+ mkdir -p "$GITHUB_WORKSPACE/npm-package"
+ npm pack --pack-destination "$GITHUB_WORKSPACE/npm-package"
+
+ - name: Upload package
+ if: steps.check.outputs.skip != 'true'
+ uses: actions/upload-artifact@v4
+ with:
+ name: librechat-data-schemas-package
+ path: npm-package/*.tgz
+ if-no-files-found: error
+ retention-days: 2
+
+ publish-npm:
+ needs: pack
+ if: github.ref == 'refs/heads/main' && needs.pack.outputs.skip != 'true'
+ runs-on: ubuntu-latest
+ environment: publish # Must match npm trusted publisher config
+ permissions:
+ contents: read
+ id-token: write # Required for OIDC trusted publishing
+ steps:
+ - name: Use Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Install npm with OIDC support
+ run: npm install -g npm@11.14.1 --ignore-scripts
+
+ - name: Download package
+ uses: actions/download-artifact@v4
+ with:
+ name: librechat-data-schemas-package
+ path: npm-package
+
+ - name: Publish
+ working-directory: npm-package
+ run: npm publish *.tgz --access public --provenance
diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml
new file mode 100644
index 0000000..57875bc
--- /dev/null
+++ b/.github/workflows/deploy-dev.yml
@@ -0,0 +1,49 @@
+name: Update Test Server
+
+on:
+ workflow_run:
+ workflows: ["Docker Dev Branch Images Build"]
+ types:
+ - completed
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ if: |
+ github.repository == 'danny-avila/LibreChat' &&
+ (github.event_name == 'workflow_dispatch' ||
+ (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'dev'))
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Install SSH Key
+ uses: shimataro/ssh-key-action@v2
+ with:
+ key: ${{ secrets.DO_SSH_PRIVATE_KEY }}
+ known_hosts: ${{ secrets.DO_KNOWN_HOSTS }}
+
+ - name: Run update script on DigitalOcean Droplet
+ env:
+ DO_HOST: ${{ secrets.DO_HOST }}
+ DO_USER: ${{ secrets.DO_USER }}
+ run: |
+ ssh ${DO_USER}@${DO_HOST} << EOF
+ sudo -i -u danny bash << 'EEOF'
+ cd ~/LibreChat && \
+ git fetch origin main && \
+ sudo npm run stop:deployed && \
+ sudo docker images --format "{{.Repository}}:{{.ID}}" | grep -E "lc-dev|librechat" | cut -d: -f2 | xargs -r sudo docker rmi -f || true && \
+ sudo npm run update:deployed && \
+ git checkout dev && \
+ git pull origin dev && \
+ git checkout do-deploy && \
+ git rebase dev && \
+ sudo npm run start:deployed && \
+ echo "Update completed. Application should be running now."
+ EEOF
+ EOF
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..e4b73da
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,41 @@
+name: Deploy_GHRunner_Linux_ACI
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ RUNNER_VERSION: 2.293.0
+ ACI_RESOURCE_GROUP: 'Demo-ACI-GitHub-Runners-RG'
+ ACI_NAME: 'gh-runner-linux-01'
+ DNS_NAME_LABEL: 'gh-lin-01'
+ GH_OWNER: ${{ github.repository_owner }}
+ GH_REPOSITORY: 'LibreChat' #Change here to deploy self hosted runner ACI to another repo.
+
+jobs:
+ deploy-gh-runner-aci:
+ runs-on: ubuntu-latest
+ steps:
+ # checkout the repo
+ - name: 'Checkout GitHub Action'
+ uses: actions/checkout@v4
+
+ - name: 'Login via Azure CLI'
+ uses: azure/login@v2
+ with:
+ creds: ${{ secrets.AZURE_CREDENTIALS }}
+
+ - name: 'Deploy to Azure Container Instances'
+ uses: 'azure/aci-deploy@v1'
+ with:
+ resource-group: ${{ env.ACI_RESOURCE_GROUP }}
+ image: ${{ secrets.REGISTRY_LOGIN_SERVER }}/pwd9000-github-runner-lin:${{ env.RUNNER_VERSION }}
+ registry-login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }}
+ registry-username: ${{ secrets.REGISTRY_USERNAME }}
+ registry-password: ${{ secrets.REGISTRY_PASSWORD }}
+ name: ${{ env.ACI_NAME }}
+ dns-name-label: ${{ env.DNS_NAME_LABEL }}
+ environment-variables: GH_TOKEN=${{ secrets.PAT_TOKEN }} GH_OWNER=${{ env.GH_OWNER }} GH_REPOSITORY=${{ env.GH_REPOSITORY }}
+ location: 'eastus'
diff --git a/.github/workflows/dev-branch-images.yml b/.github/workflows/dev-branch-images.yml
new file mode 100644
index 0000000..f0e2ba5
--- /dev/null
+++ b/.github/workflows/dev-branch-images.yml
@@ -0,0 +1,95 @@
+name: Docker Dev Branch Images Build
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - dev
+ paths:
+ - 'api/**'
+ - 'client/**'
+ - 'packages/**'
+ - 'package.json'
+ - 'package-lock.json'
+ - 'Dockerfile'
+ - 'Dockerfile.multi'
+
+permissions:
+ contents: read
+ packages: write
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ timeout-minutes: 130
+ strategy:
+ matrix:
+ include:
+ - target: api-build
+ file: Dockerfile.multi
+ image_name: lc-dev-api
+ - target: node
+ file: Dockerfile
+ image_name: lc-dev
+
+ steps:
+ # Check out the repository
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # Set up QEMU
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ # Set up Docker Buildx
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ # Log in to GitHub Container Registry
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Login to Docker Hub
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ # Prepare the environment
+ - name: Prepare environment
+ run: |
+ cp .env.example .env
+
+ - name: Compute build metadata
+ run: |
+ echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV
+ echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
+ echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
+
+ # Build and push Docker images for each target
+ - name: Build and push Docker images
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ${{ matrix.file }}
+ push: true
+ tags: |
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
+ platforms: linux/amd64,linux/arm64
+ target: ${{ matrix.target }}
+ build-args: |
+ BUILD_COMMIT=${{ env.BUILD_COMMIT }}
+ BUILD_BRANCH=${{ env.BUILD_BRANCH }}
+ BUILD_DATE=${{ env.BUILD_DATE }}
diff --git a/.github/workflows/dev-images.yml b/.github/workflows/dev-images.yml
new file mode 100644
index 0000000..efdd202
--- /dev/null
+++ b/.github/workflows/dev-images.yml
@@ -0,0 +1,91 @@
+name: Docker Dev Images Build
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ paths:
+ - 'api/**'
+ - 'client/**'
+ - 'packages/**'
+ - 'package.json'
+ - 'package-lock.json'
+ - 'Dockerfile'
+ - 'Dockerfile.multi'
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ timeout-minutes: 130
+ strategy:
+ matrix:
+ include:
+ - target: api-build
+ file: Dockerfile.multi
+ image_name: librechat-dev-api
+ - target: node
+ file: Dockerfile
+ image_name: librechat-dev
+
+ steps:
+ # Check out the repository
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # Set up QEMU
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ # Set up Docker Buildx
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ # Log in to GitHub Container Registry
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Login to Docker Hub
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ # Prepare the environment
+ - name: Prepare environment
+ run: |
+ cp .env.example .env
+
+ - name: Compute build metadata
+ run: |
+ echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV
+ echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
+ echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
+
+ # Build and push Docker images for each target
+ - name: Build and push Docker images
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ${{ matrix.file }}
+ push: true
+ tags: |
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
+ platforms: linux/amd64,linux/arm64
+ target: ${{ matrix.target }}
+ build-args: |
+ BUILD_COMMIT=${{ env.BUILD_COMMIT }}
+ BUILD_BRANCH=${{ env.BUILD_BRANCH }}
+ BUILD_DATE=${{ env.BUILD_DATE }}
diff --git a/.github/workflows/dev-staging-images.yml b/.github/workflows/dev-staging-images.yml
new file mode 100644
index 0000000..6deb862
--- /dev/null
+++ b/.github/workflows/dev-staging-images.yml
@@ -0,0 +1,79 @@
+name: Docker Dev Staging Images Build
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ include:
+ - target: api-build
+ file: Dockerfile.multi
+ image_name: lc-dev-staging-api
+ - target: node
+ file: Dockerfile
+ image_name: lc-dev-staging
+
+ steps:
+ # Check out the repository
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # Set up QEMU
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ # Set up Docker Buildx
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ # Log in to GitHub Container Registry
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Login to Docker Hub
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ # Prepare the environment
+ - name: Prepare environment
+ run: |
+ cp .env.example .env
+
+ - name: Compute build metadata
+ run: |
+ echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV
+ echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
+ echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
+
+ # Build and push Docker images for each target
+ - name: Build and push Docker images
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ${{ matrix.file }}
+ push: true
+ tags: |
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
+ platforms: linux/amd64,linux/arm64
+ target: ${{ matrix.target }}
+ build-args: |
+ BUILD_COMMIT=${{ env.BUILD_COMMIT }}
+ BUILD_BRANCH=${{ env.BUILD_BRANCH }}
+ BUILD_DATE=${{ env.BUILD_DATE }}
diff --git a/.github/workflows/docker-smoke.yml b/.github/workflows/docker-smoke.yml
new file mode 100644
index 0000000..3780959
--- /dev/null
+++ b/.github/workflows/docker-smoke.yml
@@ -0,0 +1,128 @@
+name: Docker Build Smoke Tests
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - '.github/workflows/docker-smoke.yml'
+ - '.dockerignore'
+ - 'Dockerfile.multi'
+ - 'package.json'
+ - 'package-lock.json'
+ - 'api/**'
+ - 'client/**'
+ - 'config/**'
+ - 'skill/**'
+ - 'packages/api/**'
+ - 'packages/client/**'
+ - 'packages/data-provider/**'
+ - 'packages/data-schemas/**'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: docker-smoke-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ client-package-target:
+ name: Build Docker client package target
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build client package target
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: Dockerfile.multi
+ platforms: linux/amd64
+ push: false
+ target: client-package-build
+
+ api-runtime-smoke:
+ name: API runtime smoke (production image boots)
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ # Build the real production image (final `api-build` stage), which installs
+ # with `npm ci --omit=dev` — the same prune that, in prod, exposed runtime
+ # dependencies the tsdown bundle externalizes but were never declared.
+ - name: Build production image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: Dockerfile.multi
+ platforms: linux/amd64
+ push: false
+ load: true
+ tags: librechat-api-smoke:ci
+ cache-from: type=gha,scope=docker-smoke-api
+ cache-to: type=gha,mode=max,scope=docker-smoke-api
+
+ # Loads the entire externalized require graph of the built @librechat/api
+ # bundle inside the pruned production image. A missing or ESM-incompatible
+ # runtime dependency (e.g. the `get-stream` regression) fails here with a
+ # non-zero exit — deterministically, with no database required.
+ - name: Verify production image resolves all runtime modules
+ run: |
+ docker run --rm librechat-api-smoke:ci \
+ node -e "require('@librechat/api'); require('@librechat/api/telemetry'); console.log('module resolution OK')"
+
+ # Boot the real entrypoint against a real MongoDB so the *entire* server
+ # require graph loads (api/db throws at module scope without MONGO_URI, and
+ # is imported before models/services/routes), then gate on /readyz AND the
+ # container staying alive. /readyz only returns 200 after the post-listen
+ # startup (initializeMCPs + checkMigrations) sets serverReady, and those
+ # steps process.exit(1) on failure — so ANY startup crash (missing module,
+ # ReferenceError, bad config, post-listen failure) fails the smoke.
+ - name: Boot production image against MongoDB and poll /readyz
+ run: |
+ set -u
+ docker network create lc-smoke
+ docker run -d --name lc-mongo --network lc-smoke mongo:8.0.20
+ docker run -d --name lc-api --network lc-smoke -p 3080:3080 \
+ -e HOST=0.0.0.0 -e PORT=3080 \
+ -e NODE_ENV=production \
+ -e MONGO_URI=mongodb://lc-mongo:27017/LibreChat \
+ -e CREDS_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \
+ -e CREDS_IV=0123456789abcdef0123456789abcdef \
+ -e JWT_SECRET=docker-smoke-jwt-secret \
+ -e JWT_REFRESH_SECRET=docker-smoke-jwt-refresh-secret \
+ -e SEARCH=false \
+ librechat-api-smoke:ci
+
+ healthy=""
+ for i in $(seq 1 60); do
+ if [ "$(docker inspect -f '{{.State.Running}}' lc-api 2>/dev/null)" != "true" ]; then
+ echo "::error::API container exited during startup (exit code $(docker inspect -f '{{.State.ExitCode}}' lc-api 2>/dev/null))"
+ break
+ fi
+ if [ "$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:3080/readyz 2>/dev/null || true)" = "200" ]; then
+ healthy="yes"
+ echo "/readyz returned 200 — server fully booted (post-listen startup complete)."
+ break
+ fi
+ sleep 2
+ done
+
+ echo "----- last 100 lines of api container logs -----"
+ docker logs lc-api 2>&1 | tail -100 || true
+ echo "------------------------------------------------"
+ docker rm -f lc-api lc-mongo >/dev/null 2>&1 || true
+ docker network rm lc-smoke >/dev/null 2>&1 || true
+
+ if [ -z "$healthy" ]; then
+ echo "::error::Production image failed to reach a ready /readyz within timeout"
+ exit 1
+ fi
diff --git a/.github/workflows/eslint-ci.yml b/.github/workflows/eslint-ci.yml
new file mode 100644
index 0000000..3ab8528
--- /dev/null
+++ b/.github/workflows/eslint-ci.yml
@@ -0,0 +1,128 @@
+name: ESLint Code Quality Checks
+
+on:
+ pull_request:
+ branches:
+ - main
+ - dev
+ - dev-staging
+ - release/*
+ paths:
+ - 'api/**'
+ - 'client/**'
+ - 'packages/**'
+ - '.github/workflows/eslint-ci.yml'
+
+jobs:
+ eslint_checks:
+ name: Run ESLint Linting
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ security-events: write
+ actions: read
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ # Run ESLint on changed files within the api/, client/, and packages/ directories.
+ - name: Run ESLint on changed files
+ run: |
+ # Extract the base commit SHA from the pull_request event payload.
+ BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
+ echo "Base commit SHA: $BASE_SHA"
+
+ # Get changed files (only JS/TS files in api/, client/, or packages/)
+ mapfile -d '' -t CHANGED_FILES < <(
+ git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD |
+ grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true
+ )
+
+ # Debug output
+ echo "Changed files:"
+ printf '%s\n' "${CHANGED_FILES[@]}"
+
+ # Ensure there are files to lint before running ESLint
+ if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then
+ echo "No matching files changed. Skipping ESLint."
+ exit 0
+ fi
+
+ # Run ESLint
+ npx eslint --no-error-on-unmatched-pattern \
+ --config eslint.config.mjs \
+ --max-warnings=0 \
+ -- "${CHANGED_FILES[@]}"
+
+ # Run Prettier --check on the same set of changed files to catch
+ # formatting drift in PRs that bypassed the local pre-commit hook
+ # (e.g. GitHub UI edit-and-merge, `git commit --no-verify`).
+ - name: Run Prettier --check on changed files
+ run: |
+ BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
+ mapfile -d '' -t CHANGED_FILES < <(
+ git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD |
+ grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true
+ )
+
+ if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then
+ echo "No matching files changed. Skipping Prettier."
+ exit 0
+ fi
+
+ echo "Files to check:"
+ printf '%s\n' "${CHANGED_FILES[@]}"
+
+ # `prettier --check` exits non-zero if any file would be reformatted.
+ # Suggest the local fix in the failure message so contributors aren't
+ # left guessing how to resolve.
+ if ! npx prettier --check --no-error-on-unmatched-pattern -- "${CHANGED_FILES[@]}"; then
+ echo ""
+ echo "::error::Prettier formatting drift detected. Fix locally with:"
+ echo "::error:: npx prettier --write "
+ echo "::error::Or rely on the lint-staged pre-commit hook (do not bypass with --no-verify)."
+ exit 1
+ fi
+
+ # Verify import ordering on the same set of changed files. The script
+ # only sorts files under known source roots, so unrelated changed files
+ # (configs, etc.) are ignored. Matches the lint-staged pre-commit hook.
+ - name: Check import sorting on changed files
+ run: |
+ BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
+ mapfile -d '' -t CHANGED_FILES < <(
+ git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD |
+ grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true
+ )
+
+ if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then
+ echo "No matching files changed. Skipping import-sort check."
+ exit 0
+ fi
+
+ echo "Files to check:"
+ printf '%s\n' "${CHANGED_FILES[@]}"
+
+ # `--check` lists offending files and exits non-zero without writing.
+ if ! node scripts/sort-imports.mts --check "${CHANGED_FILES[@]}"; then
+ echo ""
+ echo "::error::Import order drift detected. Fix locally with:"
+ echo "::error:: npm run sort-imports"
+ echo "::error::For specific files:"
+ echo "::error:: npm run sort-imports -- packages/api/src/app/metrics.ts packages/api/src/rum/proxy.ts"
+ echo "::error::To check without writing files:"
+ echo "::error:: npm run sort-imports:check"
+ echo "::error::Or rely on the lint-staged pre-commit hook (do not bypass with --no-verify)."
+ exit 1
+ fi
diff --git a/.github/workflows/frontend-review.yml b/.github/workflows/frontend-review.yml
new file mode 100644
index 0000000..db84d05
--- /dev/null
+++ b/.github/workflows/frontend-review.yml
@@ -0,0 +1,300 @@
+name: Frontend Unit Tests
+
+on:
+ pull_request:
+ paths:
+ - 'client/**'
+ - 'packages/client/**'
+ - 'packages/data-provider/**'
+ - '.github/workflows/frontend-review.yml'
+
+permissions:
+ contents: read
+
+env:
+ NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
+
+jobs:
+ build:
+ name: Build packages
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ client/node_modules
+ packages/client/node_modules
+ packages/data-provider/node_modules
+ key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Restore data-provider build cache
+ id: cache-data-provider
+ uses: actions/cache@v4
+ with:
+ path: packages/data-provider/dist
+ key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-provider
+ if: steps.cache-data-provider.outputs.cache-hit != 'true'
+ run: npm run build:data-provider
+
+ - name: Restore client-package build cache
+ id: cache-client-package
+ uses: actions/cache@v4
+ with:
+ path: packages/client/dist
+ key: build-client-package-${{ runner.os }}-${{ hashFiles('packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build client-package
+ if: steps.cache-client-package.outputs.cache-hit != 'true'
+ run: npm run build:client-package
+
+ - name: Upload data-provider build
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+ retention-days: 2
+
+ - name: Upload client-package build
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-client-package
+ path: packages/client/dist
+ retention-days: 2
+
+ typecheck:
+ name: TypeScript type checks (client)
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ client/node_modules
+ packages/client/node_modules
+ packages/data-provider/node_modules
+ key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Download client-package build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-client-package
+ path: packages/client/dist
+
+ - name: Type check client
+ run: npm run typecheck
+ working-directory: client
+
+ test-packages-client:
+ name: 'Tests: @librechat/client'
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ client/node_modules
+ packages/client/node_modules
+ packages/data-provider/node_modules
+ key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Run unit tests
+ run: npm run test:ci
+ working-directory: packages/client
+
+ test-ubuntu:
+ name: 'Tests: Ubuntu (shard ${{ matrix.shard }}/4)'
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [1, 2, 3, 4]
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ client/node_modules
+ packages/client/node_modules
+ packages/data-provider/node_modules
+ key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Download client-package build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-client-package
+ path: packages/client/dist
+
+ - name: Run unit tests (shard ${{ matrix.shard }}/4)
+ run: npm run test:ci -- --shard=${{ matrix.shard }}/4
+ working-directory: client
+
+ test-windows:
+ name: 'Tests: Windows (shard ${{ matrix.shard }}/4)'
+ needs: build
+ runs-on: windows-latest
+ timeout-minutes: 20
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [1, 2, 3, 4]
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ client/node_modules
+ packages/client/node_modules
+ packages/data-provider/node_modules
+ key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Download client-package build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-client-package
+ path: packages/client/dist
+
+ - name: Run unit tests (shard ${{ matrix.shard }}/4)
+ run: npm run test:ci -- --shard=${{ matrix.shard }}/4
+ working-directory: client
+
+ build-verify:
+ name: Vite build verification
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ client/node_modules
+ packages/client/node_modules
+ packages/data-provider/node_modules
+ key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Download client-package build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-client-package
+ path: packages/client/dist
+
+ - name: Build client
+ run: cd client && npm run build:ci
diff --git a/.github/workflows/generate_embeddings.yml b/.github/workflows/generate_embeddings.yml
new file mode 100644
index 0000000..3c6f271
--- /dev/null
+++ b/.github/workflows/generate_embeddings.yml
@@ -0,0 +1,23 @@
+name: 'generate_embeddings'
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ paths:
+ - 'docs/**'
+
+permissions:
+ contents: read
+
+jobs:
+ generate:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: supabase/embeddings-generator@v0.0.5
+ with:
+ supabase-url: ${{ secrets.SUPABASE_URL }}
+ supabase-service-role-key: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
+ openai-key: ${{ secrets.OPENAI_DOC_EMBEDDINGS_KEY }}
+ docs-root-path: 'docs'
diff --git a/.github/workflows/gitnexus-cleanup-pr.yml b/.github/workflows/gitnexus-cleanup-pr.yml
new file mode 100644
index 0000000..d3c9628
--- /dev/null
+++ b/.github/workflows/gitnexus-cleanup-pr.yml
@@ -0,0 +1,91 @@
+# Removes a PR's GitNexus index from the droplet when the PR is closed
+# (merged or not). The deploy workflow also prunes stale folders as a
+# safety net, but this gives us immediate cleanup without waiting for
+# the next deploy trigger.
+
+name: GitNexus Cleanup PR
+
+on:
+ pull_request:
+ types: [closed]
+
+permissions:
+ contents: read
+ actions: read
+
+concurrency:
+ group: gitnexus-cleanup-pr-${{ github.event.pull_request.number }}
+ cancel-in-progress: false
+
+jobs:
+ cleanup:
+ # Skip fork PRs entirely. GitHub withholds repository secrets from
+ # pull_request events originating on forks, so an SSH deploy job run
+ # from a fork close would fail noisily. The deploy workflow's stale-
+ # folder pruning step catches any fork-contributor indexes that
+ # actually made it onto the droplet.
+ if: github.event.pull_request.head.repo.full_name == github.repository
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ # Skip the SSH round-trip entirely when no index artifact was ever
+ # built for this PR (docs-only PRs, paths-ignored PRs, PRs closed
+ # before indexing finished, etc). Eliminates ~95% of no-op SSH
+ # sessions on a busy repo.
+ - name: Check for index artifact
+ id: check
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const { data } = await github.rest.actions.listArtifactsForRepo({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ name: `gitnexus-index-pr-${context.payload.pull_request.number}`,
+ per_page: 1,
+ });
+ const hasArtifact = data.total_count > 0;
+ core.info(`Artifact exists: ${hasArtifact}`);
+ core.setOutput('has_artifact', hasArtifact ? 'true' : 'false');
+
+ - name: Setup SSH
+ if: steps.check.outputs.has_artifact == 'true'
+ env:
+ SSH_KEY: ${{ secrets.GITNEXUS_DO_SSH_KEY }}
+ KNOWN_HOST: ${{ secrets.GITNEXUS_DO_KNOWN_HOST }}
+ run: |
+ set -e
+ mkdir -p ~/.ssh
+ chmod 700 ~/.ssh
+ printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
+ chmod 600 ~/.ssh/deploy_key
+ if [ -z "$KNOWN_HOST" ]; then
+ echo "::error::GITNEXUS_DO_KNOWN_HOST secret is empty"
+ exit 1
+ fi
+ printf '%s\n' "$KNOWN_HOST" > ~/.ssh/known_hosts
+ chmod 600 ~/.ssh/known_hosts
+
+ - name: Remove PR index from droplet
+ if: steps.check.outputs.has_artifact == 'true'
+ env:
+ SSH_USER: ${{ secrets.GITNEXUS_DO_USER }}
+ SSH_HOST: ${{ secrets.GITNEXUS_DO_HOST }}
+ PR_NUM: ${{ github.event.pull_request.number }}
+ run: |
+ ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" PR_NUM="$PR_NUM" bash <<'REMOTE'
+ set -e
+ TARGET="/opt/gitnexus/indexes/LibreChat-pr-$PR_NUM"
+ if [ -d "$TARGET" ]; then
+ echo "Removing $TARGET"
+ rm -rf "$TARGET"
+ cd /opt/gitnexus
+ docker compose up -d --force-recreate gitnexus
+ echo "GitNexus restarted without PR #$PR_NUM"
+ else
+ echo "No index to clean up for PR #$PR_NUM (artifact existed but droplet folder did not)"
+ fi
+ REMOTE
+
+ - name: Cleanup SSH key
+ if: always()
+ run: rm -f ~/.ssh/deploy_key
diff --git a/.github/workflows/gitnexus-deploy.yml b/.github/workflows/gitnexus-deploy.yml
new file mode 100644
index 0000000..dc62068
--- /dev/null
+++ b/.github/workflows/gitnexus-deploy.yml
@@ -0,0 +1,583 @@
+# Deploys GitNexus indexes to a droplet via SSH + rsync.
+#
+# Architecture:
+# GitHub Actions (deploy)
+# 1. Resolves latest successful index runs for main and dev
+# 2. Downloads each matching .gitnexus/ artifact
+# 3. Rsyncs them into /opt/gitnexus/indexes// on the droplet
+# 4. Removes any stale folders on the droplet that are not main/dev
+# 5. Pulls latest image, force-recreates gitnexus, reloads Caddy,
+# and polls docker health until the container reports healthy
+# The caddy container is untouched — no TLS churn.
+#
+# First-time droplet bootstrap (run once, manually):
+# 1. Create 2GB+ Ubuntu 24.04 droplet, add SSH key
+# 2. Point DNS A record for your subdomain at the droplet IP
+# 3. SSH in and run:
+# curl -fsSL https://get.docker.com | sh
+# systemctl enable --now docker
+# mkdir -p /opt/gitnexus/indexes
+# useradd -m -s /bin/bash deploy
+# usermod -aG docker deploy
+# mkdir -p /home/deploy/.ssh
+# # Add deploy pubkey to /home/deploy/.ssh/authorized_keys
+# chown -R deploy:deploy /home/deploy/.ssh /opt/gitnexus
+# chmod 700 /home/deploy/.ssh
+# ufw allow 22,80,443/tcp
+# ufw --force enable
+# 4. Copy .do/gitnexus/docker-compose.yml and Caddyfile into /opt/gitnexus/
+# 5. Create /opt/gitnexus/.env with: GITNEXUS_DOMAIN=... and API_TOKEN=...
+# 6. cd /opt/gitnexus && docker compose up -d
+#
+# Then capture the droplet's SSH host key from your workstation and
+# save it as the GITNEXUS_DO_KNOWN_HOST secret (below) so CI can pin it:
+# ssh-keyscan -H gitnexus.yourdomain.com
+#
+# GHCR image: the workflow runs `docker login ghcr.io` on the droplet
+# on every deploy using GITHUB_TOKEN, so the package can stay private.
+# If you'd rather not have CI manage droplet auth, make the package
+# public under repo Settings -> Packages.
+#
+# Required GitHub secrets:
+# GITNEXUS_DO_HOST — droplet IP or hostname
+# GITNEXUS_DO_USER — SSH user (e.g. "deploy")
+# GITNEXUS_DO_SSH_KEY — private key matching the authorized pubkey
+# GITNEXUS_DO_KNOWN_HOST — output of `ssh-keyscan -H ` pinning the
+# droplet's host keys (prevents MITM/TOFU risk)
+
+name: GitNexus Deploy
+
+on:
+ workflow_run:
+ workflows: ['GitNexus Index']
+ types: [completed]
+ workflow_dispatch:
+ inputs:
+ pr_number:
+ description: 'Optional PR number for status comments from bot-triggered dispatches'
+ type: string
+ default: ''
+
+permissions:
+ actions: read
+ contents: read
+ pull-requests: write # post status comments on PR command dispatches
+
+# Global serialization. Earlier versions used per-ref concurrency with
+# cancel-in-progress so rapid pushes to the same ref coalesced but deploys
+# targeting different refs ran in parallel. That had a data race: the
+# prune-stale-indexes step computes its active_names up front, so if
+# deploy A is rsyncing /opt/gitnexus/indexes/LibreChat-pr-12580 while
+# deploy B (started slightly later with a different ref) prunes, B can
+# rm -rf a folder A is still uploading into.
+#
+# All deploys now queue behind a single group. cancel-in-progress is
+# false so a running rsync/docker-compose restart never gets killed
+# mid-operation (which would leave the droplet in a partial state).
+# The 20-minute job timeout bounds total queue depth.
+concurrency:
+ group: gitnexus-deploy
+ cancel-in-progress: false
+
+env:
+ GITNEXUS_VERSION: '1.6.7'
+ IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/librechat-gitnexus
+
+jobs:
+ # Rebuilds the long-lived image only when Dockerfile/entrypoint/extensions
+ # change. Skipped on every other run, so index-only deploys are fast.
+ build-image:
+ if: |
+ github.event_name == 'workflow_dispatch' ||
+ (
+ github.event.workflow_run.conclusion == 'success' &&
+ github.event.workflow_run.event == 'push' &&
+ (github.event.workflow_run.head_branch == 'main' ||
+ github.event.workflow_run.head_branch == 'dev')
+ )
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ permissions:
+ contents: read
+ packages: write # push image to GHCR
+ outputs:
+ image_tag: ${{ steps.tag.outputs.value }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 2
+
+ - name: Detect image changes
+ id: changes
+ run: |
+ # Default to rebuild when we can't cleanly diff (first commit,
+ # workflow_run from a PR branch where HEAD isn't the trigger, etc).
+ # Rebuild on miss > skip when we should have rebuilt.
+ if git rev-parse --verify HEAD~1 >/dev/null 2>&1 && \
+ git diff --quiet HEAD~1 HEAD -- .do/gitnexus/Dockerfile .do/gitnexus/entrypoint.sh .do/gitnexus/install-extensions.js; then
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Compute image tag
+ id: tag
+ run: echo "value=v${{ env.GITNEXUS_VERSION }}" >> "$GITHUB_OUTPUT"
+
+ - name: Set up Docker Buildx
+ if: steps.changes.outputs.changed == 'true' || github.event_name == 'workflow_dispatch'
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GHCR
+ if: steps.changes.outputs.changed == 'true' || github.event_name == 'workflow_dispatch'
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push image
+ if: steps.changes.outputs.changed == 'true' || github.event_name == 'workflow_dispatch'
+ uses: docker/build-push-action@v5
+ with:
+ context: .do/gitnexus
+ file: .do/gitnexus/Dockerfile
+ push: true
+ tags: |
+ ${{ env.IMAGE_NAME }}:latest
+ ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.value }}
+ build-args: |
+ GITNEXUS_VERSION=${{ env.GITNEXUS_VERSION }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ deploy:
+ needs: build-image
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ permissions:
+ actions: read
+ contents: read
+ pull-requests: write # post deploy-complete comments on PR command dispatches
+ steps:
+ - name: Checkout deploy config
+ uses: actions/checkout@v4
+ with:
+ sparse-checkout: .do/gitnexus
+ fetch-depth: 1
+
+ # Resolve every index to serve. All resolutions go through
+ # listArtifactsForRepo keyed by the expected artifact name, so a
+ # run's branch or event type doesn't matter — we always pick the
+ # freshest artifact that actually exists.
+ #
+ # Why this matters: a /gitnexus index command dispatches
+ # gitnexus-index.yml with ref=main and an input pr_number, which
+ # produces a run whose head_branch is "main" but whose artifact
+ # is gitnexus-index-pr-. listWorkflowRuns(branch='main') would
+ # happily return that run, and we'd then try to download a
+ # nonexistent gitnexus-index-main artifact from it. Querying by
+ # artifact name directly avoids the whole mess.
+ - name: Resolve indexes to serve
+ id: resolve
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const serve = []; // [{ name, artifactName, runId }]
+
+ // Helper — pick the newest non-expired artifact matching a name.
+ const latestArtifact = async (artifactName) => {
+ const { data } = await github.rest.actions.listArtifactsForRepo({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ name: artifactName,
+ per_page: 10,
+ });
+ return data.artifacts
+ .filter((a) => !a.expired)
+ .sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0];
+ };
+
+ // --- main and dev branches ---
+ for (const [branch, name] of [
+ ['main', 'LibreChat'],
+ ['dev', 'LibreChat-dev'],
+ ]) {
+ const artifactName = `gitnexus-index-${branch}`;
+ const fresh = await latestArtifact(artifactName);
+ if (!fresh) {
+ core.warning(`No artifact found for ${branch} (expected ${artifactName})`);
+ continue;
+ }
+ serve.push({
+ name,
+ artifactName,
+ runId: fresh.workflow_run.id,
+ });
+ core.info(`${branch}: run ${fresh.workflow_run.id} -> ${name}`);
+ }
+
+ core.info('PR index deploys are paused; serving main and dev only.');
+
+ if (!serve.length) {
+ core.setFailed('No indexes to serve');
+ return;
+ }
+
+ core.setOutput('matrix', JSON.stringify(serve));
+ core.setOutput('active_names', serve.map((s) => s.name).join(','));
+
+ - name: Download each index artifact
+ env:
+ MATRIX: ${{ steps.resolve.outputs.matrix }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -e
+ mkdir -p staging
+ # main/dev artifact download failures are fatal — a missing
+ # main/dev index is a real deploy failure. PR artifact failures
+ # are soft — a PR artifact deleted mid-deploy shouldn't abort
+ # the whole deploy and take main/dev down with it.
+ echo "$MATRIX" | jq -c '.[]' | while read -r entry; do
+ name=$(echo "$entry" | jq -r '.name')
+ artifact=$(echo "$entry" | jq -r '.artifactName')
+ runId=$(echo "$entry" | jq -r '.runId')
+ target="staging/${name}/.gitnexus"
+ echo "Downloading $artifact from run $runId -> $target"
+ mkdir -p "$target"
+ if ! gh run download "$runId" \
+ --repo "${{ github.repository }}" \
+ --name "$artifact" \
+ --dir "$target"; then
+ case "$name" in
+ LibreChat|LibreChat-dev)
+ echo "::error::Failed to download critical artifact $artifact"
+ exit 1
+ ;;
+ *)
+ # The name stays in active_names so the prune step
+ # won't remove the droplet's existing copy. The old
+ # index keeps being served instead of being wiped to
+ # nothing — stale beats empty — but observability
+ # requires an explicit notice since this path is
+ # invisible in the happy-path deploy log.
+ echo "::warning::Failed to download PR artifact $artifact — skipping fresh sync; previous index (if any) will continue being served from the droplet"
+ rm -rf "staging/${name}"
+ ;;
+ esac
+ fi
+ done
+ echo ""
+ echo "Staged for rsync:"
+ du -sh staging/*/.gitnexus/ 2>/dev/null || echo "(none)"
+
+ - name: Setup SSH
+ env:
+ SSH_KEY: ${{ secrets.GITNEXUS_DO_SSH_KEY }}
+ KNOWN_HOST: ${{ secrets.GITNEXUS_DO_KNOWN_HOST }}
+ run: |
+ set -e
+ mkdir -p ~/.ssh
+ chmod 700 ~/.ssh
+ printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
+ chmod 600 ~/.ssh/deploy_key
+ # Pin the droplet's SSH host key from a repository secret instead
+ # of trusting whatever ssh-keyscan returns at deploy time. The
+ # secret is populated from `ssh-keyscan -H ` at bootstrap.
+ if [ -z "$KNOWN_HOST" ]; then
+ echo "::error::GITNEXUS_DO_KNOWN_HOST secret is empty. Run ssh-keyscan -H and paste the output as this secret."
+ exit 1
+ fi
+ printf '%s\n' "$KNOWN_HOST" > ~/.ssh/known_hosts
+ chmod 600 ~/.ssh/known_hosts
+
+ - name: Authenticate droplet with GHCR
+ # GHCR packages pushed by GITHUB_TOKEN start private. The droplet
+ # pulls the image on every deploy, so we re-authenticate it here
+ # using the same short-lived token. If the package is public, this
+ # step is redundant but harmless.
+ #
+ # The token MUST travel through SSH stdin (not as a command arg)
+ # so it's never visible in the droplet's process table via
+ # /proc//cmdline. `printf '%s'` is preferred over `echo`
+ # so the exact byte sequence sent is explicit — docker login
+ # tolerates a trailing newline but `printf` makes the intent
+ # obvious and portable across shells.
+ env:
+ SSH_USER: ${{ secrets.GITNEXUS_DO_USER }}
+ SSH_HOST: ${{ secrets.GITNEXUS_DO_HOST }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_ACTOR: ${{ github.actor }}
+ run: |
+ printf '%s' "$GH_TOKEN" | ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \
+ "docker login ghcr.io -u '$GH_ACTOR' --password-stdin"
+
+ - name: Upload config files
+ env:
+ SSH_USER: ${{ secrets.GITNEXUS_DO_USER }}
+ SSH_HOST: ${{ secrets.GITNEXUS_DO_HOST }}
+ run: |
+ rsync -az -e "ssh -i ~/.ssh/deploy_key" \
+ .do/gitnexus/docker-compose.yml \
+ .do/gitnexus/Caddyfile \
+ "$SSH_USER@$SSH_HOST:/opt/gitnexus/"
+
+ - name: Prune stale indexes then sync fresh ones
+ env:
+ SSH_USER: ${{ secrets.GITNEXUS_DO_USER }}
+ SSH_HOST: ${{ secrets.GITNEXUS_DO_HOST }}
+ ACTIVE_NAMES: ${{ steps.resolve.outputs.active_names }}
+ run: |
+ set -e
+ # ── Step 1: prune FIRST ────────────────────────────────
+ # Remove any folders on the droplet that aren't in the active set.
+ # This frees disk BEFORE rsyncing new data, which matters on a
+ # 10GB disk where each current index is ~400MB.
+ echo "Pruning stale indexes (keeping: $ACTIVE_NAMES)"
+ ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \
+ ACTIVE_NAMES="$ACTIVE_NAMES" bash <<'REMOTE'
+ set -e
+ cd /opt/gitnexus/indexes || exit 0
+ shopt -s nullglob
+ IFS=',' read -ra ACTIVE <<< "$ACTIVE_NAMES"
+ for dir in */; do
+ dir="${dir%/}"
+ keep=false
+ for a in "${ACTIVE[@]}"; do
+ if [ "$dir" = "$a" ]; then keep=true; break; fi
+ done
+ if [ "$keep" = false ]; then
+ echo "Removing stale index: $dir"
+ rm -rf "$dir"
+ fi
+ done
+ echo "Disk after prune:"
+ df -h / | tail -1
+ REMOTE
+
+ # ── Step 2: rsync-then-swap ─────────────────────────────
+ # Upload each index to a temp directory, then atomically swap
+ # it into place. If rsync fails, the old index survives intact
+ # and the partial temp dir is cleaned up — no production data
+ # is lost. The brief period where both old + new exist costs
+ # ~400MB of extra disk, but the prune step already freed
+ # space from evicted indexes so this fits on a 10GB disk.
+ for dir in staging/*/; do
+ [ -d "$dir" ] || continue
+ name=$(basename "$dir")
+ echo "Syncing $name (rsync-then-swap)"
+ ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \
+ "mkdir -p /opt/gitnexus/indexes/${name}.new"
+ if rsync -az -e "ssh -i ~/.ssh/deploy_key" \
+ "$dir" \
+ "$SSH_USER@$SSH_HOST:/opt/gitnexus/indexes/${name}.new/"; then
+ # Swap: remove old, rename new into place
+ ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \
+ "rm -rf /opt/gitnexus/indexes/$name && mv /opt/gitnexus/indexes/${name}.new /opt/gitnexus/indexes/$name"
+ echo " $name swapped successfully"
+ else
+ # Clean up the partial temp dir
+ ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \
+ "rm -rf /opt/gitnexus/indexes/${name}.new"
+ # main/dev are critical — abort the deploy so the failure
+ # is visible and the container isn't restarted with stale
+ # or missing data. PR indexes are best-effort.
+ case "$name" in
+ LibreChat|LibreChat-dev)
+ echo "::error::rsync failed for critical index $name — aborting deploy"
+ exit 1
+ ;;
+ *)
+ echo "::warning::rsync failed for PR index $name — keeping previous index"
+ ;;
+ esac
+ fi
+ done
+
+ - name: Pull image, restart gitnexus, reload Caddy, wait for healthy
+ env:
+ SSH_USER: ${{ secrets.GITNEXUS_DO_USER }}
+ SSH_HOST: ${{ secrets.GITNEXUS_DO_HOST }}
+ run: |
+ ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" bash <<'REMOTE'
+ set -e
+ cd /opt/gitnexus
+
+ # ── Disk cleanup ──────────────────────────────────────
+ # Docker accumulates old image layers, dangling images, and
+ # build cache across deploys. This droplet is only ~8.7GB
+ # usable with a 700MB+ gitnexus image, so disk pressure is
+ # constant. Prune everything not used by currently-running
+ # containers BEFORE pulling the new image so the extract has
+ # room; the post-recreate prune below reclaims the old image.
+ echo "Disk before cleanup:"
+ df -h / | tail -1
+ # Omit --volumes: Caddy's caddy-data and caddy-config volumes
+ # hold TLS certificates and ACME state. If Caddy happens to be
+ # stopped when this runs (the workflow handles that case later),
+ # --volumes would wipe them, forcing Let's Encrypt re-issuance
+ # and risking rate-limit lockout (5 certs/domain/week).
+ docker system prune -af 2>/dev/null || true
+ echo "Disk after cleanup:"
+ df -h / | tail -1
+
+ # Fail fast if disk is critically low even after prune. The
+ # gitnexus image is ~700MB and shares most layers with the
+ # running one, so an incremental pull needs well under 1GB.
+ # 1536MB leaves headroom on this small droplet without the
+ # over-conservative 2GB guard aborting on a healthy box.
+ AVAIL_MB=$(df --output=avail -m / | tail -1 | tr -d ' ')
+ if [ "$AVAIL_MB" -lt 1536 ]; then
+ echo "::error::Disk critically low (${AVAIL_MB}MB free). Aborting deploy."
+ exit 1
+ fi
+
+ docker compose pull gitnexus
+ docker compose up -d --force-recreate gitnexus
+
+ # The previous gitnexus image is now dangling (the running
+ # container was recreated onto the freshly pulled image). The
+ # pre-pull prune above couldn't touch it because it was still
+ # in use at that point. Reclaim it now so the old generation
+ # doesn't accumulate — critical on this 10GB droplet.
+ docker image prune -f 2>/dev/null || true
+
+ # Reload Caddy in-place so a changed Caddyfile takes effect
+ # without losing TLS certs or restarting connections. If caddy
+ # isn't running yet (first-time bootstrap), bring it up.
+ if docker compose ps --status running caddy 2>/dev/null | grep -q caddy; then
+ echo "Reloading Caddy config"
+ docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || {
+ echo "Caddy reload failed — forcing restart"
+ docker compose up -d --force-recreate caddy
+ }
+ else
+ echo "Caddy not running — starting"
+ docker compose up -d caddy
+ fi
+
+ # Poll gitnexus health until ready or timeout. Docker's own
+ # unhealthy detection takes up to 150s (start_period 60s +
+ # retries 3 * interval 30s), so the poll ceiling must clear
+ # that to avoid false negatives when gitnexus legitimately
+ # takes ~2.5 min to warm up.
+ # Max wait = 36 sleeps * 5s = 180s (final iteration exits
+ # before its sleep on failure, so 37 iterations is the
+ # correct upper bound for a true 180s ceiling).
+ echo "Waiting for gitnexus to report healthy..."
+ for i in $(seq 1 37); do
+ STATUS=$(docker inspect --format='{{.State.Health.Status}}' gitnexus 2>/dev/null || echo unknown)
+ echo "[$i/37] gitnexus health: $STATUS"
+ if [ "$STATUS" = "healthy" ]; then
+ echo "gitnexus is healthy"
+ break
+ fi
+ if [ "$i" -eq 37 ]; then
+ echo "ERROR: gitnexus failed to become healthy after 180s"
+ docker compose ps
+ docker compose logs --tail 80 gitnexus
+ exit 1
+ fi
+ sleep 5
+ done
+
+ docker compose ps
+ echo "--- Caddy logs (last 20 lines) ---"
+ docker compose logs --tail 20 caddy || true
+ echo "--- GitNexus logs (last 30 lines) ---"
+ docker compose logs --tail 30 gitnexus || true
+ REMOTE
+
+ # When the deploy was triggered by a PR command path, post a
+ # terminal status comment on that one PR only. Two sub-cases:
+ #
+ # 1. workflow_run trigger: the PR's native auto-index run fired
+ # workflow_run, so github.event.workflow_run.id is the trigger.
+ # Find the matching PR via the matrix entry whose runId matches.
+ #
+ # 2. workflow_dispatch trigger with inputs.pr_number set: the
+ # index workflow's bot-fallback path dispatched us directly
+ # because workflow_run is suppressed for GITHUB_TOKEN triggers.
+ # Use inputs.pr_number as the comment target.
+ #
+ # Broadcast-commenting on every active PR would be noise — only the
+ # PR that asked for a fresh index gets a reply.
+ - name: Comment on PR — deploy complete
+ if: always()
+ uses: actions/github-script@v7
+ env:
+ MATRIX: ${{ steps.resolve.outputs.matrix }}
+ TRIGGER_RUN_ID: ${{ github.event.workflow_run.id }}
+ DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number }}
+ DEPLOY_STATUS: ${{ job.status }}
+ with:
+ script: |
+ const deployUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
+ const matrix = JSON.parse(process.env.MATRIX || '[]');
+ let prNum = null;
+
+ // Case 1: dispatched directly with pr_number (bot-fallback path)
+ if (process.env.DISPATCH_PR_NUMBER && process.env.DISPATCH_PR_NUMBER !== '') {
+ const dispatchPrRaw = process.env.DISPATCH_PR_NUMBER;
+ if (!/^\d+$/.test(dispatchPrRaw)) {
+ core.setFailed(`Invalid PR number: ${dispatchPrRaw}`);
+ return;
+ }
+
+ const dispatchPrNum = Number(dispatchPrRaw);
+ const servedPr = matrix.some((m) => m.name === `LibreChat-pr-${dispatchPrNum}`);
+
+ if (!servedPr) {
+ const body = [
+ '### GitNexus: PR deploy skipped',
+ '',
+ 'PR-specific deploys are paused; only `LibreChat` and `LibreChat-dev` are currently served.',
+ `[Deploy run](${deployUrl})`,
+ ].join('\n');
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: dispatchPrNum,
+ body,
+ });
+ return;
+ }
+
+ prNum = dispatchPrNum;
+ }
+ // Case 2: workflow_run trigger from a PR index run
+ else if (context.eventName === 'workflow_run') {
+ const triggerRunId = Number(process.env.TRIGGER_RUN_ID);
+ const match = matrix.find(
+ (m) => m.runId === triggerRunId && m.name.startsWith('LibreChat-pr-'),
+ );
+ if (match) {
+ prNum = parseInt(match.name.replace('LibreChat-pr-', ''), 10);
+ }
+ }
+
+ if (!prNum) {
+ core.info('No PR to comment on (trigger was not a PR-scoped index); skipping.');
+ return;
+ }
+
+ const ok = process.env.DEPLOY_STATUS === 'success';
+ const body = [
+ `### GitNexus: ${ok ? '🚀 deployed' : '❌ deploy failed'}`,
+ '',
+ ok
+ ? `The \`LibreChat-pr-${prNum}\` index is now live on the MCP server.`
+ : `The deploy failed — the previous index (if any) continues to be served.`,
+ `[Deploy run](${deployUrl})`,
+ ].join('\n');
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNum,
+ body,
+ });
+
+ - name: Cleanup SSH key
+ if: always()
+ run: rm -f ~/.ssh/deploy_key
diff --git a/.github/workflows/gitnexus-index.yml b/.github/workflows/gitnexus-index.yml
new file mode 100644
index 0000000..89f906f
--- /dev/null
+++ b/.github/workflows/gitnexus-index.yml
@@ -0,0 +1,323 @@
+name: GitNexus Index
+
+on:
+ # PR branches are NOT auto-indexed — an embeddings run is too slow to
+ # spend on every PR push. Only main/dev are indexed automatically;
+ # individual PRs are indexed on demand via the /gitnexus command or a
+ # manual workflow_dispatch.
+ push:
+ branches: [main, dev]
+ paths-ignore: ['**.md', 'docs/**', 'LICENSE', '.github/**']
+ workflow_dispatch:
+ inputs:
+ embeddings:
+ description: 'Enable embedding generation (slow, increases index size)'
+ type: boolean
+ default: false
+ force:
+ description: 'Force full re-index'
+ type: boolean
+ default: false
+ # When invoked from the /gitnexus index PR command, the command
+ # workflow fills these so the index is built from the PR's head
+ # ref and uploaded under the PR-numbered artifact name.
+ pr_number:
+ description: 'PR number to index (set by /gitnexus command)'
+ type: string
+ default: ''
+ pr_ref:
+ description: 'Optional PR head ref to check out; defaults to refs/pull//head when pr_number is set'
+ type: string
+ default: ''
+ deploy_after:
+ description: 'Dispatch GitNexus Deploy after a successful index run'
+ type: boolean
+ default: false
+
+permissions:
+ contents: read
+
+concurrency:
+ # When triggered by the /gitnexus command, group by PR number so rapid
+ # re-runs coalesce. Otherwise group by git ref as before.
+ group: gitnexus-${{ inputs.pr_number != '' && format('pr-{0}', inputs.pr_number) || github.ref }}
+ cancel-in-progress: true
+
+env:
+ GITNEXUS_VERSION: '1.6.7'
+
+jobs:
+ index:
+ permissions:
+ contents: read
+ pull-requests: read # read changed files to decide whether embeddings are needed
+ # Push + dispatch run unconditionally. The pull_request trigger is
+ # disabled (see `on:` above), so this never runs automatically on a
+ # PR. PRs are indexed on demand instead:
+ # - /gitnexus index (PR comment command, contributor-gated)
+ # - workflow_dispatch (manual dispatch from Actions UI)
+ # Both arrive as workflow_dispatch. The pull_request guard is kept as
+ # a safety net should the trigger ever be re-added.
+ if: |
+ github.event_name != 'pull_request' ||
+ github.event.pull_request.user.login == 'danny-avila'
+ runs-on: ubuntu-latest
+ # Embedding generation dominates the budget: ~45 min worst case on
+ # standard runners since the 1.6.x graph (~23k nodes) doubled vs 1.5.x.
+ timeout-minutes: 60
+ # Best-effort index: a tool-internal crash must not block PRs. Fail soft on
+ # PR events; push/dispatch runs still fail loudly so regressions stay visible.
+ continue-on-error: ${{ github.event_name == 'pull_request' }}
+ steps:
+ - name: Validate dispatch inputs
+ if: github.event_name == 'workflow_dispatch'
+ env:
+ PR_NUMBER: ${{ inputs.pr_number }}
+ PR_REF: ${{ inputs.pr_ref }}
+ run: |
+ set -euo pipefail
+ if [ -n "$PR_NUMBER" ]; then
+ if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
+ echo "::error::pr_number must be numeric"
+ exit 1
+ fi
+ EXPECTED_REF="refs/pull/${PR_NUMBER}/head"
+ if [ -n "$PR_REF" ] && [ "$PR_REF" != "$EXPECTED_REF" ]; then
+ echo "::error::pr_ref must match ${EXPECTED_REF}"
+ exit 1
+ fi
+ elif [ -n "$PR_REF" ]; then
+ echo "::error::pr_ref requires pr_number"
+ exit 1
+ fi
+
+ - name: Resolve GitNexus flags
+ id: flags
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ ENABLE_EMBEDDINGS_INPUT: ${{ inputs.embeddings }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_NUM: ${{ github.event.pull_request.number }}
+ run: |
+ set -euo pipefail
+
+ # Decide whether to generate embeddings. Rules:
+ # push (main/dev) -> always embed
+ # pull_request -> embed ONLY when the PR changes files
+ # under paths that also trigger backend
+ # or frontend unit tests (api/, client/,
+ # packages/). Docs/config-only PRs skip
+ # embeddings to save ~3-5 min of CI.
+ # workflow_dispatch -> respect the explicit `embeddings` input
+ # (default false). This also covers the
+ # /gitnexus index [embeddings] command.
+ ENABLE_EMBEDDINGS=false
+ case "$EVENT_NAME" in
+ workflow_dispatch)
+ [ "$ENABLE_EMBEDDINGS_INPUT" = "true" ] && ENABLE_EMBEDDINGS=true
+ ;;
+ push)
+ ENABLE_EMBEDDINGS=true
+ ;;
+ pull_request)
+ CHANGED=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUM/files" \
+ --paginate --jq '.[].filename' 2>/dev/null || echo "")
+ if printf '%s\n' "$CHANGED" | grep -qE '^(api/|client/|packages/)'; then
+ echo "PR #$PR_NUM touches unit-test paths (api|client|packages) — enabling embeddings"
+ ENABLE_EMBEDDINGS=true
+ else
+ echo "PR #$PR_NUM does not touch unit-test paths — graph-only index"
+ fi
+ ;;
+ esac
+
+ if [ "$ENABLE_EMBEDDINGS" = "true" ]; then
+ echo "enable_embeddings=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "enable_embeddings=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - name: Install GitNexus CLI
+ working-directory: ${{ runner.temp }}
+ env:
+ NPM_CONFIG_AUDIT: false
+ NPM_CONFIG_CACHE: ${{ runner.temp }}/gitnexus-npm-cache
+ NPM_CONFIG_FUND: false
+ NPM_CONFIG_GLOBALCONFIG: ${{ runner.temp }}/gitnexus-cli/global-npmrc
+ NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
+ NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/gitnexus-cli/.npmrc
+ run: |
+ set -euo pipefail
+ mkdir -p "$RUNNER_TEMP/gitnexus-cli" "$RUNNER_TEMP/gitnexus-npm-cache"
+ : > "$RUNNER_TEMP/gitnexus-cli/global-npmrc"
+ printf '%s\n' \
+ 'registry=https://registry.npmjs.org/' \
+ 'audit=false' \
+ 'fund=false' \
+ > "$RUNNER_TEMP/gitnexus-cli/.npmrc"
+ # Keep GitNexus' native DB dependency deterministic in fresh CI installs.
+ npm install \
+ --prefix "$RUNNER_TEMP/gitnexus-cli" \
+ --no-save \
+ --no-package-lock \
+ "gitnexus@${{ env.GITNEXUS_VERSION }}" \
+ "@ladybugdb/core@0.17.1"
+ test -x "$RUNNER_TEMP/gitnexus-cli/node_modules/.bin/gitnexus"
+
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ # When the /gitnexus command dispatches us with a pr_ref, it's
+ # a refs/pull//head ref that GitHub mirrors into the base
+ # repo for every PR, so checkout works for fork PRs too. When
+ # pr_ref is empty (native push/pull_request), fall back to the
+ # default ref actions/checkout would use.
+ ref: ${{ inputs.pr_ref || (inputs.pr_number != '' && format('refs/pull/{0}/head', inputs.pr_number) || '') }}
+ fetch-depth: 1
+ persist-credentials: false
+
+ # HuggingFace throttles anonymous model downloads from shared GHA
+ # runner IPs (429s or stalled transfers). Cache the embedding model
+ # across runs so warm runs never touch HF at all.
+ - name: Cache HuggingFace embedding model
+ if: steps.flags.outputs.enable_embeddings == 'true'
+ uses: actions/cache@v4
+ with:
+ path: ${{ runner.temp }}/hf-cache
+ key: hf-model-snowflake-arctic-embed-xs-v1
+
+ - name: Run GitNexus Analyze
+ working-directory: ${{ runner.temp }}
+ env:
+ ENABLE_EMBEDDINGS: ${{ steps.flags.outputs.enable_embeddings }}
+ FORCE: ${{ inputs.force }}
+ GITNEXUS_BIN: ${{ runner.temp }}/gitnexus-cli/node_modules/.bin/gitnexus
+ # Fail soft in ~2 min on stalled downloads instead of eating the
+ # 25-min job budget; HF_TOKEN lifts the anonymous rate limit on
+ # cold-cache runs (empty when the secret is unset — safe no-op).
+ HF_DOWNLOAD_TIMEOUT_MS: '60000'
+ HF_HOME: ${{ runner.temp }}/hf-cache
+ HF_MAX_ATTEMPTS: '2'
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
+ NPM_CONFIG_AUDIT: false
+ NPM_CONFIG_CACHE: ${{ runner.temp }}/gitnexus-npm-cache
+ NPM_CONFIG_FUND: false
+ NPM_CONFIG_GLOBALCONFIG: ${{ runner.temp }}/gitnexus-cli/global-npmrc
+ NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
+ NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/gitnexus-cli/.npmrc
+ run: |
+ set -euo pipefail
+ FLAGS=(--skip-agents-md --verbose)
+
+ if [ "$ENABLE_EMBEDDINGS" = "true" ]; then
+ FLAGS+=(--embeddings)
+ fi
+ if [ "$FORCE" = "true" ]; then
+ FLAGS+=(--force)
+ fi
+ "$GITNEXUS_BIN" analyze "$GITHUB_WORKSPACE" "${FLAGS[@]}"
+
+ - name: Verify index
+ run: |
+ if [ ! -d ".gitnexus" ] || [ ! -f ".gitnexus/meta.json" ]; then
+ echo "::error::GitNexus index was not created"
+ exit 1
+ fi
+ echo "::group::Index metadata"
+ cat .gitnexus/meta.json
+ echo ""
+ echo "::endgroup::"
+
+ - name: Upload GitNexus index
+ uses: actions/upload-artifact@v4
+ with:
+ # Artifact naming order of precedence:
+ # 1. /gitnexus command dispatch: inputs.pr_number -> pr-
+ # 2. Native pull_request event: github.event.pull_request.number
+ # 3. Push or manual dispatch without pr_number: github.ref_name
+ name: >-
+ gitnexus-index-${{
+ inputs.pr_number != ''
+ && format('pr-{0}', inputs.pr_number)
+ || (github.event_name == 'pull_request'
+ && format('pr-{0}', github.event.pull_request.number)
+ || github.ref_name)
+ }}
+ path: .gitnexus/
+ include-hidden-files: true
+ retention-days: 30
+
+ post-index:
+ needs: index
+ if: |
+ always() &&
+ (inputs.pr_number != '' ||
+ inputs.deploy_after)
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ actions: write # dispatch gitnexus-deploy.yml when deploy_after is set
+ pull-requests: write # post completion comments for /gitnexus command runs
+ steps:
+ # GitHub suppresses workflow_run events for workflow runs triggered
+ # by GITHUB_TOKEN (to prevent recursive chaining). Dispatches without
+ # a PR number can still opt into a deploy by setting deploy_after=true.
+ - name: Trigger deploy workflow after non-PR dispatches
+ if: inputs.deploy_after && inputs.pr_number == '' && needs.index.result == 'success'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ core.info('deploy_after=true; dispatching gitnexus-deploy.yml manually.');
+ await github.rest.actions.createWorkflowDispatch({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ workflow_id: 'gitnexus-deploy.yml',
+ ref: 'main',
+ inputs: {
+ pr_number: '',
+ },
+ });
+
+ # Reply on the PR when the /gitnexus command path runs so the
+ # requester knows the index step finished. This fires when
+ # inputs.pr_number is set and reports the index job result.
+ - name: Comment on PR — index complete
+ if: inputs.pr_number != ''
+ uses: actions/github-script@v7
+ env:
+ EMBEDDINGS_INPUT: ${{ inputs.embeddings }}
+ INDEX_RESULT: ${{ needs.index.result }}
+ PR_NUMBER: ${{ inputs.pr_number }}
+ with:
+ script: |
+ const indexSucceeded = process.env.INDEX_RESULT === 'success';
+ const outcome = indexSucceeded ? '✅ indexed' : '❌ index failed';
+ const prNum = parseInt(process.env.PR_NUMBER || '', 10);
+ if (!Number.isSafeInteger(prNum)) {
+ core.setFailed(`Invalid PR number: ${process.env.PR_NUMBER}`);
+ return;
+ }
+ const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
+ const embeddingsFlag = process.env.EMBEDDINGS_INPUT === 'true' ? 'with embeddings' : 'graph-only';
+ const body = [
+ `### GitNexus: ${outcome}`,
+ ``,
+ `PR #${prNum} was indexed ${embeddingsFlag}.`,
+ `[Index run](${runUrl})`,
+ '',
+ indexSucceeded
+ ? 'PR-specific deploys are paused; only `LibreChat` and `LibreChat-dev` are currently served.'
+ : '_Index run failed — the previous index (if any) continues to be served._',
+ ].join('\n');
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNum,
+ body,
+ });
diff --git a/.github/workflows/gitnexus-pr-command.yml b/.github/workflows/gitnexus-pr-command.yml
new file mode 100644
index 0000000..214a526
--- /dev/null
+++ b/.github/workflows/gitnexus-pr-command.yml
@@ -0,0 +1,141 @@
+# Responds to `/gitnexus index` comments on pull requests.
+#
+# Gated to the same author_association roles (OWNER, MEMBER, COLLABORATOR)
+# as the automatic PR index trigger, but applied to the COMMENTER, not
+# the PR author. This intentionally lets a contributor index a PR from
+# a non-contributor / first-time fork author — the contributor takes
+# responsibility for the trust boundary by typing the command.
+#
+# When a matching comment lands on a PR, this workflow dispatches
+# `gitnexus-index.yml` with the PR number and the `refs/pull//head`
+# ref so indexing works for fork PRs too (GitHub mirrors every PR's
+# head ref into the base repo regardless of which fork it originated
+# from, so actions/checkout can always resolve it).
+#
+# Use cases:
+# - Re-index a PR after a rebase without pushing a new commit
+# - Index a docs-only PR that was skipped by paths-ignore
+# - Index a non-contributor (fork) PR that the auto-trigger skipped
+# - Re-run a failed index
+#
+# Supported commands:
+# /gitnexus index — index the PR with embeddings (default)
+# /gitnexus index embeddings — explicit form of the above; same effect
+# /gitnexus index fast — graph-only index (skip embeddings), for
+# a quick re-index without waiting ~5 min
+# of embedding generation
+
+name: GitNexus PR Command
+
+on:
+ issue_comment:
+ types: [created]
+
+permissions:
+ contents: read
+ pull-requests: write
+ actions: write # needed to dispatch gitnexus-index.yml
+
+concurrency:
+ group: gitnexus-pr-command-${{ github.event.issue.number }}
+ cancel-in-progress: false
+
+jobs:
+ dispatch:
+ # Only run for PR comments that start with /gitnexus from trusted
+ # commenters. Intentionally checks the COMMENTER's association so a
+ # contributor can index a non-contributor's PR on demand.
+ if: |
+ github.event.issue.pull_request != null &&
+ startsWith(github.event.comment.body, '/gitnexus') &&
+ (github.event.comment.author_association == 'OWNER' ||
+ github.event.comment.author_association == 'MEMBER' ||
+ github.event.comment.author_association == 'COLLABORATOR')
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Parse command and resolve PR head ref
+ id: parse
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const body = context.payload.comment.body.trim();
+ const match = body.match(/^\/gitnexus\s+(\w+)(?:\s+(\w+))?/);
+ if (!match) {
+ core.setFailed(`Unrecognized command: ${body}. Try: /gitnexus index [fast]`);
+ return;
+ }
+ const [, subcommand, modifier] = match;
+ if (subcommand !== 'index') {
+ core.setFailed(`Unknown subcommand: ${subcommand}. Only 'index' is supported.`);
+ return;
+ }
+ // Default to embeddings on — a contributor typing the command
+ // has already decided they want a full re-index. The `fast`
+ // modifier is the explicit opt-out for graph-only runs.
+ // `embeddings` is accepted as a no-op alias for backwards
+ // compat with the previous command form.
+ let embeddings = 'true';
+ if (modifier === 'fast' || modifier === 'graph-only' || modifier === 'no-embeddings') {
+ embeddings = 'false';
+ }
+
+ // Use refs/pull//head instead of the raw head SHA. GitHub
+ // mirrors every PR's head into the base repo as this ref, so
+ // actions/checkout can always resolve it — even for PRs from
+ // forks whose raw SHAs don't exist in the base repo.
+ const prNum = context.payload.issue.number;
+ core.setOutput('pr_number', String(prNum));
+ core.setOutput('pr_ref', `refs/pull/${prNum}/head`);
+ core.setOutput('embeddings', embeddings);
+ core.info(
+ `Dispatching index for PR #${prNum} at refs/pull/${prNum}/head (embeddings=${embeddings}, modifier=${modifier || '(none)'})`,
+ );
+
+ - name: Dispatch gitnexus-index workflow
+ uses: actions/github-script@v7
+ env:
+ EMBEDDINGS: ${{ steps.parse.outputs.embeddings }}
+ PR_NUMBER: ${{ steps.parse.outputs.pr_number }}
+ PR_REF: ${{ steps.parse.outputs.pr_ref }}
+ with:
+ script: |
+ const prNumber = process.env.PR_NUMBER || '';
+ const prRef = process.env.PR_REF || '';
+ const embeddings = process.env.EMBEDDINGS || 'false';
+ if (!/^[0-9]+$/.test(prNumber)) {
+ core.setFailed(`Invalid PR number: ${prNumber}`);
+ return;
+ }
+ if (prRef !== `refs/pull/${prNumber}/head`) {
+ core.setFailed(`Invalid PR ref: ${prRef}`);
+ return;
+ }
+ if (!['true', 'false'].includes(embeddings)) {
+ core.setFailed(`Invalid embeddings value: ${embeddings}`);
+ return;
+ }
+ await github.rest.actions.createWorkflowDispatch({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ workflow_id: 'gitnexus-index.yml',
+ ref: 'main',
+ inputs: {
+ pr_number: prNumber,
+ pr_ref: prRef,
+ embeddings,
+ force: 'false',
+ deploy_after: 'true',
+ },
+ });
+
+ - name: React to the comment
+ uses: actions/github-script@v7
+ with:
+ script: |
+ await github.rest.reactions.createForIssueComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: context.payload.comment.id,
+ content: 'rocket',
+ });
diff --git a/.github/workflows/helmcharts.yml b/.github/workflows/helmcharts.yml
new file mode 100644
index 0000000..9e0308e
--- /dev/null
+++ b/.github/workflows/helmcharts.yml
@@ -0,0 +1,105 @@
+name: Build Helm Charts on Tag
+
+# The workflow is triggered when a tag is pushed
+on:
+ push:
+ tags:
+ - "chart-*"
+ workflow_dispatch:
+ inputs:
+ chart_tag:
+ description: "Existing chart tag to release, for example chart-2.0.5"
+ required: true
+ type: string
+
+jobs:
+ release:
+ permissions:
+ contents: read
+ packages: write
+ runs-on: ubuntu-latest
+ env:
+ CHART_REPOSITORY: ${{ github.repository_owner }}/librechat-chart
+ steps:
+ - name: Resolve chart tag
+ id: chart-version
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ INPUT_CHART_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.chart_tag || '' }}
+ REF_NAME: ${{ github.ref_name }}
+ run: |
+ set -euo pipefail
+ CHART_TAG="$REF_NAME"
+ if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
+ CHART_TAG="$INPUT_CHART_TAG"
+ fi
+
+ CHART_VERSION="${CHART_TAG#chart-}"
+ SEMVER_REGEX='^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z.-]+)?([+][0-9A-Za-z.-]+)?$'
+ if [[ "$CHART_TAG" != chart-* || ! "$CHART_VERSION" =~ $SEMVER_REGEX ]]; then
+ echo "::error::Chart tags must use the form chart-, for example chart-2.0.3"
+ exit 1
+ fi
+
+ {
+ printf 'CHART_REF=refs/tags/%s\n' "$CHART_TAG"
+ printf 'CHART_TAG=%s\n' "$CHART_TAG"
+ printf 'CHART_VERSION=%s\n' "$CHART_VERSION"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ ref: ${{ steps.chart-version.outputs.CHART_REF }}
+
+ - name: Configure Git
+ run: |
+ git config user.name "$GITHUB_ACTOR"
+ git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
+
+ - name: Install Helm
+ uses: azure/setup-helm@v4
+ env:
+ GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
+
+ - name: Build Subchart Deps
+ run: |
+ cd helm/librechat
+ helm dependency build
+ cd ../librechat-rag-api
+ helm dependency build
+
+ # Log in to GitHub Container Registry
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Run Helm OCI Charts Releaser
+ # This is for the librechat chart
+ - name: Release Helm OCI Charts for librechat
+ uses: appany/helm-oci-chart-releaser@v0.4.2
+ with:
+ name: librechat
+ repository: ${{ env.CHART_REPOSITORY }}
+ tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
+ path: helm/librechat
+ registry: ghcr.io
+ registry_username: ${{ github.actor }}
+ registry_password: ${{ secrets.GITHUB_TOKEN }}
+
+ # this is for the librechat-rag-api chart
+ - name: Release Helm OCI Charts for librechat-rag-api
+ uses: appany/helm-oci-chart-releaser@v0.4.2
+ with:
+ name: librechat-rag-api
+ repository: ${{ env.CHART_REPOSITORY }}
+ tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
+ path: helm/librechat-rag-api
+ registry: ghcr.io
+ registry_username: ${{ github.actor }}
+ registry_password: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/i18n-unused-keys.yml b/.github/workflows/i18n-unused-keys.yml
new file mode 100644
index 0000000..6341c19
--- /dev/null
+++ b/.github/workflows/i18n-unused-keys.yml
@@ -0,0 +1,150 @@
+name: Detect Unused i18next Strings
+
+# This workflow checks for unused i18n keys in translation files.
+# It has special handling for:
+# - com_ui_special_var_* keys that are dynamically constructed
+# - com_agents_category_* keys that are stored in the database and used dynamically
+
+on:
+ pull_request:
+ paths:
+ - "client/src/**"
+ - "api/**"
+ - "packages/data-provider/src/**"
+ - "packages/client/**"
+ - "packages/data-schemas/src/**"
+
+jobs:
+ detect-unused-i18n-keys:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Find unused i18next keys
+ id: find-unused
+ run: |
+ echo "🔍 Scanning for unused i18next keys..."
+
+ # Define paths
+ I18N_FILE="client/src/locales/en/translation.json"
+ SOURCE_DIRS=("client/src" "api" "packages/data-provider/src" "packages/client" "packages/data-schemas/src")
+
+ # Check if translation file exists
+ if [[ ! -f "$I18N_FILE" ]]; then
+ echo "::error title=Missing i18n File::Translation file not found: $I18N_FILE"
+ exit 1
+ fi
+
+ # Extract all keys from the JSON file
+ KEYS=$(jq -r 'keys[]' "$I18N_FILE")
+
+ # Track unused keys
+ UNUSED_KEYS=()
+
+ # Check if each key is used in the source code
+ for KEY in $KEYS; do
+ FOUND=false
+
+ # Special case for dynamically constructed special variable keys
+ if [[ "$KEY" == com_ui_special_var_* ]]; then
+ # Check if TSpecialVarLabel is used in the codebase
+ for DIR in "${SOURCE_DIRS[@]}"; do
+ if grep -r --include=\*.{js,jsx,ts,tsx} -q "TSpecialVarLabel" "$DIR"; then
+ FOUND=true
+ break
+ fi
+ done
+
+ # Also check if the key is directly used somewhere
+ if [[ "$FOUND" == false ]]; then
+ for DIR in "${SOURCE_DIRS[@]}"; do
+ if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
+ FOUND=true
+ break
+ fi
+ done
+ fi
+ # Special case for agent category keys that are dynamically used from database
+ elif [[ "$KEY" == com_agents_category_* ]]; then
+ # Check if agent category localization is being used
+ for DIR in "${SOURCE_DIRS[@]}"; do
+ # Check for dynamic category label/description usage
+ if grep -r --include=\*.{js,jsx,ts,tsx} -E "category\.(label|description).*startsWith.*['\"]com_" "$DIR" > /dev/null 2>&1 || \
+ # Check for the method that defines these keys
+ grep -r --include=\*.{js,jsx,ts,tsx} "ensureDefaultCategories" "$DIR" > /dev/null 2>&1 || \
+ # Check for direct usage in agentCategory.ts
+ grep -r --include=\*.ts -E "label:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1 || \
+ grep -r --include=\*.ts -E "description:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1; then
+ FOUND=true
+ break
+ fi
+ done
+
+ # Also check if the key is directly used somewhere
+ if [[ "$FOUND" == false ]]; then
+ for DIR in "${SOURCE_DIRS[@]}"; do
+ if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
+ FOUND=true
+ break
+ fi
+ done
+ fi
+ else
+ # Regular check for other keys
+ for DIR in "${SOURCE_DIRS[@]}"; do
+ if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
+ FOUND=true
+ break
+ fi
+ done
+ fi
+
+ if [[ "$FOUND" == false ]]; then
+ UNUSED_KEYS+=("$KEY")
+ fi
+ done
+
+ # Output results
+ if [[ ${#UNUSED_KEYS[@]} -gt 0 ]]; then
+ echo "🛑 Found ${#UNUSED_KEYS[@]} unused i18n keys:"
+ echo "unused_keys=$(echo "${UNUSED_KEYS[@]}" | jq -R -s -c 'split(" ")')" >> $GITHUB_ENV
+ for KEY in "${UNUSED_KEYS[@]}"; do
+ echo "::warning title=Unused i18n Key::'$KEY' is defined but not used in the codebase."
+ done
+ else
+ echo "✅ No unused i18n keys detected!"
+ echo "unused_keys=[]" >> $GITHUB_ENV
+ fi
+
+ - name: Post verified comment on PR
+ if: env.unused_keys != '[]'
+ run: |
+ PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
+
+ # Format the unused keys list as checkboxes for easy manual checking.
+ FILTERED_KEYS=$(echo "$unused_keys" | jq -r '.[]' | grep -v '^\s*$' | sed 's/^/- [ ] `/;s/$/`/' )
+
+ COMMENT_BODY=$(cat < tag found"
+ exit 1
+ fi
+ printf 'LATEST_TAG=%s\n' "$LATEST_TAG" >> "$GITHUB_ENV"
+
+ - name: Compute build metadata
+ run: |
+ printf 'BUILD_COMMIT=%s\n' "$(git rev-parse HEAD)" >> "$GITHUB_ENV"
+ printf 'BUILD_BRANCH=main\n' >> "$GITHUB_ENV"
+ printf 'BUILD_DATE=%s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_ENV"
+
+ # Set up QEMU
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ # Set up Docker Buildx
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ # Log in to GitHub Container Registry
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Login to Docker Hub
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ # Prepare the environment
+ - name: Prepare environment
+ run: |
+ cp .env.example .env
+
+ # Build and push Docker images for each target
+ - name: Build and push Docker images
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ${{ matrix.file }}
+ push: true
+ tags: |
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ env.LATEST_TAG }}
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ env.LATEST_TAG }}
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
+ platforms: linux/amd64,linux/arm64
+ target: ${{ matrix.target }}
+ build-args: |
+ BUILD_COMMIT=${{ env.BUILD_COMMIT }}
+ BUILD_BRANCH=${{ env.BUILD_BRANCH }}
+ BUILD_DATE=${{ env.BUILD_DATE }}
diff --git a/.github/workflows/playwright-mock.yml b/.github/workflows/playwright-mock.yml
new file mode 100644
index 0000000..fc94dc0
--- /dev/null
+++ b/.github/workflows/playwright-mock.yml
@@ -0,0 +1,137 @@
+name: Playwright E2E Tests
+
+on:
+ pull_request:
+ workflow_dispatch:
+ inputs:
+ reason:
+ description: 'Reason for manual trigger'
+ required: false
+ default: 'Manual e2e run'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: playwright-mock-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
+ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
+
+jobs:
+ e2e:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ env:
+ E2E_CHROMIUM_CHANNEL: chrome
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ client/node_modules
+ packages/client/node_modules
+ packages/data-provider/node_modules
+ packages/data-schemas/node_modules
+ packages/api/node_modules
+ api/node_modules
+ key: node-modules-e2e-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Restore data-provider build cache
+ id: cache-data-provider
+ uses: actions/cache@v4
+ with:
+ path: packages/data-provider/dist
+ key: build-data-provider-${{ runner.os }}-${{ hashFiles('package-lock.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-provider
+ if: steps.cache-data-provider.outputs.cache-hit != 'true'
+ run: npm run build:data-provider
+
+ - name: Restore data-schemas build cache
+ id: cache-data-schemas
+ uses: actions/cache@v4
+ with:
+ path: packages/data-schemas/dist
+ key: build-data-schemas-${{ runner.os }}-${{ hashFiles('package-lock.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-schemas
+ if: steps.cache-data-schemas.outputs.cache-hit != 'true'
+ run: npm run build:data-schemas
+
+ - name: Restore api build cache
+ id: cache-api
+ uses: actions/cache@v4
+ with:
+ path: packages/api/dist
+ key: build-api-${{ runner.os }}-${{ hashFiles('package-lock.json', 'packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
+
+ - name: Build api
+ if: steps.cache-api.outputs.cache-hit != 'true'
+ run: npm run build:api
+
+ - name: Restore client-package build cache
+ id: cache-client-package
+ uses: actions/cache@v4
+ with:
+ path: packages/client/dist
+ key: build-client-package-${{ runner.os }}-${{ hashFiles('package-lock.json', 'packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build client-package
+ if: steps.cache-client-package.outputs.cache-hit != 'true'
+ run: npm run build:client-package
+
+ - name: Restore client app build cache
+ id: cache-client-app
+ uses: actions/cache@v4
+ with:
+ path: client/dist
+ key: build-client-app-e2e-${{ runner.os }}-${{ hashFiles('package-lock.json', 'client/src/**', 'client/public/**', 'client/scripts/post-build.cjs', 'client/index.html', 'client/package.json', 'client/vite.config.*', 'client/tsconfig*.json', 'client/tailwind.config.*', 'client/postcss.config.*', 'packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build client app
+ if: steps.cache-client-app.outputs.cache-hit != 'true'
+ run: npm run build:client
+
+ - name: Install Playwright runtime dependencies
+ timeout-minutes: 5
+ run: |
+ google-chrome --version
+ npx playwright install-deps chrome
+
+ - name: Run mock-LLM Tier-1 e2e
+ run: npx playwright test --config=e2e/playwright.config.mock.ts
+ env:
+ CI: 'true'
+
+ - name: Upload Playwright HTML report
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report
+ path: e2e/playwright-report/**
+ retention-days: 7
+ if-no-files-found: ignore
+
+ - name: Upload traces & screenshots
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-test-results
+ path: e2e/specs/.test-results/**
+ retention-days: 7
+ if-no-files-found: ignore
diff --git a/.github/workflows/retry-docker-builds.yml b/.github/workflows/retry-docker-builds.yml
new file mode 100644
index 0000000..f454397
--- /dev/null
+++ b/.github/workflows/retry-docker-builds.yml
@@ -0,0 +1,136 @@
+name: Retry Failed Docker Builds
+
+on:
+ workflow_run:
+ workflows:
+ - Docker Build Smoke Tests
+ - Docker Compose Build Latest Main Image Tag (Manual Dispatch)
+ - Docker Dev Branch Images Build
+ - Docker Dev Images Build
+ - Docker Dev Staging Images Build
+ - Docker Images Build on Tag
+ types:
+ - completed
+
+permissions:
+ actions: write
+ contents: read
+
+jobs:
+ retry-failed-jobs:
+ name: Re-run failed jobs
+ if: >
+ (github.event.workflow_run.conclusion == 'failure' ||
+ github.event.workflow_run.conclusion == 'timed_out') &&
+ github.event.workflow_run.run_attempt < 3
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check failed run is still current
+ id: current-run
+ env:
+ GH_TOKEN: ${{ github.token }}
+ HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
+ HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
+ RUN_EVENT: ${{ github.event.workflow_run.event }}
+ RUN_ID: ${{ github.event.workflow_run.id }}
+ WORKFLOW_ID: ${{ github.event.workflow_run.workflow_id }}
+ WORKFLOW_NAME: ${{ github.event.workflow_run.name }}
+ run: |
+ set -euo pipefail
+
+ encode_uri() {
+ jq -nr --arg value "$1" '$value | @uri'
+ }
+
+ workflow_runs_path="repos/${GITHUB_REPOSITORY}/actions/workflows/${WORKFLOW_ID}/runs"
+ query="per_page=1&exclude_pull_requests=false"
+
+ if [ "$WORKFLOW_NAME" = "Docker Images Build on Tag" ]; then
+ if [ -z "$HEAD_BRANCH" ]; then
+ echo "No tag name found for ${WORKFLOW_NAME}; skipping retry."
+ echo "should_retry=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ if ! tag_ref=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/$(encode_uri "$HEAD_BRANCH")" --jq '.object'); then
+ echo "Tag ${HEAD_BRANCH} no longer exists; skipping retry."
+ echo "should_retry=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ tag_object_type=$(jq -r '.type' <<< "$tag_ref")
+ tag_object_sha=$(jq -r '.sha' <<< "$tag_ref")
+
+ if [ "$tag_object_type" = "tag" ]; then
+ tag_head_sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object_sha}" --jq '.object.sha')
+ else
+ tag_head_sha="$tag_object_sha"
+ fi
+
+ if [ "$tag_head_sha" = "$HEAD_SHA" ]; then
+ echo "should_retry=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ echo "Skipping retry for stale ${WORKFLOW_NAME} run ${RUN_ID}; tag ${HEAD_BRANCH} now points to ${tag_head_sha}."
+ echo "should_retry=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ if [ -n "$RUN_EVENT" ]; then
+ query="${query}&event=$(encode_uri "$RUN_EVENT")"
+ fi
+
+ if [ -n "$HEAD_BRANCH" ]; then
+ query_with_ref="${query}&branch=$(encode_uri "$HEAD_BRANCH")"
+ latest_run=$(gh api "${workflow_runs_path}?${query_with_ref}" --jq '.workflow_runs[0] // empty')
+ else
+ latest_run=""
+ fi
+
+ if [ -z "$latest_run" ]; then
+ latest_run=$(gh api "${workflow_runs_path}?${query}" --jq '.workflow_runs[0] // empty')
+ fi
+
+ if [ -z "$latest_run" ]; then
+ echo "No matching workflow runs found for ${WORKFLOW_NAME}; skipping retry."
+ echo "should_retry=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ latest_run_id=$(jq -r '.id' <<< "$latest_run")
+ latest_head_sha=$(jq -r '.head_sha' <<< "$latest_run")
+
+ if [ "$latest_run_id" = "$RUN_ID" ] || [ "$latest_head_sha" = "$HEAD_SHA" ]; then
+ echo "should_retry=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ echo "Skipping retry for stale ${WORKFLOW_NAME} run ${RUN_ID}; newer run ${latest_run_id} is at ${latest_head_sha}."
+ echo "should_retry=false" >> "$GITHUB_OUTPUT"
+
+ - name: Re-run failed Docker jobs
+ if: steps.current-run.outputs.should_retry == 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
+ RUN_ID: ${{ github.event.workflow_run.id }}
+ WORKFLOW_NAME: ${{ github.event.workflow_run.name }}
+ run: |
+ set -euo pipefail
+
+ cancelled_jobs=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/jobs?per_page=100" \
+ | jq -s '[.[].jobs[]? | select(.conclusion == "cancelled")] | length')
+
+ if [ "$cancelled_jobs" -gt 0 ]; then
+ endpoint="rerun"
+ echo "Found ${cancelled_jobs} cancelled job(s); re-running the full workflow run."
+ else
+ endpoint="rerun-failed-jobs"
+ echo "Re-running failed jobs only."
+ fi
+
+ echo "Retrying ${WORKFLOW_NAME} (run ${RUN_ID}, attempt ${RUN_ATTEMPT})."
+ gh api \
+ --method POST \
+ "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/${endpoint}"
diff --git a/.github/workflows/sync-helm-chart-tags.yml b/.github/workflows/sync-helm-chart-tags.yml
new file mode 100644
index 0000000..bde4c2f
--- /dev/null
+++ b/.github/workflows/sync-helm-chart-tags.yml
@@ -0,0 +1,85 @@
+name: Sync Helm Chart Tags
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+ inputs:
+ release_existing_tag:
+ description: "Existing chart-* tag to dispatch if tag creation succeeded but release dispatch failed"
+ required: false
+ type: string
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sync-helm-chart-tags
+ cancel-in-progress: false
+
+jobs:
+ noop:
+ name: Ignore non-main push
+ if: github.event_name == 'push' && github.ref != 'refs/heads/main'
+ runs-on: ubuntu-latest
+ timeout-minutes: 1
+ steps:
+ - name: Skip tag sync
+ run: echo "Helm chart tag sync only runs on main pushes or manual dispatch."
+
+ sync:
+ name: Sync chart tags
+ if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ actions: write
+ contents: write
+ env:
+ BASE_REF: refs/remotes/origin/main
+ BACKFILL_FROM_VERSION: 1.9.0
+ CHART_PATH: helm/librechat/Chart.yaml
+ DEFAULT_BRANCH: main
+ DISPATCH_WORKFLOW: helmcharts.yml
+ GITHUB_SERVER_URL: ${{ github.server_url }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ RELEASE_EXISTING_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_existing_tag || '' }}
+ REPO_DIR: /tmp/librechat-sync
+ TAG_PREFIX: chart-
+ steps:
+ - name: Fetch main and tags
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ if [[ ! "$GITHUB_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
+ echo "::error::Unexpected repository name: $GITHUB_REPOSITORY"
+ exit 1
+ fi
+
+ if [[ "$GITHUB_SERVER_URL" != "https://github.com" ]]; then
+ echo "::error::Unexpected GitHub server URL: $GITHUB_SERVER_URL"
+ exit 1
+ fi
+
+ rm -rf "$REPO_DIR"
+ git init "$REPO_DIR"
+ cd "$REPO_DIR"
+ git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
+ AUTH_HEADER="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')"
+ git -c "http.extraheader=AUTHORIZATION: basic ${AUTH_HEADER}" \
+ fetch --prune --force --tags origin \
+ "+refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}"
+ git checkout --detach "$BASE_REF"
+
+ - name: Create missing chart tags
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PUSH_TAGS: "true"
+ run: |
+ set -euo pipefail
+ cd "$REPO_DIR"
+ .github/scripts/sync-helm-chart-tags.sh
diff --git a/.github/workflows/tag-images.yml b/.github/workflows/tag-images.yml
new file mode 100644
index 0000000..3b4dfc0
--- /dev/null
+++ b/.github/workflows/tag-images.yml
@@ -0,0 +1,118 @@
+name: Docker Images Build on Tag
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ include:
+ - target: api-build
+ file: Dockerfile.multi
+ image_name: librechat-api
+ - target: node
+ file: Dockerfile
+ image_name: librechat
+
+ steps:
+ # Check out the repository
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Validate release tag
+ id: release-tag
+ env:
+ REF_NAME: ${{ github.ref_name }}
+ run: |
+ set -euo pipefail
+ TAG_REGEX='^v[0-9]+[.][0-9]+[.][0-9]+(-rc[0-9]+)?$'
+ STABLE_TAG_REGEX='^v[0-9]+[.][0-9]+[.][0-9]+$'
+ if [[ ! "$REF_NAME" =~ $TAG_REGEX ]]; then
+ echo "::error::Docker release tags must use v or v-rcN, for example v0.8.5 or v0.8.5-rc1"
+ exit 1
+ fi
+ printf 'image_tag=%s\n' "$REF_NAME" >> "$GITHUB_OUTPUT"
+ if [[ "$REF_NAME" =~ $STABLE_TAG_REGEX ]]; then
+ echo "is_stable=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "is_stable=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ # Set up QEMU
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ # Set up Docker Buildx
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ # Log in to GitHub Container Registry
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Login to Docker Hub
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ # Prepare the environment
+ - name: Prepare environment
+ run: |
+ cp .env.example .env
+
+ - name: Compute build metadata
+ run: |
+ echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV
+ echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
+ echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
+
+ - name: Resolve image tags
+ id: image-tags
+ env:
+ DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+ IMAGE_NAME: ${{ matrix.image_name }}
+ IMAGE_TAG: ${{ steps.release-tag.outputs.image_tag }}
+ IS_STABLE: ${{ steps.release-tag.outputs.is_stable }}
+ run: |
+ set -euo pipefail
+ git fetch --tags --force
+ LATEST_STABLE_TAG=$(git tag --list 'v[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+[.][0-9]+[.][0-9]+$' | head -n 1 || true)
+ {
+ echo 'tags<> "$GITHUB_OUTPUT"
+
+ # Build and push Docker images for each target
+ - name: Build and push Docker images
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ${{ matrix.file }}
+ push: true
+ tags: ${{ steps.image-tags.outputs.tags }}
+ platforms: linux/amd64,linux/arm64
+ target: ${{ matrix.target }}
+ build-args: |
+ BUILD_COMMIT=${{ env.BUILD_COMMIT }}
+ BUILD_BRANCH=${{ env.BUILD_BRANCH }}
+ BUILD_DATE=${{ env.BUILD_DATE }}
diff --git a/.github/workflows/unused-packages.yml b/.github/workflows/unused-packages.yml
new file mode 100644
index 0000000..5401d37
--- /dev/null
+++ b/.github/workflows/unused-packages.yml
@@ -0,0 +1,282 @@
+name: Detect Unused NPM Packages
+
+on:
+ pull_request:
+ paths:
+ - 'package.json'
+ - 'package-lock.json'
+ - 'client/**'
+ - 'api/**'
+ - 'packages/client/**'
+ - 'packages/api/**'
+
+jobs:
+ detect-unused-packages:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+ cache: 'npm'
+
+ - name: Install depcheck
+ run: npm install -g depcheck
+
+ - name: Validate JSON files
+ run: |
+ for FILE in package.json client/package.json api/package.json packages/client/package.json; do
+ if [[ -f "$FILE" ]]; then
+ jq empty "$FILE" || (echo "::error title=Invalid JSON::$FILE is invalid" && exit 1)
+ fi
+ done
+
+ - name: Extract Dependencies Used in Scripts
+ id: extract-used-scripts
+ run: |
+ extract_deps_from_scripts() {
+ local package_file=$1
+ if [[ -f "$package_file" ]]; then
+ jq -r '.scripts | to_entries[].value' "$package_file" | \
+ grep -oE '([a-zA-Z0-9_-]+)' | sort -u > used_scripts.txt
+ else
+ touch used_scripts.txt
+ fi
+ }
+
+ extract_deps_from_scripts "package.json"
+ mv used_scripts.txt root_used_deps.txt
+
+ extract_deps_from_scripts "client/package.json"
+ mv used_scripts.txt client_used_deps.txt
+
+ extract_deps_from_scripts "api/package.json"
+ mv used_scripts.txt api_used_deps.txt
+
+ - name: Extract Dependencies Used in Source Code
+ id: extract-used-code
+ run: |
+ extract_deps_from_code() {
+ local folder=$1
+ local output_file=$2
+
+ # Initialize empty output file
+ > "$output_file"
+
+ if [[ -d "$folder" ]]; then
+ # Extract require() statements (use explicit includes for portability)
+ grep -rEho "require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)" "$folder" \
+ --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
+ sed -E "s/require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)/\1/" >> "$output_file" || true
+
+ # Extract ES6 imports - import x from 'module'
+ grep -rEho "import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
+ --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
+ sed -E "s/import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
+
+ # import 'module' (side-effect imports)
+ grep -rEho "import ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
+ --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
+ sed -E "s/import ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
+
+ # export { x } from 'module' or export * from 'module'
+ grep -rEho "export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
+ --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
+ sed -E "s/export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
+
+ # import type { x } from 'module' (TypeScript)
+ grep -rEho "import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
+ --include='*.ts' --include='*.tsx' 2>/dev/null | \
+ sed -E "s/import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
+
+ # Remove subpath imports but keep the base package
+ # For scoped packages: '@scope/pkg/subpath' -> '@scope/pkg'
+ # For regular packages: 'pkg/subpath' -> 'pkg'
+ # Scoped packages (must keep @scope/package, strip anything after)
+ sed -i -E 's|^(@[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true
+ # Non-scoped packages (keep package name, strip subpath)
+ sed -i -E 's|^([a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true
+
+ sort -u "$output_file" -o "$output_file"
+ fi
+ }
+
+ extract_deps_from_code "." root_used_code.txt
+ extract_deps_from_code "client" client_used_code.txt
+ extract_deps_from_code "api" api_used_code.txt
+
+ # Extract dependencies used by workspace packages
+ # These packages are used in the workspace but dependencies are provided by parent package.json
+ extract_deps_from_code "packages/client" packages_client_used_code.txt
+ extract_deps_from_code "packages/api" packages_api_used_code.txt
+
+ - name: Get @librechat/client dependencies
+ id: get-librechat-client-deps
+ run: |
+ if [[ -f "packages/client/package.json" ]]; then
+ # Get all dependencies from @librechat/client (dependencies, devDependencies, and peerDependencies)
+ DEPS=$(jq -r '.dependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
+ DEV_DEPS=$(jq -r '.devDependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
+ PEER_DEPS=$(jq -r '.peerDependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
+
+ # Combine all dependencies
+ echo "$DEPS" > librechat_client_deps.txt
+ echo "$DEV_DEPS" >> librechat_client_deps.txt
+ echo "$PEER_DEPS" >> librechat_client_deps.txt
+
+ # Also include dependencies that are imported in packages/client
+ cat packages_client_used_code.txt >> librechat_client_deps.txt
+
+ # Remove empty lines and sort
+ grep -v '^$' librechat_client_deps.txt | sort -u > temp_deps.txt
+ mv temp_deps.txt librechat_client_deps.txt
+ else
+ touch librechat_client_deps.txt
+ fi
+
+ - name: Get @librechat/api dependencies
+ id: get-librechat-api-deps
+ run: |
+ if [[ -f "packages/api/package.json" ]]; then
+ # Get all dependencies from @librechat/api (dependencies, devDependencies, and peerDependencies)
+ DEPS=$(jq -r '.dependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
+ DEV_DEPS=$(jq -r '.devDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
+ PEER_DEPS=$(jq -r '.peerDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
+
+ # Combine all dependencies
+ echo "$DEPS" > librechat_api_deps.txt
+ echo "$DEV_DEPS" >> librechat_api_deps.txt
+ echo "$PEER_DEPS" >> librechat_api_deps.txt
+
+ # Also include dependencies that are imported in packages/api
+ cat packages_api_used_code.txt >> librechat_api_deps.txt
+
+ # Remove empty lines and sort
+ grep -v '^$' librechat_api_deps.txt | sort -u > temp_deps.txt
+ mv temp_deps.txt librechat_api_deps.txt
+ else
+ touch librechat_api_deps.txt
+ fi
+
+ - name: Extract Workspace Dependencies
+ id: extract-workspace-deps
+ run: |
+ # Function to get dependencies from a workspace package that are used by another package
+ get_workspace_package_deps() {
+ local package_json=$1
+ local output_file=$2
+
+ # Get all workspace dependencies (starting with @librechat/)
+ if [[ -f "$package_json" ]]; then
+ local workspace_deps=$(jq -r '.dependencies // {} | to_entries[] | select(.key | startswith("@librechat/")) | .key' "$package_json" 2>/dev/null || echo "")
+
+ # For each workspace dependency, get its dependencies
+ for dep in $workspace_deps; do
+ # Convert @librechat/api to packages/api
+ local workspace_path=$(echo "$dep" | sed 's/@librechat\//packages\//')
+ local workspace_package_json="${workspace_path}/package.json"
+
+ if [[ -f "$workspace_package_json" ]]; then
+ # Extract all dependencies from the workspace package
+ jq -r '.dependencies // {} | keys[]' "$workspace_package_json" 2>/dev/null >> "$output_file"
+ # Also extract peerDependencies
+ jq -r '.peerDependencies // {} | keys[]' "$workspace_package_json" 2>/dev/null >> "$output_file"
+ fi
+ done
+ fi
+
+ if [[ -f "$output_file" ]]; then
+ sort -u "$output_file" -o "$output_file"
+ else
+ touch "$output_file"
+ fi
+ }
+
+ # Get workspace dependencies for each package
+ get_workspace_package_deps "package.json" root_workspace_deps.txt
+ get_workspace_package_deps "client/package.json" client_workspace_deps.txt
+ get_workspace_package_deps "api/package.json" api_workspace_deps.txt
+
+ - name: Run depcheck for root package.json
+ id: check-root
+ run: |
+ if [[ -f "package.json" ]]; then
+ UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
+ # Exclude dependencies used in scripts, code, and workspace packages
+ UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat root_used_deps.txt root_used_code.txt root_workspace_deps.txt | sort) || echo "")
+ echo "ROOT_UNUSED<> $GITHUB_ENV
+ echo "$UNUSED" >> $GITHUB_ENV
+ echo "EOF" >> $GITHUB_ENV
+ fi
+
+ - name: Run depcheck for client/package.json
+ id: check-client
+ run: |
+ if [[ -f "client/package.json" ]]; then
+ chmod -R 755 client
+ cd client
+ UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
+ # Exclude dependencies used in scripts, code, workspace packages, and @librechat/client imports
+ UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt ../client_workspace_deps.txt ../packages_client_used_code.txt ../librechat_client_deps.txt 2>/dev/null | sort -u) || echo "")
+ # Filter out false positives
+ UNUSED=$(echo "$UNUSED" | grep -v "^micromark-extension-llm-math$" || echo "")
+ echo "CLIENT_UNUSED<> $GITHUB_ENV
+ echo "$UNUSED" >> $GITHUB_ENV
+ echo "EOF" >> $GITHUB_ENV
+ cd ..
+ fi
+
+ - name: Run depcheck for api/package.json
+ id: check-api
+ run: |
+ if [[ -f "api/package.json" ]]; then
+ chmod -R 755 api
+ cd api
+ UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
+ # Exclude dependencies used in scripts, code, workspace packages, and @librechat/api imports
+ UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../api_used_deps.txt ../api_used_code.txt ../api_workspace_deps.txt ../packages_api_used_code.txt ../librechat_api_deps.txt 2>/dev/null | sort -u) || echo "")
+ echo "API_UNUSED<> $GITHUB_ENV
+ echo "$UNUSED" >> $GITHUB_ENV
+ echo "EOF" >> $GITHUB_ENV
+ cd ..
+ fi
+
+ - name: Post comment on PR if unused dependencies are found
+ if: env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != ''
+ run: |
+ PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
+
+ ROOT_LIST=$(echo "$ROOT_UNUSED" | awk '{print "- `" $0 "`"}')
+ CLIENT_LIST=$(echo "$CLIENT_UNUSED" | awk '{print "- `" $0 "`"}')
+ API_LIST=$(echo "$API_UNUSED" | awk '{print "- `" $0 "`"}')
+
+ COMMENT_BODY=$(cat </**"],
+ "program": "${workspaceFolder}/api/server/index.js",
+ "env": {
+ "NODE_ENV": "production",
+ "NODE_TLS_REJECT_UNAUTHORIZED": "0"
+ },
+ "console": "integratedTerminal",
+ "envFile": "${workspaceFolder}/.env"
+ }
+ ]
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..9b8a940
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1 @@
+See CLAUDE.md.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..9e5d41b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,84 @@
+# v0.8.7
+
+# Base node image
+FROM node:24.16.0-alpine AS node
+
+RUN apk upgrade --no-cache
+RUN apk add --no-cache jemalloc
+RUN apk add --no-cache python3 py3-pip uv
+
+# Set environment variable to use jemalloc
+ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
+
+# Add `uv` for extended MCP support
+COPY --from=ghcr.io/astral-sh/uv:0.9.5-python3.12-alpine /usr/local/bin/uv /usr/local/bin/uvx /bin/
+RUN uv --version
+
+# Set configurable max-old-space-size with default
+ARG NODE_MAX_OLD_SPACE_SIZE=6144
+ARG NPM_CI_TIMEOUT_SECONDS=1500
+ARG NPM_CI_ATTEMPTS=2
+
+RUN mkdir -p /app && chown node:node /app
+WORKDIR /app
+
+USER node
+
+COPY --chown=node:node package.json package-lock.json ./
+COPY --chown=node:node api/package.json ./api/package.json
+COPY --chown=node:node client/package.json ./client/package.json
+COPY --chown=node:node packages/data-provider/package.json ./packages/data-provider/package.json
+COPY --chown=node:node packages/data-schemas/package.json ./packages/data-schemas/package.json
+COPY --chown=node:node packages/api/package.json ./packages/api/package.json
+
+RUN \
+ # Allow mounting of these files, which have no default
+ touch .env ; \
+ # Create directories for the volumes to inherit the correct permissions
+ mkdir -p /app/client/public/images /app/logs /app/uploads /app/skill ; \
+ npm config set fetch-retry-maxtimeout 600000 ; \
+ npm config set fetch-retries 5 ; \
+ npm config set fetch-retry-mintimeout 15000 ; \
+ attempt=1 ; \
+ until timeout "$NPM_CI_TIMEOUT_SECONDS" npm ci --no-audit ; do \
+ status=$? ; \
+ if [ "$attempt" -ge "$NPM_CI_ATTEMPTS" ]; then \
+ exit "$status" ; \
+ fi ; \
+ echo "npm ci --no-audit failed with exit code $status; retrying attempt $((attempt + 1))/$NPM_CI_ATTEMPTS" ; \
+ attempt=$((attempt + 1)) ; \
+ npm cache clean --force || true ; \
+ sleep 10 ; \
+ done
+
+COPY --chown=node:node . .
+
+RUN \
+ # React client build with configurable memory
+ NODE_OPTIONS="--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE}" npm run frontend; \
+ npm prune --production; \
+ npm cache clean --force
+
+# Optional build metadata surfaced in Settings -> About for support triage.
+# Declared here (after the heavy install/build steps) so that commit/date
+# changing on every CI run does not bust the cache for dependency install
+# and frontend build layers. When unset, the backend falls back to local
+# git resolution (if .git is present), and finally to empty values.
+ARG BUILD_COMMIT=
+ARG BUILD_BRANCH=
+ARG BUILD_DATE=
+ENV BUILD_COMMIT=${BUILD_COMMIT}
+ENV BUILD_BRANCH=${BUILD_BRANCH}
+ENV BUILD_DATE=${BUILD_DATE}
+
+# Node API setup
+EXPOSE 3080
+ENV HOST=0.0.0.0
+CMD ["npm", "run", "backend"]
+
+# Optional: for client with nginx routing
+# FROM nginx:stable-alpine AS nginx-client
+# WORKDIR /usr/share/nginx/html
+# COPY --from=node /app/client/dist /usr/share/nginx/html
+# COPY client/nginx.conf /etc/nginx/conf.d/default.conf
+# ENTRYPOINT ["nginx", "-g", "daemon off;"]
diff --git a/Dockerfile.multi b/Dockerfile.multi
new file mode 100644
index 0000000..ce429c0
--- /dev/null
+++ b/Dockerfile.multi
@@ -0,0 +1,129 @@
+# Dockerfile.multi
+# v0.8.7
+
+# Set configurable max-old-space-size with default
+ARG NODE_MAX_OLD_SPACE_SIZE=6144
+
+# Optional build metadata surfaced in Settings -> About for support triage.
+ARG BUILD_COMMIT=
+ARG BUILD_BRANCH=
+ARG BUILD_DATE=
+
+# Base for all builds
+FROM node:24.16.0-alpine AS base-min
+ARG NPM_CI_TIMEOUT_SECONDS=1500
+ARG NPM_CI_ATTEMPTS=2
+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
+
+WORKDIR /app
+RUN apk --no-cache add curl
+RUN npm config set fetch-retry-maxtimeout 600000 && \
+ npm config set fetch-retries 5 && \
+ npm config set fetch-retry-mintimeout 15000
+COPY package*.json ./
+COPY packages/data-provider/package*.json ./packages/data-provider/
+COPY packages/api/package*.json ./packages/api/
+COPY packages/data-schemas/package*.json ./packages/data-schemas/
+COPY packages/client/package*.json ./packages/client/
+COPY client/package*.json ./client/
+COPY api/package*.json ./api/
+
+# Install all dependencies for every build
+FROM base-min AS base
+ARG NPM_CI_TIMEOUT_SECONDS=1500
+ARG NPM_CI_ATTEMPTS=2
+WORKDIR /app
+RUN attempt=1; \
+ until timeout "$NPM_CI_TIMEOUT_SECONDS" npm ci; do \
+ status=$?; \
+ if [ "$attempt" -ge "$NPM_CI_ATTEMPTS" ]; then \
+ exit "$status"; \
+ fi; \
+ echo "npm ci failed with exit code $status; retrying attempt $((attempt + 1))/$NPM_CI_ATTEMPTS"; \
+ attempt=$((attempt + 1)); \
+ npm cache clean --force || true; \
+ sleep 10; \
+ done
+
+# Build `data-provider` package
+FROM base AS data-provider-build
+WORKDIR /app/packages/data-provider
+COPY packages/data-provider ./
+RUN npm run build
+
+# Build `data-schemas` package
+FROM base AS data-schemas-build
+WORKDIR /app/packages/data-schemas
+COPY packages/data-schemas ./
+COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
+RUN npm run build
+
+# Build `api` package
+FROM base AS api-package-build
+WORKDIR /app/packages/api
+COPY packages/api ./
+COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
+COPY --from=data-schemas-build /app/packages/data-schemas/dist /app/packages/data-schemas/dist
+RUN npm run build
+
+# Build `client` package
+FROM base AS client-package-build
+WORKDIR /app/packages/client
+COPY packages/client ./
+COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
+RUN npm run build
+
+# Client build
+FROM base AS client-build
+WORKDIR /app/client
+COPY client ./
+COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
+COPY --from=client-package-build /app/packages/client/dist /app/packages/client/dist
+COPY --from=client-package-build /app/packages/client/src /app/packages/client/src
+ARG NODE_MAX_OLD_SPACE_SIZE
+ENV NODE_OPTIONS="--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE}"
+RUN npm run build
+
+# API setup (including client dist)
+FROM base-min AS api-build
+ARG NPM_CI_TIMEOUT_SECONDS=1500
+ARG NPM_CI_ATTEMPTS=2
+# Add `uv` for extended MCP support
+COPY --from=ghcr.io/astral-sh/uv:0.6.13 /uv /uvx /bin/
+RUN uv --version
+WORKDIR /app
+# Install only production deps
+RUN attempt=1; \
+ until timeout "$NPM_CI_TIMEOUT_SECONDS" npm ci --omit=dev; do \
+ status=$?; \
+ if [ "$attempt" -ge "$NPM_CI_ATTEMPTS" ]; then \
+ exit "$status"; \
+ fi; \
+ echo "npm ci --omit=dev failed with exit code $status; retrying attempt $((attempt + 1))/$NPM_CI_ATTEMPTS"; \
+ attempt=$((attempt + 1)); \
+ npm cache clean --force || true; \
+ sleep 10; \
+ done
+COPY api ./api
+COPY config ./config
+COPY skill ./skill
+COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data-provider/dist
+COPY --from=data-schemas-build /app/packages/data-schemas/dist ./packages/data-schemas/dist
+COPY --from=api-package-build /app/packages/api/dist ./packages/api/dist
+COPY --from=client-build /app/client/dist ./client/dist
+# Propagate build metadata into runtime env so /api/config can expose it.
+# Declared here (after the heavy install/copy steps) so that commit/date
+# changing on every CI run does not bust the cache for those layers.
+ARG BUILD_COMMIT
+ARG BUILD_BRANCH
+ARG BUILD_DATE
+ENV BUILD_COMMIT=${BUILD_COMMIT}
+ENV BUILD_BRANCH=${BUILD_BRANCH}
+ENV BUILD_DATE=${BUILD_DATE}
+WORKDIR /app/api
+EXPOSE 3080
+ENV HOST=0.0.0.0
+CMD ["node", "server/index.js"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5ee6463
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 LibreChat
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..54bf286
--- /dev/null
+++ b/README.md
@@ -0,0 +1,234 @@
+
+
+
+
+
+
+
+
+ English ·
+ 中文
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+# ✨ Features
+
+- 🖥️ **UI & Experience** inspired by ChatGPT with enhanced design and features
+
+- 🤖 **AI Model Selection**:
+ - Anthropic (Claude), AWS Bedrock, OpenAI, Azure OpenAI, Google, Vertex AI, OpenAI Responses API (incl. Azure)
+ - [Custom Endpoints](https://www.librechat.ai/docs/quick_start/custom_endpoints): Use any OpenAI-compatible API with LibreChat, no proxy required
+ - Compatible with [Local & Remote AI Providers](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, and more
+
+- 🔧 **[Code Interpreter API](https://www.librechat.ai/docs/features/code_interpreter)**:
+ - Secure, Sandboxed Execution in Python, Node.js (JS/TS), Go, C/C++, Java, PHP, Rust, and Fortran
+ - Seamless File Handling: Upload, process, and download files directly
+ - No Privacy Concerns: Fully isolated and secure execution
+ - Open-Source & Self-Hostable: powered by [ClickHouse/code-interpreter](https://github.com/ClickHouse/code-interpreter)
+
+- 🔦 **Agents & Tools Integration**:
+ - **[LibreChat Agents](https://www.librechat.ai/docs/features/agents)**:
+ - No-Code Custom Assistants: Build specialized, AI-driven helpers
+ - Agent Marketplace: Discover and deploy community-built agents
+ - Collaborative Sharing: Share agents with specific users and groups
+ - Flexible & Extensible: Use MCP Servers, tools, file search, code execution, and more
+ - [Skills](https://www.librechat.ai/docs/features/skills): Create reusable `SKILL.md` instruction bundles for manual, automatic, or always-on agent workflows
+ - [Subagents](https://www.librechat.ai/docs/features/subagents): Delegate focused work to isolated child agent runs with their own context windows
+ - Compatible with Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, Google, Vertex AI, Responses API, and more
+ - [Model Context Protocol (MCP) Support](https://modelcontextprotocol.io/clients#librechat) for Tools
+
+- 🔍 **Web Search**:
+ - Search the internet and retrieve relevant information to enhance your AI context
+ - Combines search providers, content scrapers, and result rerankers for optimal results
+ - **Customizable Jina Reranking**: Configure custom Jina API URLs for reranking services
+ - **[Learn More →](https://www.librechat.ai/docs/features/web_search)**
+
+- 🪄 **Generative UI with Code Artifacts**:
+ - [Code Artifacts](https://youtu.be/GfTj7O4gmd0?si=WJbdnemZpJzBrJo3) allow creation of React, HTML, and Mermaid diagrams directly in chat
+
+- 🎨 **Image Generation & Editing**
+ - Text-to-image and image-to-image with [GPT-Image-1](https://www.librechat.ai/docs/features/image_gen#1--openai-image-tools-recommended)
+ - Text-to-image with [DALL-E (3/2)](https://www.librechat.ai/docs/features/image_gen#2--dalle-legacy), [Stable Diffusion](https://www.librechat.ai/docs/features/image_gen#3--stable-diffusion-local), [Flux](https://www.librechat.ai/docs/features/image_gen#4--flux), or any [MCP server](https://www.librechat.ai/docs/features/image_gen#5--model-context-protocol-mcp)
+ - Produce stunning visuals from prompts or refine existing images with a single instruction
+
+- 💾 **Presets & Context Management**:
+ - Create, Save, & Share Custom Presets
+ - Switch between AI Endpoints and Presets mid-chat
+ - Edit, Resubmit, and Continue Messages with Conversation branching
+ - Create and share prompts with specific users and groups
+ - [Fork Messages & Conversations](https://www.librechat.ai/docs/features/fork) for Advanced Context control
+
+- 💬 **Multimodal & File Interactions**:
+ - Upload and analyze images with Claude 3, GPT-4.5, GPT-4o, o1, Llama-Vision, and Gemini 📸
+ - Chat with Files using Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, & Google 🗃️
+
+- 🌎 **Multilingual 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, ไทย, ئۇيغۇرچە
+
+- 🧠 **Reasoning UI**:
+ - Dynamic Reasoning UI for Chain-of-Thought/Reasoning AI models like DeepSeek-R1
+
+- 🎨 **Customizable Interface**:
+ - Customizable Dropdown & Interface that adapts to both power users and newcomers
+
+- 🌊 **[Resumable Streams](https://www.librechat.ai/docs/features/resumable_streams)**:
+ - Never lose a response: AI responses automatically reconnect and resume if your connection drops
+ - Multi-Tab & Multi-Device Sync: Open the same chat in multiple tabs or pick up on another device
+ - Production-Ready: Works from single-server setups to horizontally scaled deployments with Redis
+
+- 🗣️ **Speech & Audio**:
+ - Chat hands-free with Speech-to-Text and Text-to-Speech
+ - Automatically send and play Audio
+ - Supports OpenAI, Azure OpenAI, and Elevenlabs
+
+- 📥 **Import & Export Conversations**:
+ - Import Conversations from LibreChat, ChatGPT, Chatbot UI
+ - Export conversations as screenshots, markdown, text, json
+
+- 🔍 **Search & Discovery**:
+ - Search all messages/conversations
+
+- 👥 **Multi-User & Secure Access**:
+ - Multi-User, Secure Authentication with OAuth2, LDAP, & Email Login Support
+ - Built-in Moderation, and Token spend tools
+
+- 🎛️ **[Admin Panel](https://www.librechat.ai/docs/features/admin_panel)**:
+ - Browser-based UI to manage users, groups, roles, and configuration overrides
+ - Edit settings and per-role/group permissions live, without redeploying
+ - Bundled with the Docker Compose stacks for one-command setup
+
+- ⚙️ **Configuration & Deployment**:
+ - Configure Proxy, Reverse Proxy, Docker, & many Deployment options
+ - Use [S3 with CloudFront](https://www.librechat.ai/docs/configuration/cdn/cloudfront) for stable media links, edge delivery, signed cookies, and secured downloads
+ - Use completely local or deploy on the cloud
+
+- 📖 **Open-Source & Community**:
+ - Completely Open-Source & Built in Public
+ - Community-driven development, support, and feedback
+
+[For a thorough review of our features, see our docs here](https://docs.librechat.ai/) 📚
+
+## 🪶 All-In-One AI Conversations with LibreChat
+
+LibreChat is a self-hosted AI chat platform that unifies all major AI providers in a single, privacy-focused interface.
+
+Beyond chat, LibreChat provides AI Agents, Model Context Protocol (MCP) support, Artifacts, Code Interpreter, custom actions, conversation search, and enterprise-ready multi-user authentication.
+
+Open source, actively developed, and built for anyone who values control over their AI infrastructure.
+
+---
+
+## 🌐 Resources
+
+**GitHub Repo:**
+ - **RAG API:** [github.com/danny-avila/rag_api](https://github.com/danny-avila/rag_api)
+ - **Website:** [github.com/LibreChat-AI/librechat.ai](https://github.com/LibreChat-AI/librechat.ai)
+
+**Other:**
+ - **Website:** [librechat.ai](https://librechat.ai)
+ - **Documentation:** [librechat.ai/docs](https://librechat.ai/docs)
+ - **Blog:** [librechat.ai/blog](https://librechat.ai/blog)
+
+---
+
+## 📝 Changelog
+
+Keep up with the latest updates by visiting the releases page and notes:
+- [Releases](https://github.com/danny-avila/LibreChat/releases)
+- [Changelog](https://www.librechat.ai/changelog)
+
+**⚠️ Please consult the [changelog](https://www.librechat.ai/changelog) for breaking changes before updating.**
+
+---
+
+## ⭐ Star History
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+---
+
+## ✨ Contributions
+
+Contributions, suggestions, bug reports and fixes are welcome!
+
+For new features, components, or extensions, please open an issue and discuss before sending a PR.
+
+If you'd like to help translate LibreChat into your language, we'd love your contribution! Improving our translations not only makes LibreChat more accessible to users around the world but also enhances the overall user experience. Please check out our [Translation Guide](https://www.librechat.ai/docs/translation).
+
+---
+
+## 💖 This project exists in its current state thanks to all the people who contribute
+
+
+
+
+
+---
+
+## 🎉 Special Thanks
+
+We thank [Locize](https://locize.com) for their translation management tools that support multiple languages in LibreChat.
+
+
+
+
+
+
diff --git a/README.wehub.md b/README.wehub.md
new file mode 100644
index 0000000..48b36f6
--- /dev/null
+++ b/README.wehub.md
@@ -0,0 +1,7 @@
+# WeHub 来源说明
+
+- 原始项目:`danny-avila/LibreChat`
+- 原始仓库:https://github.com/danny-avila/LibreChat
+- 导入方式:上游默认分支的最新快照
+- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
+- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
diff --git a/README.zh.md b/README.zh.md
new file mode 100644
index 0000000..61c6d58
--- /dev/null
+++ b/README.zh.md
@@ -0,0 +1,230 @@
+
+
+
+
+
+
+
+
+
+
+ English ·
+ 中文
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+# ✨ 功能
+
+- 🖥️ **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 服务器、工具、文件搜索、代码执行等。
+ - [Skills](https://www.librechat.ai/docs/features/skills):创建可复用的 `SKILL.md` 指令包,用于手动、自动或始终启用的智能体工作流。
+ - [Subagents](https://www.librechat.ai/docs/features/subagents):将专门任务委派给拥有独立上下文窗口的隔离子智能体运行。
+ - 兼容自定义端点、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 及多种部署选项。
+ - 使用 [S3 与 CloudFront](https://www.librechat.ai/docs/configuration/cdn/cloudfront) 获得稳定的媒体链接、边缘分发、签名 Cookie 和安全下载。
+ - 可完全本地运行或部署在云端。
+
+- 📖 **开源与社区**:
+ - 完全开源且在公众监督下开发。
+ - 社区驱动的开发、支持与反馈。
+
+[查看我们的文档了解更多功能详情](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 历史
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+---
+
+## ✨ 贡献
+
+欢迎任何形式的贡献、建议、错误报告和修复!
+
+对于新功能、组件或扩展,请在发送 PR 前开启 issue 进行讨论。
+
+如果您想帮助我们将 LibreChat 翻译成您的母语,我们非常欢迎!改进翻译不仅能让全球用户更轻松地使用 LibreChat,还能提升整体用户体验。请查看我们的[翻译指南](https://www.librechat.ai/docs/translation)。
+
+---
+
+## 💖 感谢所有贡献者
+
+
+
+
+
+---
+
+## 🎉 特别鸣谢
+
+感谢 [Locize](https://locize.com) 提供的翻译管理工具,支持 LibreChat 的多语言功能。
+
+
+
+
+
+
diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js
new file mode 100644
index 0000000..b7c5a8a
--- /dev/null
+++ b/api/app/clients/BaseClient.js
@@ -0,0 +1,1536 @@
+const crypto = require('crypto');
+const fetch = require('node-fetch');
+const { logger } = require('@librechat/data-schemas');
+const {
+ countTokens,
+ checkBalance,
+ getBalanceConfig,
+ buildMessageFiles,
+ sanitizeFileForTransmit,
+ extractFileContext,
+ getReferencedQuotes,
+ encodeAndFormatAudios,
+ encodeAndFormatVideos,
+ encodeAndFormatDocuments,
+} = require('@librechat/api');
+const {
+ Constants,
+ FileSources,
+ Tools,
+ ContentTypes,
+ excludedKeys,
+ EModelEndpoint,
+ mergeFileConfig,
+ isParamEndpoint,
+ isAgentsEndpoint,
+ isEphemeralAgentId,
+ supportsBalanceCheck,
+ isBedrockDocumentType,
+ getEndpointFileConfig,
+} = require('librechat-data-provider');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { logViolation } = require('~/cache');
+const TextStream = require('./TextStream');
+const db = require('~/models');
+
+const collectHistoricalFileRefs = (message) => {
+ const refs = [];
+ if (Array.isArray(message.files)) {
+ refs.push(...message.files);
+ }
+ if (Array.isArray(message.attachments)) {
+ refs.push(...message.attachments);
+ }
+ return refs;
+};
+
+const collectHistoricalFileIds = (messages) => {
+ const fileIds = new Set();
+ for (const message of messages) {
+ for (const ref of collectHistoricalFileRefs(message)) {
+ if (ref?.file_id) {
+ fileIds.add(ref.file_id);
+ }
+ }
+ }
+ return Array.from(fileIds);
+};
+
+const buildOwnerFileFilter = (fileIds, user) => {
+ if (!user?.id || fileIds.length === 0) {
+ return null;
+ }
+
+ const filter = {
+ file_id: { $in: fileIds },
+ user: user.id,
+ };
+ if (user.tenantId) {
+ filter.tenantId = user.tenantId;
+ }
+ return filter;
+};
+
+const TOOL_ATTACHMENT_KEYS = [
+ Tools.file_search,
+ Tools.web_search,
+ Tools.ui_resources,
+ Tools.memory,
+];
+const DISPLAY_ATTACHMENT_FIELDS = [
+ 'filename',
+ 'filepath',
+ 'expiresAt',
+ 'conversationId',
+ 'messageId',
+ 'toolCallId',
+ 'name',
+];
+const PER_MESSAGE_FILE_ATTACHMENT_FIELDS = ['messageId', 'toolCallId'];
+
+const pickFields = (source, fields) => {
+ const picked = {};
+ for (const field of fields) {
+ if (source?.[field] !== undefined) {
+ picked[field] = source[field];
+ }
+ }
+ return picked;
+};
+
+const sanitizeDisplayOnlyAttachment = (ref) => {
+ if (!ref || ref.file_id) {
+ return undefined;
+ }
+
+ const attachment = pickFields(ref, DISPLAY_ATTACHMENT_FIELDS);
+ if (TOOL_ATTACHMENT_KEYS.includes(ref.type)) {
+ attachment.type = ref.type;
+ }
+ for (const key of TOOL_ATTACHMENT_KEYS) {
+ if (ref[key] !== undefined) {
+ attachment[key] = ref[key];
+ }
+ }
+
+ return Object.keys(attachment).length > 0 ? attachment : undefined;
+};
+
+const rehydrateMessageFileRefs = (refs, filesById, { preserveDisplayOnly = false } = {}) => {
+ if (!Array.isArray(refs)) {
+ return undefined;
+ }
+
+ const files = [];
+ for (const ref of refs) {
+ const file = filesById.get(ref?.file_id);
+ if (file) {
+ files.push({
+ ...sanitizeFileForTransmit(file),
+ ...pickFields(ref, PER_MESSAGE_FILE_ATTACHMENT_FIELDS),
+ });
+ continue;
+ }
+
+ if (preserveDisplayOnly) {
+ const displayOnlyAttachment = sanitizeDisplayOnlyAttachment(ref);
+ if (displayOnlyAttachment) {
+ files.push(displayOnlyAttachment);
+ }
+ }
+ }
+ return files.length > 0 ? files : undefined;
+};
+
+class BaseClient {
+ constructor(apiKey, options = {}) {
+ this.apiKey = apiKey;
+ this.sender = options.sender ?? 'AI';
+ this.currentDateString = new Date().toLocaleDateString('en-us', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+ /** @type {boolean} */
+ this.skipSaveConvo = false;
+ /** @type {boolean} */
+ this.skipSaveUserMessage = false;
+ /** @type {string} */
+ this.user;
+ /** @type {string} */
+ this.conversationId;
+ /** @type {string} */
+ this.responseMessageId;
+ /** @type {string} */
+ this.parentMessageId;
+ /** @type {TAttachment[]} */
+ this.attachments;
+ /** The key for the usage object's input tokens
+ * @type {string} */
+ this.inputTokensKey = 'prompt_tokens';
+ /** The key for the usage object's output tokens
+ * @type {string} */
+ this.outputTokensKey = 'completion_tokens';
+ /** @type {Set} */
+ this.savedMessageIds = new Set();
+ /**
+ * Flag to determine if the client re-submitted the latest assistant message.
+ * @type {boolean | undefined} */
+ this.continued;
+ /**
+ * Flag to determine if the client has already fetched the conversation while saving new messages.
+ * @type {boolean | undefined} */
+ this.fetchedConvo;
+ /** @type {TMessage[]} */
+ 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() {
+ throw new Error("Method 'setOptions' must be implemented.");
+ }
+
+ async getCompletion() {
+ throw new Error("Method 'getCompletion' must be implemented.");
+ }
+
+ /** @type {sendCompletion} */
+ async sendCompletion() {
+ throw new Error("Method 'sendCompletion' must be implemented.");
+ }
+
+ getSaveOptions() {
+ throw new Error('Subclasses must implement getSaveOptions');
+ }
+
+ async buildMessages() {
+ throw new Error('Subclasses must implement buildMessages');
+ }
+
+ async summarizeMessages() {
+ throw new Error('Subclasses attempted to call summarizeMessages without implementing it');
+ }
+
+ /**
+ * @returns {string}
+ */
+ getResponseModel() {
+ if (isAgentsEndpoint(this.options.endpoint) && this.options.agent && this.options.agent.id) {
+ return this.options.agent.id;
+ }
+
+ return this.modelOptions?.model ?? this.model;
+ }
+
+ /**
+ * Abstract method to get the token count for a message. Subclasses must implement this method.
+ * @param {TMessage} responseMessage
+ * @returns {number}
+ */
+ getTokenCountForResponse(responseMessage) {
+ logger.debug('[BaseClient] `recordTokenUsage` not implemented.', {
+ messageId: responseMessage?.messageId,
+ });
+ }
+
+ /**
+ * Abstract method to record token usage. Subclasses must implement this method.
+ * If a correction to the token usage is needed, the method should return an object with the corrected token counts.
+ * Should only be used if `recordCollectedUsage` was not used instead.
+ * @param {string} [model]
+ * @param {AppConfig['balance']} [balance]
+ * @param {number} promptTokens
+ * @param {number} completionTokens
+ * @param {string} [messageId]
+ * @returns {Promise}
+ */
+ async recordTokenUsage({ model, balance, promptTokens, completionTokens, messageId }) {
+ logger.debug('[BaseClient] `recordTokenUsage` not implemented.', {
+ model,
+ balance,
+ messageId,
+ promptTokens,
+ completionTokens,
+ });
+ }
+
+ /**
+ * Makes an HTTP request and logs the process.
+ *
+ * @param {RequestInfo} url - The URL to make the request to. Can be a string or a Request object.
+ * @param {RequestInit} [init] - Optional init options for the request.
+ * @returns {Promise} - A promise that resolves to the response of the fetch request.
+ */
+ async fetch(_url, init) {
+ let url = _url;
+ if (this.options.directEndpoint) {
+ url = this.options.reverseProxyUrl;
+ }
+ logger.debug(`Making request to ${url}`);
+ if (typeof Bun !== 'undefined') {
+ return await fetch(url, init);
+ }
+ return await fetch(url, init);
+ }
+
+ getBuildMessagesOptions() {
+ throw new Error('Subclasses must implement getBuildMessagesOptions');
+ }
+
+ async generateTextStream(text, onProgress, options = {}) {
+ const stream = new TextStream(text, options);
+ await stream.processTextStream(onProgress);
+ }
+
+ /**
+ * @returns {[string|undefined, string|undefined]}
+ */
+ processOverideIds() {
+ /** @type {Record} */
+ let { overrideConvoId, overrideUserMessageId } = this.options?.req?.body ?? {};
+ if (overrideConvoId) {
+ const [conversationId, index] = overrideConvoId.split(Constants.COMMON_DIVIDER);
+ overrideConvoId = conversationId;
+ if (index !== '0') {
+ this.skipSaveConvo = true;
+ }
+ }
+ if (overrideUserMessageId) {
+ const [userMessageId, index] = overrideUserMessageId.split(Constants.COMMON_DIVIDER);
+ overrideUserMessageId = userMessageId;
+ if (index !== '0') {
+ this.skipSaveUserMessage = true;
+ }
+ }
+
+ return [overrideConvoId, overrideUserMessageId];
+ }
+
+ async setMessageOptions(opts = {}) {
+ if (opts && opts.replaceOptions) {
+ this.setOptions(opts);
+ }
+
+ const [overrideConvoId, overrideUserMessageId] = this.processOverideIds();
+ const { isEdited, isContinued } = opts;
+ const user = opts.user ?? null;
+ this.user = user;
+ const saveOptions = this.getSaveOptions();
+ this.abortController = opts.abortController ?? new AbortController();
+ const requestConvoId = overrideConvoId ?? opts.conversationId;
+ const conversationId = requestConvoId ?? crypto.randomUUID();
+ const parentMessageId = opts.parentMessageId ?? Constants.NO_PARENT;
+ const userMessageId =
+ overrideUserMessageId ?? opts.overrideParentMessageId ?? crypto.randomUUID();
+ let responseMessageId = opts.responseMessageId ?? crypto.randomUUID();
+ let head = isEdited ? responseMessageId : parentMessageId;
+ this.currentMessages = (await this.loadHistory(conversationId, head)) ?? [];
+ this.conversationId = conversationId;
+
+ if (isEdited && !isContinued) {
+ responseMessageId = crypto.randomUUID();
+ head = responseMessageId;
+ this.currentMessages[this.currentMessages.length - 1].messageId = head;
+ }
+
+ if (opts.isRegenerate && responseMessageId.endsWith('_')) {
+ responseMessageId = crypto.randomUUID();
+ }
+
+ this.responseMessageId = responseMessageId;
+
+ return {
+ ...opts,
+ user,
+ head,
+ saveOptions,
+ userMessageId,
+ requestConvoId,
+ conversationId,
+ parentMessageId,
+ responseMessageId,
+ };
+ }
+
+ createUserMessage({ messageId, parentMessageId, conversationId, text }) {
+ return {
+ messageId,
+ parentMessageId,
+ conversationId,
+ sender: 'User',
+ text,
+ isCreatedByUser: true,
+ };
+ }
+
+ async handleStartMethods(message, opts) {
+ const {
+ user,
+ head,
+ saveOptions,
+ userMessageId,
+ requestConvoId,
+ conversationId,
+ parentMessageId,
+ responseMessageId,
+ } = await this.setMessageOptions(opts);
+
+ const userMessage = opts.isEdited
+ ? this.currentMessages[this.currentMessages.length - 2]
+ : this.createUserMessage({
+ messageId: userMessageId,
+ parentMessageId,
+ conversationId,
+ text: message,
+ });
+
+ /**
+ * Attach quoted excerpts (the "Add to chat" selections from `req.body.quotes`)
+ * before `getReqData`/`onStart` fire, so the optimistic bubble, resumable job
+ * metadata, and the saved row all carry them. Only on fresh turns — edits
+ * replay an existing message that already has its quotes. The excerpts are
+ * merged into the model-facing text later, per message, in `buildMessages`,
+ * keeping the stored `text` clean while the count stays consistent.
+ */
+ if (!opts.isEdited) {
+ const referencedQuotes = getReferencedQuotes(this.options.req?.body?.quotes);
+ if (referencedQuotes != null) {
+ userMessage.quotes = referencedQuotes;
+ }
+ }
+
+ if (typeof opts?.getReqData === 'function') {
+ opts.getReqData({
+ userMessage,
+ conversationId,
+ responseMessageId,
+ sender: this.sender,
+ });
+ }
+
+ if (typeof opts?.onStart === 'function') {
+ const isNewConvo = !requestConvoId && parentMessageId === Constants.NO_PARENT;
+ opts.onStart(userMessage, responseMessageId, isNewConvo);
+ }
+
+ return {
+ ...opts,
+ user,
+ head,
+ conversationId,
+ responseMessageId,
+ saveOptions,
+ userMessage,
+ };
+ }
+
+ /**
+ * Adds instructions to the messages array. If the instructions object is empty or undefined,
+ * the original messages array is returned. Otherwise, the instructions are added to the messages
+ * array either at the beginning (default) or preserving the last message at the end.
+ *
+ * @param {Array} messages - An array of messages.
+ * @param {Object} instructions - An object containing instructions to be added to the messages.
+ * @param {boolean} [beforeLast=false] - If true, adds instructions before the last message; if false, adds at the beginning.
+ * @returns {Array} An array containing messages and instructions, or the original messages if instructions are empty.
+ */
+ addInstructions(messages, instructions, beforeLast = false) {
+ if (!instructions || Object.keys(instructions).length === 0) {
+ return messages;
+ }
+
+ if (!beforeLast) {
+ return [instructions, ...messages];
+ }
+
+ // Legacy behavior: add instructions before the last message
+ const payload = [];
+ if (messages.length > 1) {
+ payload.push(...messages.slice(0, -1));
+ }
+
+ payload.push(instructions);
+
+ if (messages.length > 0) {
+ payload.push(messages[messages.length - 1]);
+ }
+
+ return payload;
+ }
+
+ concatenateMessages(messages) {
+ return messages.reduce((acc, message) => {
+ const nameOrRole = message.name ?? message.role;
+ return acc + `${nameOrRole}:\n${message.content}\n\n`;
+ }, '');
+ }
+
+ /**
+ * This method processes an array of messages and returns a context of messages that fit within a specified token limit.
+ * It iterates over the messages from newest to oldest, adding them to the context until the token limit is reached.
+ * If the token limit would be exceeded by adding a message, that message is not added to the context and remains in the original array.
+ * The method uses `push` and `pop` operations for efficient array manipulation, and reverses the context array at the end to maintain the original order of the messages.
+ *
+ * @param {Object} params
+ * @param {TMessage[]} params.messages - An array of messages, each with a `tokenCount` property. The messages should be ordered from oldest to newest.
+ * @param {number} [params.maxContextTokens] - The max number of tokens allowed in the context. If not provided, defaults to `this.maxContextTokens`.
+ * @param {{ role: 'system', content: text, tokenCount: number }} [params.instructions] - Instructions already added to the context at index 0.
+ * @returns {Promise<{
+ * context: TMessage[],
+ * remainingContextTokens: number,
+ * messagesToRefine: TMessage[],
+ * }>} An object with three properties: `context`, `remainingContextTokens`, and `messagesToRefine`.
+ * `context` is an array of messages that fit within the token limit.
+ * `remainingContextTokens` is the number of tokens remaining within the limit after adding the messages to the context.
+ * `messagesToRefine` is an array of messages that were not added to the context because they would have exceeded the token limit.
+ */
+ async getMessagesWithinTokenLimit({ messages: _messages, maxContextTokens, instructions }) {
+ // Every reply is primed with <|start|>assistant<|message|>, so we
+ // start with 3 tokens for the label after all messages have been counted.
+ let currentTokenCount = 3;
+ const instructionsTokenCount = instructions?.tokenCount ?? 0;
+ let remainingContextTokens =
+ (maxContextTokens ?? this.maxContextTokens) - instructionsTokenCount;
+ const messages = [..._messages];
+
+ const context = [];
+
+ if (currentTokenCount < remainingContextTokens) {
+ while (messages.length > 0 && currentTokenCount < remainingContextTokens) {
+ if (messages.length === 1 && instructions) {
+ break;
+ }
+ const poppedMessage = messages.pop();
+ const { tokenCount } = poppedMessage;
+
+ if (poppedMessage && currentTokenCount + tokenCount <= remainingContextTokens) {
+ context.push(poppedMessage);
+ currentTokenCount += tokenCount;
+ } else {
+ messages.push(poppedMessage);
+ break;
+ }
+ }
+ }
+
+ if (instructions) {
+ context.push(_messages[0]);
+ messages.shift();
+ }
+
+ const prunedMemory = messages;
+ remainingContextTokens -= currentTokenCount;
+
+ return {
+ context: context.reverse(),
+ remainingContextTokens,
+ messagesToRefine: prunedMemory,
+ };
+ }
+
+ async sendMessage(message, opts = {}) {
+ const appConfig = this.options.req?.config;
+ /** @type {Promise} */
+ let userMessagePromise;
+ const { user, head, isEdited, conversationId, responseMessageId, saveOptions, userMessage } =
+ await this.handleStartMethods(message, opts);
+
+ if (opts.progressCallback) {
+ opts.onProgress = opts.progressCallback.call(null, {
+ ...(opts.progressOptions ?? {}),
+ parentMessageId: userMessage.messageId,
+ messageId: responseMessageId,
+ });
+ }
+
+ const { editedContent } = opts;
+
+ // It's not necessary to push to currentMessages
+ // depending on subclass implementation of handling messages
+ // When this is an edit, all messages are already in currentMessages, both user and response
+ if (isEdited) {
+ let latestMessage = this.currentMessages[this.currentMessages.length - 1];
+ if (!latestMessage) {
+ latestMessage = {
+ messageId: responseMessageId,
+ conversationId,
+ parentMessageId: userMessage.messageId,
+ isCreatedByUser: false,
+ model: this.modelOptions?.model ?? this.model,
+ sender: this.sender,
+ };
+ this.currentMessages.push(userMessage, latestMessage);
+ } else if (editedContent != null) {
+ // Handle editedContent for content parts
+ if (editedContent && latestMessage.content && Array.isArray(latestMessage.content)) {
+ const { index, text, type } = editedContent;
+ if (index >= 0 && index < latestMessage.content.length) {
+ const contentPart = latestMessage.content[index];
+ if (type === ContentTypes.THINK && contentPart.type === ContentTypes.THINK) {
+ contentPart[ContentTypes.THINK] = text;
+ } else if (type === ContentTypes.TEXT && contentPart.type === ContentTypes.TEXT) {
+ contentPart[ContentTypes.TEXT] = text;
+ }
+ }
+ }
+ }
+ this.continued = true;
+ } else {
+ this.currentMessages.push(userMessage);
+ }
+
+ /**
+ * When the userMessage is pushed to currentMessages, the parentMessage is the userMessageId.
+ * this only matters when buildMessages is utilizing the parentMessageId, and may vary on implementation
+ */
+ const parentMessageId = isEdited ? head : userMessage.messageId;
+ this.parentMessageId = parentMessageId;
+ let {
+ prompt: payload,
+ tokenCountMap,
+ promptTokens,
+ } = await this.buildMessages(
+ this.currentMessages,
+ parentMessageId,
+ this.getBuildMessagesOptions(opts),
+ opts,
+ );
+
+ 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) {
+ const reqFiles = this.options.req?.body?.files;
+ if (reqFiles && Array.isArray(this.options.attachments)) {
+ const files = buildMessageFiles(reqFiles, this.options.attachments);
+ if (files.length > 0) {
+ userMessage.files = files;
+ }
+ delete userMessage.image_urls;
+ }
+ /**
+ * Persist the user's manual skill picks onto the user message so the
+ * frontend `SkillPills` component can render them in history
+ * after reload. UI-only metadata — the runtime skill resolution
+ * pipeline reads the top-level `req.body.manualSkills` separately.
+ * Filter is defense-in-depth on top of Mongoose schema validation:
+ * keeps the DB row free of empty/non-string entries even if a
+ * crafted payload slips past schema checks upstream.
+ */
+ const rawManualSkills = this.options.req?.body?.manualSkills;
+ if (Array.isArray(rawManualSkills) && rawManualSkills.length > 0) {
+ const skills = rawManualSkills.filter((s) => typeof s === 'string' && s.length > 0);
+ if (skills.length > 0) {
+ userMessage.manualSkills = skills;
+ }
+ }
+ /**
+ * Persist the names of skills auto-primed this turn via `always-apply`
+ * frontmatter so `SkillPills` can render pinned-variant badges
+ * on the user bubble that survive reload and history render. Frozen
+ * at turn time (not reconstructed from `Skill.alwaysApply` at render
+ * time) because the flag is mutable — historical turns must keep
+ * their audit trail even if an admin flips `alwaysApply` off later.
+ */
+ const alwaysApplySkillPrimes = this.options.agent?.alwaysApplySkillPrimes;
+ if (Array.isArray(alwaysApplySkillPrimes) && alwaysApplySkillPrimes.length > 0) {
+ const names = alwaysApplySkillPrimes
+ .map((p) => p?.name)
+ .filter((n) => typeof n === 'string' && n.length > 0);
+ if (names.length > 0) {
+ userMessage.alwaysAppliedSkills = names;
+ }
+ }
+ 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({
+ userMessagePromise,
+ });
+ }
+ }
+
+ const balanceConfig = getBalanceConfig(appConfig);
+ if (
+ 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,
+ },
+ },
+ {
+ logViolation,
+ getMultiplier: db.getMultiplier,
+ findBalanceByUser: db.findBalanceByUser,
+ createAutoRefillTransaction: db.createAutoRefillTransaction,
+ balanceConfig,
+ upsertBalanceFields: db.upsertBalanceFields,
+ },
+ );
+ }
+
+ const { completion, metadata } = await this.sendCompletion(payload, opts);
+ if (this.abortController) {
+ this.abortController.requestCompleted = true;
+ }
+
+ /** @type {TMessage} */
+ const responseMessage = {
+ messageId: responseMessageId,
+ conversationId,
+ parentMessageId: userMessage.messageId,
+ isCreatedByUser: false,
+ isEdited,
+ model: this.getResponseModel(),
+ sender: this.sender,
+ promptTokens,
+ iconURL: this.options.iconURL,
+ endpoint: this.options.endpoint,
+ ...(this.metadata ?? {}),
+ metadata: Object.keys(metadata ?? {}).length > 0 ? metadata : undefined,
+ };
+
+ if (typeof completion === 'string') {
+ responseMessage.text = completion;
+ } else if (
+ Array.isArray(completion) &&
+ (this.clientName === EModelEndpoint.agents ||
+ isParamEndpoint(this.options.endpoint, this.options.endpointType))
+ ) {
+ responseMessage.text = '';
+
+ if (!opts.editedContent || this.currentMessages.length === 0) {
+ responseMessage.content = completion;
+ } else {
+ const latestMessage = this.currentMessages[this.currentMessages.length - 1];
+ if (!latestMessage?.content) {
+ responseMessage.content = completion;
+ } else {
+ const existingContent = [...latestMessage.content];
+ const { type: editedType } = opts.editedContent;
+ responseMessage.content = this.mergeEditedContent(
+ existingContent,
+ completion,
+ editedType,
+ );
+ }
+ }
+ } else if (Array.isArray(completion)) {
+ responseMessage.text = completion.join('');
+ }
+
+ if (tokenCountMap && this.recordTokenUsage && this.getTokenCountForResponse) {
+ let completionTokens;
+
+ /**
+ * Metadata about input/output costs for the current message. The client
+ * should provide a function to get the current stream usage metadata; if not,
+ * use the legacy token estimations.
+ * @type {StreamUsage | null} */
+ const usage = this.getStreamUsage != null ? this.getStreamUsage() : null;
+
+ if (usage != null && Number(usage[this.outputTokensKey]) > 0) {
+ responseMessage.tokenCount = usage[this.outputTokensKey];
+ completionTokens = responseMessage.tokenCount;
+ } else {
+ responseMessage.tokenCount = this.getTokenCountForResponse(responseMessage);
+ completionTokens = responseMessage.tokenCount;
+ await this.recordTokenUsage({
+ usage,
+ promptTokens,
+ completionTokens,
+ balance: balanceConfig,
+ /** Note: When using agents, responseMessage.model is the agent ID, not the model */
+ model: this.model,
+ messageId: this.responseMessageId,
+ });
+ }
+
+ logger.debug('[BaseClient] Response token usage', {
+ messageId: responseMessage.messageId,
+ model: responseMessage.model,
+ promptTokens,
+ completionTokens,
+ });
+ }
+
+ if (userMessagePromise) {
+ 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);
+ }
+
+ if (this.options.attachments) {
+ try {
+ saveOptions.files = this.options.attachments.map((attachments) => attachments.file_id);
+ } catch (error) {
+ logger.error('[BaseClient] Error mapping attachments for conversation', error);
+ }
+ }
+
+ if (this.contextMeta) {
+ responseMessage.contextMeta = this.contextMeta;
+ }
+
+ responseMessage.databasePromise = this.saveMessageToDatabase(
+ responseMessage,
+ saveOptions,
+ user,
+ );
+ this.savedMessageIds.add(responseMessage.messageId);
+ return responseMessage;
+ }
+
+ async loadHistory(conversationId, parentMessageId = null) {
+ logger.debug('[BaseClient] Loading history:', { conversationId, parentMessageId });
+
+ const messages = (await db.getMessages({ conversationId, user: this.user })) ?? [];
+
+ if (messages.length === 0) {
+ return [];
+ }
+
+ let mapMethod = null;
+ if (this.getMessageMapMethod) {
+ mapMethod = this.getMessageMapMethod();
+ }
+
+ let _messages = this.constructor.getMessagesForConversation({
+ messages,
+ parentMessageId,
+ mapMethod,
+ });
+
+ _messages = await this.addPreviousAttachments(_messages);
+
+ if (!this.shouldSummarize) {
+ return _messages;
+ }
+
+ for (let i = _messages.length - 1; i >= 0; 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;
+ }
+ }
+
+ if (this.previous_summary) {
+ const { messageId, summary, tokenCount, summaryTokenCount } = this.previous_summary;
+ logger.debug('[BaseClient] Previous summary:', {
+ messageId,
+ summary,
+ tokenCount,
+ summaryTokenCount,
+ });
+ }
+
+ return _messages;
+ }
+
+ /**
+ * Save a message to the database.
+ * @param {TMessage} message
+ * @param {Partial} endpointOptions
+ * @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 = 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: options.endpoint,
+ unfinished: false,
+ user,
+ ...(hasAddedConvo && { addedConvo: true }),
+ },
+ { context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage' },
+ );
+
+ if (this.skipSaveConvo) {
+ return { message: savedMessage };
+ }
+
+ const fieldsToKeep = {
+ conversationId: message.conversationId,
+ endpoint: options.endpoint,
+ endpointType: options.endpointType,
+ ...endpointOptions,
+ };
+ const conversationCreatedAt = options?.req?.conversationCreatedAt;
+ const createdAtOnInsert =
+ conversationCreatedAt != null ? new Date(conversationCreatedAt) : undefined;
+ const validCreatedAtOnInsert =
+ createdAtOnInsert && !Number.isNaN(createdAtOnInsert.getTime())
+ ? createdAtOnInsert
+ : undefined;
+
+ const req = options?.req;
+ const skippedExistingConvoLookup = this.fetchedConvo === true;
+ const hasResolvedConversation =
+ req != null && Object.prototype.hasOwnProperty.call(req, 'resolvedConversation');
+ let existingConvo = null;
+ if (!skippedExistingConvoLookup && hasResolvedConversation) {
+ existingConvo = req.resolvedConversation;
+ } else if (!skippedExistingConvoLookup) {
+ existingConvo = await db.getConvo(req?.user?.id, message.conversationId);
+ }
+ if (hasResolvedConversation) {
+ delete req.resolvedConversation;
+ }
+ const shouldSetCreatedAtOnInsert = !skippedExistingConvoLookup && existingConvo == null;
+
+ const unsetFields = {};
+ const exceptions = new Set(['spec', 'iconURL']);
+ const hasNonEphemeralAgent =
+ isAgentsEndpoint(options.endpoint) &&
+ endpointOptions?.agent_id &&
+ !isEphemeralAgentId(endpointOptions.agent_id);
+ if (hasNonEphemeralAgent) {
+ exceptions.add('model');
+ }
+ if (existingConvo != null) {
+ this.fetchedConvo = true;
+ for (const key in existingConvo) {
+ if (!key) {
+ continue;
+ }
+ if (excludedKeys.has(key) && !exceptions.has(key)) {
+ continue;
+ }
+
+ if (endpointOptions?.[key] === undefined) {
+ unsetFields[key] = 1;
+ }
+ }
+ }
+
+ const conversation = await db.saveConvo(reqCtx, fieldsToKeep, {
+ context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveConvo',
+ unsetFields,
+ createdAtOnInsert: shouldSetCreatedAtOnInsert ? validCreatedAtOnInsert : undefined,
+ });
+
+ return { message: savedMessage, conversation };
+ }
+
+ /**
+ * Update a message in the database.
+ * @param {Partial} message
+ */
+ async updateMessageInDatabase(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;
+ }
+
+ /**
+ * Iterate through messages, building an array based on the parentMessageId.
+ *
+ * This function constructs a conversation thread by traversing messages from a given parentMessageId up to the root message.
+ * It handles cyclic references by ensuring that a message is not processed more than once.
+ * If the 'summary' option is set to true and a message has a 'summary' property:
+ * - The message's 'role' is set to 'system'.
+ * - The message's 'text' is set to its 'summary'.
+ * - If the message has a 'summaryTokenCount', the message's 'tokenCount' is set to 'summaryTokenCount'.
+ * The traversal stops at the message with the 'summary' property.
+ *
+ * Each message object should have an 'id' or 'messageId' property and may have a 'parentMessageId' property.
+ * The 'parentMessageId' is the ID of the message that the current message is a reply to.
+ * If 'parentMessageId' is not present, null, or is Constants.NO_PARENT,
+ * the message is considered a root message.
+ *
+ * @param {Object} options - The options for the function.
+ * @param {TMessage[]} options.messages - An array of message objects. Each object should have either an 'id' or 'messageId' property, and may have a 'parentMessageId' property.
+ * @param {string} options.parentMessageId - The ID of the parent message to start the traversal from.
+ * @param {Function} [options.mapMethod] - An optional function to map over the ordered messages. Applied conditionally based on mapCondition.
+ * @param {(message: TMessage) => boolean} [options.mapCondition] - An optional function to determine whether mapMethod should be applied to a given message. If not provided and mapMethod is set, mapMethod applies to all messages.
+ * @param {boolean} [options.summary=false] - If set to true, the traversal modifies messages with 'summary' and 'summaryTokenCount' properties and stops at the message with a 'summary' property.
+ * @returns {TMessage[]} An array containing the messages in the order they should be displayed, starting with the most recent message with a 'summary' property if the 'summary' option is true, and ending with the message identified by 'parentMessageId'.
+ */
+ static getMessagesForConversation({
+ messages,
+ parentMessageId,
+ mapMethod = null,
+ mapCondition = null,
+ summary = false,
+ }) {
+ if (!messages || messages.length === 0) {
+ return [];
+ }
+
+ const orderedMessages = [];
+ let currentMessageId = parentMessageId;
+ const visitedMessageIds = new Set();
+
+ while (currentMessageId) {
+ if (visitedMessageIds.has(currentMessageId)) {
+ break;
+ }
+ const message = messages.find((msg) => {
+ const messageId = msg.messageId ?? msg.id;
+ return messageId === currentMessageId;
+ });
+
+ visitedMessageIds.add(currentMessageId);
+
+ if (!message) {
+ break;
+ }
+
+ 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;
+ }
+ }
+
+ const shouldMap = mapMethod != null && (mapCondition != null ? mapCondition(resolved) : true);
+ const processedMessage = shouldMap ? mapMethod(resolved) : resolved;
+ orderedMessages.push(processedMessage);
+
+ if (hasSummary) {
+ break;
+ }
+
+ currentMessageId =
+ message.parentMessageId === Constants.NO_PARENT ? null : message.parentMessageId;
+ }
+
+ orderedMessages.reverse();
+ return orderedMessages;
+ }
+
+ /**
+ * Algorithm adapted from "6. Counting tokens for chat API calls" of
+ * https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
+ *
+ * An additional 3 tokens need to be added for assistant label priming after all messages have been counted.
+ * In our implementation, this is accounted for in the getMessagesWithinTokenLimit method.
+ *
+ * The content parts example was adapted from the following example:
+ * https://github.com/openai/openai-cookbook/pull/881/files
+ *
+ * Note: image token calculation is to be done elsewhere where we have access to the image metadata
+ *
+ * @param {Object} message
+ */
+ getTokenCountForMessage(message) {
+ // Note: gpt-3.5-turbo and gpt-4 may update over time. Use default for these as well as for unknown models
+ let tokensPerMessage = 3;
+ let tokensPerName = 1;
+ const model = this.modelOptions?.model ?? this.model;
+
+ if (model === 'gpt-3.5-turbo-0301') {
+ tokensPerMessage = 4;
+ tokensPerName = -1;
+ }
+
+ const processValue = (value) => {
+ if (Array.isArray(value)) {
+ for (let item of value) {
+ if (
+ !item ||
+ !item.type ||
+ item.type === ContentTypes.THINK ||
+ item.type === ContentTypes.ERROR ||
+ item.type === ContentTypes.IMAGE_URL
+ ) {
+ continue;
+ }
+
+ if (item.type === ContentTypes.TOOL_CALL && item.tool_call != null) {
+ const toolName = item.tool_call?.name || '';
+ if (toolName != null && toolName && typeof toolName === 'string') {
+ numTokens += this.getTokenCount(toolName);
+ }
+
+ const args = item.tool_call?.args || '';
+ if (args != null && args && typeof args === 'string') {
+ numTokens += this.getTokenCount(args);
+ }
+
+ const output = item.tool_call?.output || '';
+ if (output != null && output && typeof output === 'string') {
+ numTokens += this.getTokenCount(output);
+ }
+ continue;
+ }
+
+ const nestedValue = item[item.type];
+
+ if (!nestedValue) {
+ continue;
+ }
+
+ processValue(nestedValue);
+ }
+ } else if (typeof value === 'string') {
+ numTokens += this.getTokenCount(value);
+ } else if (typeof value === 'number') {
+ numTokens += this.getTokenCount(value.toString());
+ } else if (typeof value === 'boolean') {
+ numTokens += this.getTokenCount(value.toString());
+ }
+ };
+
+ let numTokens = tokensPerMessage;
+ for (let [key, value] of Object.entries(message)) {
+ processValue(value);
+
+ if (key === 'name') {
+ numTokens += tokensPerName;
+ }
+ }
+ return numTokens;
+ }
+
+ /**
+ * Merges completion content with existing content when editing TEXT or THINK types
+ * @param {Array} existingContent - The existing content array
+ * @param {Array} newCompletion - The new completion content
+ * @param {string} editedType - The type of content being edited
+ * @returns {Array} The merged content array
+ */
+ mergeEditedContent(existingContent, newCompletion, editedType) {
+ if (!newCompletion.length) {
+ return existingContent.concat(newCompletion);
+ }
+
+ if (editedType !== ContentTypes.TEXT && editedType !== ContentTypes.THINK) {
+ return existingContent.concat(newCompletion);
+ }
+
+ const lastIndex = existingContent.length - 1;
+ const lastExisting = existingContent[lastIndex];
+ const firstNew = newCompletion[0];
+
+ if (lastExisting?.type !== firstNew?.type || firstNew?.type !== editedType) {
+ return existingContent.concat(newCompletion);
+ }
+
+ const mergedContent = [...existingContent];
+ if (editedType === ContentTypes.TEXT) {
+ mergedContent[lastIndex] = {
+ ...mergedContent[lastIndex],
+ [ContentTypes.TEXT]:
+ (mergedContent[lastIndex][ContentTypes.TEXT] || '') + (firstNew[ContentTypes.TEXT] || ''),
+ };
+ } else {
+ mergedContent[lastIndex] = {
+ ...mergedContent[lastIndex],
+ [ContentTypes.THINK]:
+ (mergedContent[lastIndex][ContentTypes.THINK] || '') +
+ (firstNew[ContentTypes.THINK] || ''),
+ };
+ }
+
+ // Add remaining completion items
+ return mergedContent.concat(newCompletion.slice(1));
+ }
+
+ async sendPayload(payload, opts = {}) {
+ if (opts && typeof opts === 'object') {
+ this.setOptions(opts);
+ }
+
+ return await this.sendCompletion(payload, opts);
+ }
+
+ async addDocuments(message, attachments) {
+ const documentResult = await encodeAndFormatDocuments(
+ this.options.req,
+ attachments,
+ {
+ 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,
+ );
+ message.documents =
+ documentResult.documents && documentResult.documents.length
+ ? documentResult.documents
+ : undefined;
+ return documentResult.files;
+ }
+
+ async addVideos(message, attachments) {
+ const videoResult = await encodeAndFormatVideos(
+ this.options.req,
+ attachments,
+ {
+ provider: this.options.agent?.provider ?? this.options.endpoint,
+ endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
+ },
+ getStrategyFunctions,
+ );
+ message.videos =
+ videoResult.videos && videoResult.videos.length ? videoResult.videos : undefined;
+ return videoResult.files;
+ }
+
+ async addAudios(message, attachments) {
+ const audioResult = await encodeAndFormatAudios(
+ this.options.req,
+ attachments,
+ {
+ provider: this.options.agent?.provider ?? this.options.endpoint,
+ endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
+ },
+ getStrategyFunctions,
+ );
+ message.audios =
+ audioResult.audios && audioResult.audios.length ? audioResult.audios : undefined;
+ return audioResult.files;
+ }
+
+ /**
+ * Extracts text context from attachments and sets it on the message.
+ * This handles text that was already extracted from files (OCR, transcriptions, document text, etc.)
+ * @param {TMessage} message - The message to add context to
+ * @param {MongoFile[]} attachments - Array of file attachments
+ * @returns {Promise}
+ */
+ async addFileContextToMessage(message, attachments) {
+ const fileContext = await extractFileContext({
+ attachments,
+ req: this.options?.req,
+ tokenCountFn: (text) => countTokens(text),
+ });
+
+ if (fileContext) {
+ message.fileContext = fileContext;
+ }
+ }
+
+ async processAttachments(message, attachments) {
+ const categorizedAttachments = {
+ images: [],
+ videos: [],
+ audios: [],
+ documents: [],
+ };
+
+ const allFiles = [];
+
+ const provider = this.options.agent?.provider ?? this.options.endpoint;
+ const isBedrock = provider === EModelEndpoint.bedrock;
+
+ if (!this._mergedFileConfig) {
+ 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;
+ if (source === FileSources.text) {
+ allFiles.push(file);
+ continue;
+ }
+ if (
+ file.embedded === true ||
+ file.metadata?.codeEnvRef != null ||
+ file.metadata?.fileIdentifier != null
+ ) {
+ allFiles.push(file);
+ continue;
+ }
+
+ if (file.type.startsWith('image/')) {
+ categorizedAttachments.images.push(file);
+ } else if (file.type === 'application/pdf') {
+ categorizedAttachments.documents.push(file);
+ allFiles.push(file);
+ } else if (isBedrock && isBedrockDocumentType(file.type)) {
+ categorizedAttachments.documents.push(file);
+ allFiles.push(file);
+ } else if (file.type.startsWith('video/')) {
+ categorizedAttachments.videos.push(file);
+ allFiles.push(file);
+ } 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);
+ }
+ }
+
+ const [imageFiles] = await Promise.all([
+ categorizedAttachments.images.length > 0
+ ? this.addImageURLs(message, categorizedAttachments.images)
+ : Promise.resolve([]),
+ categorizedAttachments.documents.length > 0
+ ? this.addDocuments(message, categorizedAttachments.documents)
+ : Promise.resolve([]),
+ categorizedAttachments.videos.length > 0
+ ? this.addVideos(message, categorizedAttachments.videos)
+ : Promise.resolve([]),
+ categorizedAttachments.audios.length > 0
+ ? this.addAudios(message, categorizedAttachments.audios)
+ : Promise.resolve([]),
+ ]);
+
+ allFiles.push(...imageFiles);
+
+ const seenFileIds = new Set();
+ const uniqueFiles = [];
+
+ for (const file of allFiles) {
+ if (file.file_id && !seenFileIds.has(file.file_id)) {
+ seenFileIds.add(file.file_id);
+ uniqueFiles.push(file);
+ } else if (!file.file_id) {
+ uniqueFiles.push(file);
+ }
+ }
+
+ return uniqueFiles;
+ }
+
+ /**
+ * @param {TMessage[]} _messages
+ * @returns {Promise}
+ */
+ async addPreviousAttachments(_messages) {
+ if (!this.options.resendFiles) {
+ return _messages;
+ }
+
+ const contextSeen = new Set();
+ const attachmentsProcessed =
+ this.options.attachments && !(this.options.attachments instanceof Promise);
+ if (attachmentsProcessed) {
+ for (const attachment of this.options.attachments) {
+ if (attachment?.file_id) {
+ contextSeen.add(attachment.file_id);
+ }
+ }
+ }
+
+ const historicalFileIds = collectHistoricalFileIds(_messages);
+ const fileFilter = buildOwnerFileFilter(historicalFileIds, this.options.req?.user);
+ const authorizedFilesById = new Map();
+ if (fileFilter) {
+ const files = (await db.getFiles(fileFilter, {}, {})) ?? [];
+ for (const file of files) {
+ if (file?.file_id) {
+ authorizedFilesById.set(file.file_id, file);
+ }
+ }
+ }
+
+ /**
+ *
+ * @param {TMessage} message
+ */
+ const processMessage = async (message) => {
+ if (!this.message_file_map) {
+ /** @type {Record */
+ this.message_file_map = {};
+ }
+
+ delete message.fileContext;
+
+ const contextFiles = [];
+ if (Array.isArray(message.files)) {
+ for (const file of message.files) {
+ if (!file?.file_id || contextSeen.has(file.file_id)) {
+ continue;
+ }
+ const authorizedFile = authorizedFilesById.get(file.file_id);
+ if (authorizedFile) {
+ contextFiles.push(authorizedFile);
+ contextSeen.add(file.file_id);
+ }
+ }
+ }
+
+ const rehydratedFiles = rehydrateMessageFileRefs(message.files, authorizedFilesById);
+ if (rehydratedFiles) {
+ message.files = rehydratedFiles;
+ } else {
+ delete message.files;
+ }
+
+ const rehydratedAttachments = rehydrateMessageFileRefs(
+ message.attachments,
+ authorizedFilesById,
+ {
+ preserveDisplayOnly: true,
+ },
+ );
+ if (rehydratedAttachments) {
+ message.attachments = rehydratedAttachments;
+ } else {
+ delete message.attachments;
+ }
+
+ if (contextFiles.length === 0) {
+ return message;
+ }
+
+ await this.addFileContextToMessage(message, contextFiles);
+ await this.processAttachments(message, contextFiles);
+
+ this.message_file_map[message.messageId] = contextFiles;
+ return message;
+ };
+
+ const promises = [];
+
+ for (const message of _messages) {
+ if (!message.files && !message.attachments) {
+ promises.push(message);
+ continue;
+ }
+
+ promises.push(processMessage(message));
+ }
+
+ const messages = await Promise.all(promises);
+
+ this.checkVisionRequest(Object.values(this.message_file_map ?? {}).flat());
+ return messages;
+ }
+}
+
+module.exports = BaseClient;
diff --git a/api/app/clients/OllamaClient.js b/api/app/clients/OllamaClient.js
new file mode 100644
index 0000000..d0dda51
--- /dev/null
+++ b/api/app/clients/OllamaClient.js
@@ -0,0 +1,167 @@
+const { z } = require('zod');
+const axios = require('axios');
+const { Ollama } = require('ollama');
+const { sleep } = require('@librechat/agents');
+const { logger } = require('@librechat/data-schemas');
+const { Constants } = require('librechat-data-provider');
+const { resolveHeaders, deriveBaseURL } = require('@librechat/api');
+
+const ollamaPayloadSchema = z.object({
+ mirostat: z.number().optional(),
+ mirostat_eta: z.number().optional(),
+ mirostat_tau: z.number().optional(),
+ num_ctx: z.number().optional(),
+ repeat_last_n: z.number().optional(),
+ repeat_penalty: z.number().optional(),
+ temperature: z.number().optional(),
+ seed: z.number().nullable().optional(),
+ stop: z.array(z.string()).optional(),
+ tfs_z: z.number().optional(),
+ num_predict: z.number().optional(),
+ top_k: z.number().optional(),
+ top_p: z.number().optional(),
+ stream: z.optional(z.boolean()),
+ model: z.string(),
+});
+
+/**
+ * @param {string} imageUrl
+ * @returns {string}
+ * @throws {Error}
+ */
+const getValidBase64 = (imageUrl) => {
+ const parts = imageUrl.split(';base64,');
+
+ if (parts.length === 2) {
+ return parts[1];
+ } else {
+ logger.error('Invalid or no Base64 string found in URL.');
+ }
+};
+
+class OllamaClient {
+ constructor(options = {}) {
+ const host = deriveBaseURL(options.baseURL ?? 'http://localhost:11434');
+ this.streamRate = options.streamRate ?? Constants.DEFAULT_STREAM_RATE;
+ this.headers = options.headers ?? {};
+ /** @type {Ollama} */
+ this.client = new Ollama({ host });
+ }
+
+ /**
+ * Fetches Ollama models from the specified base API path.
+ * @param {string} baseURL
+ * @param {Object} [options] - Optional configuration
+ * @param {Partial} [options.user] - User object for header resolution
+ * @param {Record} [options.headers] - Headers to include in the request
+ * @returns {Promise} The Ollama models.
+ * @throws {Error} Throws if the Ollama API request fails
+ */
+ static async fetchModels(baseURL, options = {}) {
+ if (!baseURL) {
+ return [];
+ }
+
+ const ollamaEndpoint = deriveBaseURL(baseURL);
+
+ const resolvedHeaders = resolveHeaders({
+ headers: options.headers,
+ user: options.user,
+ });
+
+ /** @type {Promise>} */
+ const response = await axios.get(`${ollamaEndpoint}/api/tags`, {
+ headers: resolvedHeaders,
+ timeout: 5000,
+ });
+
+ const models = response.data.models.map((tag) => tag.name);
+ return models;
+ }
+
+ /**
+ * @param {ChatCompletionMessage[]} messages
+ * @returns {OllamaMessage[]}
+ */
+ static formatOpenAIMessages(messages) {
+ const ollamaMessages = [];
+
+ for (const message of messages) {
+ if (typeof message.content === 'string') {
+ ollamaMessages.push({
+ role: message.role,
+ content: message.content,
+ });
+ continue;
+ }
+
+ let aggregatedText = '';
+ let imageUrls = [];
+
+ for (const content of message.content) {
+ if (content.type === 'text') {
+ aggregatedText += content.text + ' ';
+ } else if (content.type === 'image_url') {
+ imageUrls.push(getValidBase64(content.image_url.url));
+ }
+ }
+
+ const ollamaMessage = {
+ role: message.role,
+ content: aggregatedText.trim(),
+ };
+
+ if (imageUrls.length > 0) {
+ ollamaMessage.images = imageUrls;
+ }
+
+ ollamaMessages.push(ollamaMessage);
+ }
+
+ return ollamaMessages;
+ }
+
+ /***
+ * @param {Object} params
+ * @param {ChatCompletionPayload} params.payload
+ * @param {onTokenProgress} params.onProgress
+ * @param {AbortController} params.abortController
+ */
+ async chatCompletion({ payload, onProgress, abortController = null }) {
+ let intermediateReply = '';
+
+ const parameters = ollamaPayloadSchema.parse(payload);
+ const messages = OllamaClient.formatOpenAIMessages(payload.messages);
+
+ if (parameters.stream) {
+ const stream = await this.client.chat({
+ messages,
+ ...parameters,
+ });
+
+ for await (const chunk of stream) {
+ const token = chunk.message.content;
+ intermediateReply += token;
+ onProgress(token);
+ if (abortController.signal.aborted) {
+ stream.controller.abort();
+ break;
+ }
+
+ await sleep(this.streamRate);
+ }
+ }
+ // TODO: regular completion
+ else {
+ // const generation = await this.client.generate(payload);
+ }
+
+ return intermediateReply;
+ }
+ catch(err) {
+ logger.error('[OllamaClient.chatCompletion]', err);
+ throw err;
+ }
+}
+
+module.exports = { OllamaClient, ollamaPayloadSchema };
diff --git a/api/app/clients/TextStream.js b/api/app/clients/TextStream.js
new file mode 100644
index 0000000..ea3d898
--- /dev/null
+++ b/api/app/clients/TextStream.js
@@ -0,0 +1,60 @@
+const { Readable } = require('stream');
+const { logger } = require('@librechat/data-schemas');
+
+class TextStream extends Readable {
+ constructor(text, options = {}) {
+ super(options);
+ this.text = text;
+ this.currentIndex = 0;
+ this.minChunkSize = options.minChunkSize ?? 2;
+ this.maxChunkSize = options.maxChunkSize ?? 4;
+ this.delay = options.delay ?? 20; // Time in milliseconds
+ }
+
+ _read() {
+ const { delay, minChunkSize, maxChunkSize } = this;
+
+ if (this.currentIndex < this.text.length) {
+ setTimeout(() => {
+ const remainingChars = this.text.length - this.currentIndex;
+ const chunkSize = Math.min(this.randomInt(minChunkSize, maxChunkSize + 1), remainingChars);
+
+ const chunk = this.text.slice(this.currentIndex, this.currentIndex + chunkSize);
+ this.push(chunk);
+ this.currentIndex += chunkSize;
+ }, delay);
+ } else {
+ this.push(null); // signal end of data
+ }
+ }
+
+ randomInt(min, max) {
+ return Math.floor(Math.random() * (max - min)) + min;
+ }
+
+ async processTextStream(onProgressCallback) {
+ const streamPromise = new Promise((resolve, reject) => {
+ this.on('data', (chunk) => {
+ onProgressCallback(chunk.toString());
+ });
+
+ this.on('end', () => {
+ // logger.debug('[processTextStream] Stream ended');
+ resolve();
+ });
+
+ this.on('error', (err) => {
+ reject(err);
+ });
+ });
+
+ try {
+ await streamPromise;
+ } catch (err) {
+ logger.error('[processTextStream] Error in text stream:', err);
+ // Handle the error appropriately, e.g., return an error message or throw an error
+ }
+ }
+}
+
+module.exports = TextStream;
diff --git a/api/app/clients/index.js b/api/app/clients/index.js
new file mode 100644
index 0000000..3dbe397
--- /dev/null
+++ b/api/app/clients/index.js
@@ -0,0 +1,7 @@
+const TextStream = require('./TextStream');
+const toolUtils = require('./tools/util');
+
+module.exports = {
+ TextStream,
+ ...toolUtils,
+};
diff --git a/api/app/clients/prompts/artifacts.js b/api/app/clients/prompts/artifacts.js
new file mode 100644
index 0000000..f5e0335
--- /dev/null
+++ b/api/app/clients/prompts/artifacts.js
@@ -0,0 +1,537 @@
+const dedent = require('dedent');
+const { EModelEndpoint, ArtifactModes } = require('librechat-data-provider');
+const { generateShadcnPrompt } = require('~/app/clients/prompts/shadcn-docs/generate');
+const { components } = require('~/app/clients/prompts/shadcn-docs/components');
+
+/** @deprecated */
+// eslint-disable-next-line no-unused-vars
+const artifactsPromptV1 = dedent`The assistant can create and reference artifacts during conversations.
+
+Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
+
+# Good artifacts are...
+- Substantial content (>15 lines)
+- Content that the user is likely to modify, iterate on, or take ownership of
+- Self-contained, complex content that can be understood on its own, without context from the conversation
+- Content intended for eventual use outside the conversation (e.g., reports, emails, presentations)
+- Content likely to be referenced or reused multiple times
+
+# Don't use artifacts for...
+- Simple, informational, or short content, such as brief code snippets, mathematical equations, or small examples
+- Primarily explanatory, instructional, or illustrative content, such as examples provided to clarify a concept
+- Suggestions, commentary, or feedback on existing artifacts
+- Conversational or explanatory content that doesn't represent a standalone piece of work
+- Content that is dependent on the current conversational context to be useful
+- Content that is unlikely to be modified or iterated upon by the user
+- Request from users that appears to be a one-off question
+
+# Usage notes
+- One artifact per message unless specifically requested
+- Prefer in-line content (don't use artifacts) when possible. Unnecessary use of artifacts can be jarring for users.
+- If a user asks the assistant to "draw an SVG" or "make a website," the assistant does not need to explain that it doesn't have these capabilities. Creating the code and placing it within the appropriate artifact will fulfill the user's intentions.
+- If asked to generate an image, the assistant can offer an SVG instead. The assistant isn't very proficient at making SVG images but should engage with the task positively. Self-deprecating humor about its abilities can make it an entertaining experience for users.
+- The assistant errs on the side of simplicity and avoids overusing artifacts for content that can be effectively presented within the conversation.
+- Always provide complete, specific, and fully functional content without any placeholders, ellipses, or 'remains the same' comments.
+
+
+ When collaborating with the user on creating content that falls into compatible categories, the assistant should follow these steps:
+
+ 1. Create the artifact using the following format:
+
+ :::artifact{identifier="unique-identifier" type="mime-type" title="Artifact Title"}
+ \`\`\`\`
+ Your artifact content here
+ \`\`\`\`
+ :::
+
+ 2. Assign an identifier to the \`identifier\` attribute. For updates, reuse the prior identifier. For new artifacts, the identifier should be descriptive and relevant to the content, using kebab-case (e.g., "example-code-snippet"). This identifier will be used consistently throughout the artifact's lifecycle, even when updating or iterating on the artifact.
+ 3. Include a \`title\` attribute to provide a brief title or description of the content.
+ 4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
+ - HTML: "text/html"
+ - The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
+ - Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \` \`
+ - The only place external scripts can be imported from is https://cdnjs.cloudflare.com
+ - Mermaid Diagrams: "application/vnd.mermaid"
+ - The user interface will render Mermaid diagrams placed within the artifact tags.
+ - React Components: "application/vnd.react"
+ - Use this for displaying either: React elements, e.g. \`Hello World! \`, React pure functional components, e.g. \`() => Hello World! \`, React functional components with Hooks, or React component classes
+ - When creating a React component, ensure it has no required props (or provide default values for all props) and use a default export.
+ - Use Tailwind classes for styling. DO NOT USE ARBITRARY VALUES (e.g. \`h-[600px]\`).
+ - Base React is available to be imported. To use hooks, first import it at the top of the artifact, e.g. \`import { useState } from "react"\`
+ - The lucide-react@0.263.1 library is available to be imported. e.g. \`import { Camera } from "lucide-react"\` & \` \`
+ - The recharts charting library is available to be imported, e.g. \`import { LineChart, XAxis, ... } from "recharts"\` & \` ...\`
+ - The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
+ - Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
+ - NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
+ - Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \` \`
+ - If you are unable to follow the above requirements for any reason, don't use artifacts and use regular code blocks instead, which will not attempt to render the component.
+ 5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
+ 6. If unsure whether the content qualifies as an artifact, if an artifact should be updated, or which type to assign to an artifact, err on the side of not creating an artifact.
+ 7. Use a backtick fence longer than any backtick fence in the artifact content. Use a 4-backtick fence by default; if the artifact content contains a 4-backtick fence, use 5 backticks, and so on.
+
+
+Here are some examples of correct usage of artifacts:
+
+
+
+ This example demonstrates how to create a Mermaid artifact for a simple flow chart.
+
+
+
+ Can you create a simple flow chart showing the process of making tea using Mermaid?
+
+
+ Sure! Here's a simple flow chart depicting the process of making tea using Mermaid syntax:
+
+ :::artifact{identifier="tea-making-flowchart" type="application/vnd.mermaid" title="Flow chart: Making Tea"}
+ \`\`\`\`mermaid
+ graph TD
+ A[Start] --> B{Water boiled?}
+ B -->|Yes| C[Add tea leaves to cup]
+ B -->|No| D[Boil water]
+ D --> B
+ C --> E[Pour boiling water into cup]
+ E --> F[Steep tea for desired time]
+ F --> G[Remove tea leaves]
+ G --> H[Add milk or sugar, if desired]
+ H --> I[Enjoy your tea!]
+ I --> J[End]
+ \`\`\`\`
+ :::
+
+ This flow chart uses Mermaid syntax to visualize the steps involved in making a cup of tea. Here's a brief explanation of the process:
+
+ 1. Start
+ 2. Check if water is boiled
+ 3. If not boiled, boil the water
+ 4. Once water is boiled, add tea leaves to the cup
+ 5. Pour boiling water into the cup
+ 6. Steep the tea for the desired time
+ 7. Remove the tea leaves
+ 8. Optionally add milk or sugar
+ 9. Enjoy your tea!
+ 10. End
+
+ This chart provides a clear visual representation of the tea-making process. You can easily modify or expand this chart if you want to add more details or steps to the process. Let me know if you'd like any changes or have any questions!
+
+
+ `;
+
+const artifactsPrompt = dedent`The assistant can create and reference artifacts during conversations.
+
+Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
+
+# Good artifacts are...
+- Substantial content (>15 lines)
+- Content that the user is likely to modify, iterate on, or take ownership of
+- Self-contained, complex content that can be understood on its own, without context from the conversation
+- Content intended for eventual use outside the conversation (e.g., reports, emails, presentations)
+- Content likely to be referenced or reused multiple times
+
+# Don't use artifacts for...
+- Simple, informational, or short content, such as brief code snippets, mathematical equations, or small examples
+- Primarily explanatory, instructional, or illustrative content, such as examples provided to clarify a concept
+- Suggestions, commentary, or feedback on existing artifacts
+- Conversational or explanatory content that doesn't represent a standalone piece of work
+- Content that is dependent on the current conversational context to be useful
+- Content that is unlikely to be modified or iterated upon by the user
+- Request from users that appears to be a one-off question
+
+# Usage notes
+- One artifact per message unless specifically requested
+- Prefer in-line content (don't use artifacts) when possible. Unnecessary use of artifacts can be jarring for users.
+- If a user asks the assistant to "draw an SVG" or "make a website," the assistant does not need to explain that it doesn't have these capabilities. Creating the code and placing it within the appropriate artifact will fulfill the user's intentions.
+- If asked to generate an image, the assistant can offer an SVG instead. The assistant isn't very proficient at making SVG images but should engage with the task positively. Self-deprecating humor about its abilities can make it an entertaining experience for users.
+- The assistant errs on the side of simplicity and avoids overusing artifacts for content that can be effectively presented within the conversation.
+- Always provide complete, specific, and fully functional content for artifacts without any snippets, placeholders, ellipses, or 'remains the same' comments.
+- If an artifact is not necessary or requested, the assistant should not mention artifacts at all, and respond to the user accordingly.
+
+
+ When collaborating with the user on creating content that falls into compatible categories, the assistant should follow these steps:
+
+ 1. Create the artifact using the following format:
+
+ :::artifact{identifier="unique-identifier" type="mime-type" title="Artifact Title"}
+ \`\`\`\`
+ Your artifact content here
+ \`\`\`\`
+ :::
+
+ 2. Assign an identifier to the \`identifier\` attribute. For updates, reuse the prior identifier. For new artifacts, the identifier should be descriptive and relevant to the content, using kebab-case (e.g., "example-code-snippet"). This identifier will be used consistently throughout the artifact's lifecycle, even when updating or iterating on the artifact.
+ 3. Include a \`title\` attribute to provide a brief title or description of the content.
+ 4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
+ - HTML: "text/html"
+ - The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
+ - Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \` \`
+ - The only place external scripts can be imported from is https://cdnjs.cloudflare.com
+ - SVG: "image/svg+xml"
+ - The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
+ - The assistant should specify the viewbox of the SVG rather than defining a width/height
+ - Markdown: "text/markdown" or "text/md"
+ - The user interface will render Markdown content placed within the artifact tags.
+ - Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more.
+ - Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content.
+ - Mermaid Diagrams: "application/vnd.mermaid"
+ - The user interface will render Mermaid diagrams placed within the artifact tags.
+ - React Components: "application/vnd.react"
+ - Use this for displaying either: React elements, e.g. \`Hello World! \`, React pure functional components, e.g. \`() => Hello World! \`, React functional components with Hooks, or React component classes
+ - When creating a React component, ensure it has no required props (or provide default values for all props) and use a default export.
+ - Use Tailwind classes for styling. DO NOT USE ARBITRARY VALUES (e.g. \`h-[600px]\`).
+ - Base React is available to be imported. To use hooks, first import it at the top of the artifact, e.g. \`import { useState } from "react"\`
+ - The lucide-react@0.394.0 library is available to be imported. e.g. \`import { Camera } from "lucide-react"\` & \` \`
+ - The recharts charting library is available to be imported, e.g. \`import { LineChart, XAxis, ... } from "recharts"\` & \` ...\`
+ - The three.js library is available to be imported, e.g. \`import * as THREE from "three";\`
+ - The date-fns library is available to be imported, e.g. \`import { compareAsc, format } from "date-fns";\`
+ - The react-day-picker library is available to be imported, e.g. \`import { DayPicker } from "react-day-picker";\`
+ - The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
+ - Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
+ - NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
+ - Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \` \`
+ - When iterating on code, ensure that the code is complete and functional without any snippets, placeholders, or ellipses.
+ - If you are unable to follow the above requirements for any reason, don't use artifacts and use regular code blocks instead, which will not attempt to render the component.
+ 5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
+ 6. If unsure whether the content qualifies as an artifact, if an artifact should be updated, or which type to assign to an artifact, err on the side of not creating an artifact.
+ 7. Use a backtick fence longer than any backtick fence in the artifact content. Use a 4-backtick fence by default; if the artifact content contains a 4-backtick fence, use 5 backticks, and so on.
+
+
+Here are some examples of correct usage of artifacts:
+
+
+
+ This example demonstrates how to create a Mermaid artifact for a simple flow chart.
+
+
+
+ Can you create a simple flow chart showing the process of making tea using Mermaid?
+
+
+ Sure! Here's a simple flow chart depicting the process of making tea using Mermaid syntax:
+
+ :::artifact{identifier="tea-making-flowchart" type="application/vnd.mermaid" title="Flow chart: Making Tea"}
+ \`\`\`\`mermaid
+ graph TD
+ A[Start] --> B{Water boiled?}
+ B -->|Yes| C[Add tea leaves to cup]
+ B -->|No| D[Boil water]
+ D --> B
+ C --> E[Pour boiling water into cup]
+ E --> F[Steep tea for desired time]
+ F --> G[Remove tea leaves]
+ G --> H[Add milk or sugar, if desired]
+ H --> I[Enjoy your tea!]
+ I --> J[End]
+ \`\`\`\`
+ :::
+
+ This flow chart uses Mermaid syntax to visualize the steps involved in making a cup of tea. Here's a brief explanation of the process:
+
+ 1. Start
+ 2. Check if water is boiled
+ 3. If not boiled, boil the water
+ 4. Once water is boiled, add tea leaves to the cup
+ 5. Pour boiling water into the cup
+ 6. Steep the tea for the desired time
+ 7. Remove the tea leaves
+ 8. Optionally add milk or sugar
+ 9. Enjoy your tea!
+ 10. End
+
+ This chart provides a clear visual representation of the tea-making process. You can easily modify or expand this chart if you want to add more details or steps to the process. Let me know if you'd like any changes or have any questions!
+
+
+
+
+ Create a simple React counter component
+
+ Here's a simple React counter component:
+
+ :::artifact{identifier="react-counter" type="application/vnd.react" title="React Counter"}
+ \`\`\`\`
+ import { useState } from 'react';
+
+ export default function Counter() {
+ const [count, setCount] = useState(0);
+ return (
+
+
Count: {count}
+
setCount(count + 1)}>
+ Increment
+
+
+ );
+ }
+ \`\`\`\`
+ :::
+
+ This component creates a simple counter with an increment button.
+
+
+
+
+ Create a basic HTML structure for a blog post
+
+ Here's a basic HTML structure for a blog post:
+
+ :::artifact{identifier="blog-post-html" type="text/html" title="Blog Post HTML"}
+ \`\`\`\`
+
+
+
+
+
+ My Blog Post
+
+
+
+
+
+
+ This is the content of my blog post. It's short and sweet!
+
+
+
+
+
+ \`\`\`\`
+ :::
+
+ This HTML structure provides a simple layout for a blog post.
+
+
+ `;
+
+const artifactsOpenAIPrompt = dedent`The assistant can create and reference artifacts during conversations.
+
+Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
+
+# Good artifacts are...
+- Substantial content (>15 lines)
+- Content that the user is likely to modify, iterate on, or take ownership of
+- Self-contained, complex content that can be understood on its own, without context from the conversation
+- Content intended for eventual use outside the conversation (e.g., reports, emails, presentations)
+- Content likely to be referenced or reused multiple times
+
+# Don't use artifacts for...
+- Simple, informational, or short content, such as brief code snippets, mathematical equations, or small examples
+- Primarily explanatory, instructional, or illustrative content, such as examples provided to clarify a concept
+- Suggestions, commentary, or feedback on existing artifacts
+- Conversational or explanatory content that doesn't represent a standalone piece of work
+- Content that is dependent on the current conversational context to be useful
+- Content that is unlikely to be modified or iterated upon by the user
+- Request from users that appears to be a one-off question
+
+# Usage notes
+- One artifact per message unless specifically requested
+- Prefer in-line content (don't use artifacts) when possible. Unnecessary use of artifacts can be jarring for users.
+- If a user asks the assistant to "draw an SVG" or "make a website," the assistant does not need to explain that it doesn't have these capabilities. Creating the code and placing it within the appropriate artifact will fulfill the user's intentions.
+- If asked to generate an image, the assistant can offer an SVG instead. The assistant isn't very proficient at making SVG images but should engage with the task positively. Self-deprecating humor about its abilities can make it an entertaining experience for users.
+- The assistant errs on the side of simplicity and avoids overusing artifacts for content that can be effectively presented within the conversation.
+- Always provide complete, specific, and fully functional content for artifacts without any snippets, placeholders, ellipses, or 'remains the same' comments.
+- If an artifact is not necessary or requested, the assistant should not mention artifacts at all, and respond to the user accordingly.
+
+## Artifact Instructions
+ When collaborating with the user on creating content that falls into compatible categories, the assistant should follow these steps:
+
+ 1. Create the artifact using the following remark-directive markdown format:
+
+ :::artifact{identifier="unique-identifier" type="mime-type" title="Artifact Title"}
+ \`\`\`\`
+ Your artifact content here
+ \`\`\`\`
+ :::
+
+ a. Example of correct format:
+
+ :::artifact{identifier="example-artifact" type="text/plain" title="Example Artifact"}
+ \`\`\`\`
+ This is the content of the artifact.
+ It can span multiple lines.
+ \`\`\`\`
+ :::
+
+ b. Common mistakes to avoid:
+ - Don't split the opening ::: line
+ - Don't add extra backticks outside the artifact structure
+ - Don't omit the closing :::
+
+ 2. Assign an identifier to the \`identifier\` attribute. For updates, reuse the prior identifier. For new artifacts, the identifier should be descriptive and relevant to the content, using kebab-case (e.g., "example-code-snippet"). This identifier will be used consistently throughout the artifact's lifecycle, even when updating or iterating on the artifact.
+ 3. Include a \`title\` attribute to provide a brief title or description of the content.
+ 4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
+ - HTML: "text/html"
+ - The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
+ - Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \` \`
+ - The only place external scripts can be imported from is https://cdnjs.cloudflare.com
+ - SVG: "image/svg+xml"
+ - The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
+ - The assistant should specify the viewbox of the SVG rather than defining a width/height
+ - Markdown: "text/markdown" or "text/md"
+ - The user interface will render Markdown content placed within the artifact tags.
+ - Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more.
+ - Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content.
+ - Mermaid Diagrams: "application/vnd.mermaid"
+ - The user interface will render Mermaid diagrams placed within the artifact tags.
+ - React Components: "application/vnd.react"
+ - Use this for displaying either: React elements, e.g. \`Hello World! \`, React pure functional components, e.g. \`() => Hello World! \`, React functional components with Hooks, or React component classes
+ - When creating a React component, ensure it has no required props (or provide default values for all props) and use a default export.
+ - Use Tailwind classes for styling. DO NOT USE ARBITRARY VALUES (e.g. \`h-[600px]\`).
+ - Base React is available to be imported. To use hooks, first import it at the top of the artifact, e.g. \`import { useState } from "react"\`
+ - The lucide-react@0.394.0 library is available to be imported. e.g. \`import { Camera } from "lucide-react"\` & \` \`
+ - The recharts charting library is available to be imported, e.g. \`import { LineChart, XAxis, ... } from "recharts"\` & \` ...\`
+ - The three.js library is available to be imported, e.g. \`import * as THREE from "three";\`
+ - The date-fns library is available to be imported, e.g. \`import { compareAsc, format } from "date-fns";\`
+ - The react-day-picker library is available to be imported, e.g. \`import { DayPicker } from "react-day-picker";\`
+ - The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
+ - Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
+ - NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
+ - Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \` \`
+ - When iterating on code, ensure that the code is complete and functional without any snippets, placeholders, or ellipses.
+ - If you are unable to follow the above requirements for any reason, don't use artifacts and use regular code blocks instead, which will not attempt to render the component.
+ 5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
+ 6. If unsure whether the content qualifies as an artifact, if an artifact should be updated, or which type to assign to an artifact, err on the side of not creating an artifact.
+ 7. Use a backtick fence longer than any backtick fence in the artifact content. Use a 4-backtick fence by default; if the artifact content contains a 4-backtick fence, use 5 backticks, and so on.
+
+Here are some examples of correct usage of artifacts:
+
+## Examples
+
+### Example 1
+
+ This example demonstrates how to create a Mermaid artifact for a simple flow chart.
+
+ User: Can you create a simple flow chart showing the process of making tea using Mermaid?
+
+ Assistant: Sure! Here's a simple flow chart depicting the process of making tea using Mermaid syntax:
+
+ :::artifact{identifier="tea-making-flowchart" type="application/vnd.mermaid" title="Flow chart: Making Tea"}
+ \`\`\`\`mermaid
+ graph TD
+ A[Start] --> B{Water boiled?}
+ B -->|Yes| C[Add tea leaves to cup]
+ B -->|No| D[Boil water]
+ D --> B
+ C --> E[Pour boiling water into cup]
+ E --> F[Steep tea for desired time]
+ F --> G[Remove tea leaves]
+ G --> H[Add milk or sugar, if desired]
+ H --> I[Enjoy your tea!]
+ I --> J[End]
+ \`\`\`\`
+ :::
+
+ This flow chart uses Mermaid syntax to visualize the steps involved in making a cup of tea. Here's a brief explanation of the process:
+
+ 1. Start
+ 2. Check if water is boiled
+ 3. If not boiled, boil the water
+ 4. Once water is boiled, add tea leaves to the cup
+ 5. Pour boiling water into the cup
+ 6. Steep the tea for the desired time
+ 7. Remove the tea leaves
+ 8. Optionally add milk or sugar
+ 9. Enjoy your tea!
+ 10. End
+
+ This chart provides a clear visual representation of the tea-making process. You can easily modify or expand this chart if you want to add more details or steps to the process. Let me know if you'd like any changes or have any questions!
+
+---
+
+### Example 2
+
+ User: Create a simple React counter component
+
+ Assistant: Here's a simple React counter component:
+
+ :::artifact{identifier="react-counter" type="application/vnd.react" title="React Counter"}
+ \`\`\`\`
+ import { useState } from 'react';
+
+ export default function Counter() {
+ const [count, setCount] = useState(0);
+ return (
+
+
Count: {count}
+
setCount(count + 1)}>
+ Increment
+
+
+ );
+ }
+ \`\`\`\`
+ :::
+
+ This component creates a simple counter with an increment button.
+
+---
+
+### Example 3
+ User: Create a basic HTML structure for a blog post
+ Assistant: Here's a basic HTML structure for a blog post:
+
+ :::artifact{identifier="blog-post-html" type="text/html" title="Blog Post HTML"}
+ \`\`\`\`
+
+
+
+
+
+ My Blog Post
+
+
+
+
+
+
+ This is the content of my blog post. It's short and sweet!
+
+
+
+
+
+ \`\`\`\`
+ :::
+
+ This HTML structure provides a simple layout for a blog post.
+
+---`;
+
+/**
+ *
+ * @param {Object} params
+ * @param {EModelEndpoint | string} params.endpoint - The current endpoint
+ * @param {ArtifactModes} params.artifacts - The current artifact mode
+ * @returns
+ */
+const generateArtifactsPrompt = ({ endpoint, artifacts }) => {
+ if (artifacts === ArtifactModes.CUSTOM) {
+ return null;
+ }
+
+ let prompt = artifactsPrompt;
+ if (endpoint !== EModelEndpoint.anthropic) {
+ prompt = artifactsOpenAIPrompt;
+ }
+
+ if (artifacts === ArtifactModes.SHADCNUI) {
+ prompt += generateShadcnPrompt({ components, useXML: endpoint === EModelEndpoint.anthropic });
+ }
+
+ return prompt;
+};
+
+module.exports = generateArtifactsPrompt;
diff --git a/api/app/clients/prompts/createContextHandlers.js b/api/app/clients/prompts/createContextHandlers.js
new file mode 100644
index 0000000..37b6ba9
--- /dev/null
+++ b/api/app/clients/prompts/createContextHandlers.js
@@ -0,0 +1,159 @@
+const axios = require('axios');
+const { isEnabled, generateShortLivedToken, logAxiosError } = require('@librechat/api');
+
+const footer = `Use the context as your learned knowledge to better answer the user.
+
+In your response, remember to follow these guidelines:
+- If you don't know the answer, simply say that you don't know.
+- If you are unsure how to answer, ask for clarification.
+- Avoid mentioning that you obtained the information from the context.
+`;
+
+function createContextHandlers(req, userMessageContent) {
+ if (!process.env.RAG_API_URL) {
+ return;
+ }
+
+ const queryPromises = [];
+ const processedFiles = [];
+ const processedIds = new Set();
+ const jwtToken = generateShortLivedToken(req.user.id);
+ const useFullContext = isEnabled(process.env.RAG_USE_FULL_CONTEXT);
+
+ const query = async (file) => {
+ if (useFullContext) {
+ return axios.get(`${process.env.RAG_API_URL}/documents/${file.file_id}/context`, {
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ },
+ });
+ }
+
+ return axios.post(
+ `${process.env.RAG_API_URL}/query`,
+ {
+ file_id: file.file_id,
+ query: userMessageContent,
+ k: 4,
+ },
+ {
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ 'Content-Type': 'application/json',
+ },
+ },
+ );
+ };
+
+ const processFile = async (file) => {
+ if (file.embedded && !processedIds.has(file.file_id)) {
+ try {
+ const promise = query(file);
+ queryPromises.push(promise);
+ processedFiles.push(file);
+ processedIds.add(file.file_id);
+ } catch (error) {
+ logAxiosError({ message: `Error processing file ${file.filename}`, error });
+ }
+ }
+ };
+
+ const createContext = async () => {
+ try {
+ if (!queryPromises.length || !processedFiles.length) {
+ return '';
+ }
+
+ const oneFile = processedFiles.length === 1;
+ const header = `The user has attached ${oneFile ? 'a' : processedFiles.length} file${
+ !oneFile ? 's' : ''
+ } to the conversation:`;
+
+ const files = `${
+ oneFile
+ ? ''
+ : `
+ `
+ }${processedFiles
+ .map(
+ (file) => `
+
+ ${file.filename}
+ ${file.type}
+ `,
+ )
+ .join('')}${
+ oneFile
+ ? ''
+ : `
+ `
+ }`;
+
+ const resolvedQueries = await Promise.all(queryPromises);
+
+ const context =
+ resolvedQueries.length === 0
+ ? '\n\tThe semantic search did not return any results.'
+ : resolvedQueries
+ .map((queryResult, index) => {
+ const file = processedFiles[index];
+ let contextItems = queryResult.data;
+
+ const generateContext = (currentContext) =>
+ `
+
+ ${file.filename}
+ ${currentContext}
+
+ `;
+
+ if (useFullContext) {
+ return generateContext(`\n${contextItems}`);
+ }
+
+ contextItems = queryResult.data
+ .map((item) => {
+ const pageContent = item[0].page_content;
+ return `
+
+
+ `;
+ })
+ .join('');
+
+ return generateContext(contextItems);
+ })
+ .join('');
+
+ if (useFullContext) {
+ const prompt = `${header}
+ ${context}
+ ${footer}`;
+
+ return prompt;
+ }
+
+ const prompt = `${header}
+ ${files}
+
+ A semantic search was executed with the user's message as the query, retrieving the following context inside XML tags.
+
+ ${context}
+
+
+ ${footer}`;
+
+ return prompt;
+ } catch (error) {
+ logAxiosError({ message: 'Error creating context', error });
+ throw error;
+ }
+ };
+
+ return {
+ processFile,
+ createContext,
+ };
+}
+
+module.exports = createContextHandlers;
diff --git a/api/app/clients/prompts/createVisionPrompt.js b/api/app/clients/prompts/createVisionPrompt.js
new file mode 100644
index 0000000..5d8a7bb
--- /dev/null
+++ b/api/app/clients/prompts/createVisionPrompt.js
@@ -0,0 +1,34 @@
+/**
+ * Generates a prompt instructing the user to describe an image in detail, tailored to different types of visual content.
+ * @param {boolean} pluralized - Whether to pluralize the prompt for multiple images.
+ * @returns {string} - The generated vision prompt.
+ */
+const createVisionPrompt = (pluralized = false) => {
+ return `Please describe the image${
+ pluralized ? 's' : ''
+ } in detail, covering relevant aspects such as:
+
+ For photographs, illustrations, or artwork:
+ - The main subject(s) and their appearance, positioning, and actions
+ - The setting, background, and any notable objects or elements
+ - Colors, lighting, and overall mood or atmosphere
+ - Any interesting details, textures, or patterns
+ - The style, technique, or medium used (if discernible)
+
+ For screenshots or images containing text:
+ - The content and purpose of the text
+ - The layout, formatting, and organization of the information
+ - Any notable visual elements, such as logos, icons, or graphics
+ - The overall context or message conveyed by the screenshot
+
+ For graphs, charts, or data visualizations:
+ - The type of graph or chart (e.g., bar graph, line chart, pie chart)
+ - The variables being compared or analyzed
+ - Any trends, patterns, or outliers in the data
+ - The axis labels, scales, and units of measurement
+ - The title, legend, and any additional context provided
+
+ Be as specific and descriptive as possible while maintaining clarity and concision.`;
+};
+
+module.exports = createVisionPrompt;
diff --git a/api/app/clients/prompts/formatAgentMessages.spec.js b/api/app/clients/prompts/formatAgentMessages.spec.js
new file mode 100644
index 0000000..d8e9262
--- /dev/null
+++ b/api/app/clients/prompts/formatAgentMessages.spec.js
@@ -0,0 +1,514 @@
+const { ContentTypes } = require('librechat-data-provider');
+const {
+ AIMessage,
+ ToolMessage,
+ HumanMessage,
+ SystemMessage,
+} = require('@librechat/agents/langchain/messages');
+const { formatAgentMessages } = require('./formatMessages');
+
+describe('formatAgentMessages', () => {
+ it('should format simple user and AI messages', () => {
+ const payload = [
+ { role: 'user', content: 'Hello' },
+ { role: 'assistant', content: 'Hi there!' },
+ ];
+ const result = formatAgentMessages(payload);
+ expect(result).toHaveLength(2);
+ expect(result[0]).toBeInstanceOf(HumanMessage);
+ expect(result[1]).toBeInstanceOf(AIMessage);
+ });
+
+ it('should handle system messages', () => {
+ const payload = [{ role: 'system', content: 'You are a helpful assistant.' }];
+ const result = formatAgentMessages(payload);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBeInstanceOf(SystemMessage);
+ });
+
+ it('should format messages with content arrays', () => {
+ const payload = [
+ {
+ role: 'user',
+ content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Hello' }],
+ },
+ ];
+ const result = formatAgentMessages(payload);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBeInstanceOf(HumanMessage);
+ });
+
+ it('should handle tool calls and create ToolMessages', () => {
+ const payload = [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: ContentTypes.TEXT,
+ [ContentTypes.TEXT]: 'Let me check that for you.',
+ tool_call_ids: ['123'],
+ },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ id: '123',
+ name: 'search',
+ args: '{"query":"weather"}',
+ output: 'The weather is sunny.',
+ },
+ },
+ ],
+ },
+ ];
+ const result = formatAgentMessages(payload);
+ expect(result).toHaveLength(2);
+ expect(result[0]).toBeInstanceOf(AIMessage);
+ expect(result[1]).toBeInstanceOf(ToolMessage);
+ expect(result[0].tool_calls).toHaveLength(1);
+ expect(result[1].tool_call_id).toBe('123');
+ });
+
+ it('should handle multiple content parts in assistant messages', () => {
+ const payload = [
+ {
+ role: 'assistant',
+ content: [
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Part 1' },
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Part 2' },
+ ],
+ },
+ ];
+ const result = formatAgentMessages(payload);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBeInstanceOf(AIMessage);
+ expect(result[0].content).toHaveLength(2);
+ });
+
+ it('should throw an error for invalid tool call structure', () => {
+ const payload = [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ id: '123',
+ name: 'search',
+ args: '{"query":"weather"}',
+ output: 'The weather is sunny.',
+ },
+ },
+ ],
+ },
+ ];
+ expect(() => formatAgentMessages(payload)).toThrow('Invalid tool call structure');
+ });
+
+ it('should handle tool calls with non-JSON args', () => {
+ const payload = [
+ {
+ role: 'assistant',
+ content: [
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Checking...', tool_call_ids: ['123'] },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ id: '123',
+ name: 'search',
+ args: 'non-json-string',
+ output: 'Result',
+ },
+ },
+ ],
+ },
+ ];
+ const result = formatAgentMessages(payload);
+ expect(result).toHaveLength(2);
+ expect(result[0].tool_calls[0].args).toStrictEqual({ input: 'non-json-string' });
+ });
+
+ it('should handle complex tool calls with multiple steps', () => {
+ const payload = [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: ContentTypes.TEXT,
+ [ContentTypes.TEXT]: "I'll search for that information.",
+ tool_call_ids: ['search_1'],
+ },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ id: 'search_1',
+ name: 'search',
+ args: '{"query":"weather in New York"}',
+ output: 'The weather in New York is currently sunny with a temperature of 75°F.',
+ },
+ },
+ {
+ type: ContentTypes.TEXT,
+ [ContentTypes.TEXT]: "Now, I'll convert the temperature.",
+ tool_call_ids: ['convert_1'],
+ },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ id: 'convert_1',
+ name: 'convert_temperature',
+ args: '{"temperature": 75, "from": "F", "to": "C"}',
+ output: '23.89°C',
+ },
+ },
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's your answer." },
+ ],
+ },
+ ];
+
+ const result = formatAgentMessages(payload);
+
+ expect(result).toHaveLength(5);
+ expect(result[0]).toBeInstanceOf(AIMessage);
+ expect(result[1]).toBeInstanceOf(ToolMessage);
+ expect(result[2]).toBeInstanceOf(AIMessage);
+ expect(result[3]).toBeInstanceOf(ToolMessage);
+ expect(result[4]).toBeInstanceOf(AIMessage);
+
+ // Check first AIMessage
+ expect(result[0].content).toBe("I'll search for that information.");
+ expect(result[0].tool_calls).toHaveLength(1);
+ expect(result[0].tool_calls[0]).toEqual({
+ id: 'search_1',
+ name: 'search',
+ args: { query: 'weather in New York' },
+ });
+
+ // Check first ToolMessage
+ expect(result[1].tool_call_id).toBe('search_1');
+ expect(result[1].name).toBe('search');
+ expect(result[1].content).toBe(
+ 'The weather in New York is currently sunny with a temperature of 75°F.',
+ );
+
+ // Check second AIMessage
+ expect(result[2].content).toBe("Now, I'll convert the temperature.");
+ expect(result[2].tool_calls).toHaveLength(1);
+ expect(result[2].tool_calls[0]).toEqual({
+ id: 'convert_1',
+ name: 'convert_temperature',
+ args: { temperature: 75, from: 'F', to: 'C' },
+ });
+
+ // Check second ToolMessage
+ expect(result[3].tool_call_id).toBe('convert_1');
+ expect(result[3].name).toBe('convert_temperature');
+ expect(result[3].content).toBe('23.89°C');
+
+ // Check final AIMessage
+ expect(result[4].content).toStrictEqual([
+ { [ContentTypes.TEXT]: "Here's your answer.", type: ContentTypes.TEXT },
+ ]);
+ });
+
+ it.skip('should not produce two consecutive assistant messages and format content correctly', () => {
+ const payload = [
+ { role: 'user', content: 'Hello' },
+ {
+ role: 'assistant',
+ content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Hi there!' }],
+ },
+ {
+ role: 'assistant',
+ content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'How can I help you?' }],
+ },
+ { role: 'user', content: "What's the weather?" },
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: ContentTypes.TEXT,
+ [ContentTypes.TEXT]: 'Let me check that for you.',
+ tool_call_ids: ['weather_1'],
+ },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ id: 'weather_1',
+ name: 'check_weather',
+ args: '{"location":"New York"}',
+ output: 'Sunny, 75°F',
+ },
+ },
+ ],
+ },
+ {
+ role: 'assistant',
+ content: [
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's the weather information." },
+ ],
+ },
+ ];
+
+ const result = formatAgentMessages(payload);
+
+ // Check correct message count and types
+ expect(result).toHaveLength(6);
+ expect(result[0]).toBeInstanceOf(HumanMessage);
+ expect(result[1]).toBeInstanceOf(AIMessage);
+ expect(result[2]).toBeInstanceOf(HumanMessage);
+ expect(result[3]).toBeInstanceOf(AIMessage);
+ expect(result[4]).toBeInstanceOf(ToolMessage);
+ expect(result[5]).toBeInstanceOf(AIMessage);
+
+ // Check content of messages
+ expect(result[0].content).toStrictEqual([
+ { [ContentTypes.TEXT]: 'Hello', type: ContentTypes.TEXT },
+ ]);
+ expect(result[1].content).toStrictEqual([
+ { [ContentTypes.TEXT]: 'Hi there!', type: ContentTypes.TEXT },
+ { [ContentTypes.TEXT]: 'How can I help you?', type: ContentTypes.TEXT },
+ ]);
+ expect(result[2].content).toStrictEqual([
+ { [ContentTypes.TEXT]: "What's the weather?", type: ContentTypes.TEXT },
+ ]);
+ expect(result[3].content).toBe('Let me check that for you.');
+ expect(result[4].content).toBe('Sunny, 75°F');
+ expect(result[5].content).toStrictEqual([
+ { [ContentTypes.TEXT]: "Here's the weather information.", type: ContentTypes.TEXT },
+ ]);
+
+ // Check that there are no consecutive AIMessages
+ const messageTypes = result.map((message) => message.constructor);
+ for (let i = 0; i < messageTypes.length - 1; i++) {
+ expect(messageTypes[i] === AIMessage && messageTypes[i + 1] === AIMessage).toBe(false);
+ }
+
+ // Additional check to ensure the consecutive assistant messages were combined
+ expect(result[1].content).toHaveLength(2);
+ });
+
+ it('should skip THINK type content parts', () => {
+ const payload = [
+ {
+ role: 'assistant',
+ content: [
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Initial response' },
+ { type: ContentTypes.THINK, [ContentTypes.THINK]: 'Reasoning about the problem...' },
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Final answer' },
+ ],
+ },
+ ];
+
+ const result = formatAgentMessages(payload);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBeInstanceOf(AIMessage);
+ expect(result[0].content).toEqual('Initial response\nFinal answer');
+ });
+
+ it('should join TEXT content as string when THINK content type is present', () => {
+ const payload = [
+ {
+ role: 'assistant',
+ content: [
+ { type: ContentTypes.THINK, [ContentTypes.THINK]: 'Analyzing the problem...' },
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'First part of response' },
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Second part of response' },
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Final part of response' },
+ ],
+ },
+ ];
+
+ const result = formatAgentMessages(payload);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBeInstanceOf(AIMessage);
+ expect(typeof result[0].content).toBe('string');
+ expect(result[0].content).toBe(
+ 'First part of response\nSecond part of response\nFinal part of response',
+ );
+ expect(result[0].content).not.toContain('Analyzing the problem...');
+ });
+
+ it('should exclude ERROR type content parts', () => {
+ const payload = [
+ {
+ role: 'assistant',
+ content: [
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Hello there' },
+ {
+ type: ContentTypes.ERROR,
+ [ContentTypes.ERROR]:
+ 'An error occurred while processing the request: Something went wrong',
+ },
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Final answer' },
+ ],
+ },
+ ];
+
+ const result = formatAgentMessages(payload);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBeInstanceOf(AIMessage);
+ expect(result[0].content).toEqual([
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Hello there' },
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Final answer' },
+ ]);
+
+ // Make sure no error content exists in the result
+ const hasErrorContent = result[0].content.some(
+ (item) =>
+ item.type === ContentTypes.ERROR || JSON.stringify(item).includes('An error occurred'),
+ );
+ expect(hasErrorContent).toBe(false);
+ });
+
+ describe('Vertex Gemini thoughtSignatures persistence (issue #13006 follow-up)', () => {
+ const SIG_A = 'AY89a1/sigA==';
+ const SIG_B = 'AY89a1/sigB==';
+
+ it('restores additional_kwargs.signatures onto the AIMessage that owns the tool_call', () => {
+ const payload = [
+ { role: 'user', content: 'list files' },
+ {
+ role: 'assistant',
+ metadata: { thoughtSignatures: { t1: SIG_A } },
+ content: [
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: '', tool_call_ids: ['t1'] },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: { id: 't1', name: 'bash', args: '{}', output: 'ok' },
+ },
+ ],
+ },
+ ];
+
+ const result = formatAgentMessages(payload);
+
+ const assistant = result.find((m) => m instanceof AIMessage);
+ expect(assistant.tool_calls).toHaveLength(1);
+ expect(assistant.additional_kwargs?.signatures).toEqual([SIG_A]);
+ });
+
+ it('attaches signatures per-step in multi-step tool turns (codex review fix)', () => {
+ // Reproduces the Codex P1 concern: an assistant turn where the agent
+ // loop made two LLM cycles, each emitting its own tool_call. Each step
+ // must carry its OWN signature on resume — Vertex validates per-step,
+ // not per-turn.
+ const payload = [
+ { role: 'user', content: 'do two things' },
+ {
+ role: 'assistant',
+ metadata: { thoughtSignatures: { t1: SIG_A, t2: SIG_B } },
+ content: [
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'first', tool_call_ids: ['t1'] },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: { id: 't1', name: 'a', args: '{}', output: 'okA' },
+ },
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'second', tool_call_ids: ['t2'] },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: { id: 't2', name: 'b', args: '{}', output: 'okB' },
+ },
+ ],
+ },
+ ];
+
+ const result = formatAgentMessages(payload);
+ const aiMessages = result.filter((m) => m instanceof AIMessage);
+ expect(aiMessages).toHaveLength(2);
+ expect(aiMessages[0].tool_calls).toHaveLength(1);
+ expect(aiMessages[0].additional_kwargs?.signatures).toEqual([SIG_A]);
+ expect(aiMessages[1].tool_calls).toHaveLength(1);
+ expect(aiMessages[1].additional_kwargs?.signatures).toEqual([SIG_B]);
+ });
+
+ it('preserves tool_call ordering when signatures are partial', () => {
+ // Mixed case: only some tool_calls have stored signatures. Position-
+ // aligned array (with empty placeholders) lets the agents-side
+ // dispatcher attach the correct signature to the correct functionCall.
+ const payload = [
+ { role: 'user', content: 'two parallel tools' },
+ {
+ role: 'assistant',
+ metadata: { thoughtSignatures: { t2: SIG_B } },
+ content: [
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: '', tool_call_ids: ['t1', 't2'] },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: { id: 't1', name: 'a', args: '{}', output: 'okA' },
+ },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: { id: 't2', name: 'b', args: '{}', output: 'okB' },
+ },
+ ],
+ },
+ ];
+
+ const result = formatAgentMessages(payload);
+ const assistant = result.find((m) => m instanceof AIMessage);
+ expect(assistant.additional_kwargs?.signatures).toEqual(['', SIG_B]);
+ });
+
+ it('no-op when metadata.thoughtSignatures is absent', () => {
+ const payload = [
+ { role: 'user', content: 'hi' },
+ {
+ role: 'assistant',
+ content: [
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: '', tool_call_ids: ['t1'] },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: { id: 't1', name: 'bash', args: '{}', output: 'ok' },
+ },
+ ],
+ },
+ ];
+
+ const result = formatAgentMessages(payload);
+ const assistant = result.find((m) => m instanceof AIMessage);
+ expect(assistant.additional_kwargs?.signatures).toBeUndefined();
+ });
+
+ it('no-op when assistant message has no tool_calls', () => {
+ const payload = [
+ { role: 'user', content: 'hi' },
+ {
+ role: 'assistant',
+ metadata: { thoughtSignatures: { t1: SIG_A } },
+ content: 'plain text reply',
+ },
+ ];
+
+ const result = formatAgentMessages(payload);
+ const assistant = result.find((m) => m instanceof AIMessage);
+ expect(assistant.additional_kwargs?.signatures).toBeUndefined();
+ });
+
+ it('no-op when no tool_call has a corresponding stored signature', () => {
+ // The persisted map exists but addresses different tool_call_ids
+ // (e.g., the previous turn's signatures, somehow leaked). Don't
+ // fabricate empty arrays onto the AIMessage.
+ const payload = [
+ { role: 'user', content: 'hi' },
+ {
+ role: 'assistant',
+ metadata: { thoughtSignatures: { unrelated_id: SIG_A } },
+ content: [
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: '', tool_call_ids: ['t1'] },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: { id: 't1', name: 'bash', args: '{}', output: 'ok' },
+ },
+ ],
+ },
+ ];
+
+ const result = formatAgentMessages(payload);
+ const assistant = result.find((m) => m instanceof AIMessage);
+ expect(assistant.additional_kwargs?.signatures).toBeUndefined();
+ });
+ });
+});
diff --git a/api/app/clients/prompts/formatGoogleInputs.js b/api/app/clients/prompts/formatGoogleInputs.js
new file mode 100644
index 0000000..c929df8
--- /dev/null
+++ b/api/app/clients/prompts/formatGoogleInputs.js
@@ -0,0 +1,42 @@
+/**
+ * Formats an object to match the struct_val, list_val, string_val, float_val, and int_val format.
+ *
+ * @param {Object} obj - The object to be formatted.
+ * @returns {Object} The formatted object.
+ *
+ * Handles different types:
+ * - Arrays are wrapped in list_val and each element is processed.
+ * - Objects are recursively processed.
+ * - Strings are wrapped in string_val.
+ * - Numbers are wrapped in float_val or int_val depending on whether they are floating-point or integers.
+ */
+function formatGoogleInputs(obj) {
+ const formattedObj = {};
+
+ for (const key in obj) {
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
+ const value = obj[key];
+
+ // Handle arrays
+ if (Array.isArray(value)) {
+ formattedObj[key] = { list_val: value.map((item) => formatGoogleInputs(item)) };
+ }
+ // Handle objects
+ else if (typeof value === 'object' && value !== null) {
+ formattedObj[key] = formatGoogleInputs(value);
+ }
+ // Handle numbers
+ else if (typeof value === 'number') {
+ formattedObj[key] = Number.isInteger(value) ? { int_val: value } : { float_val: value };
+ }
+ // Handle other types (e.g., strings)
+ else {
+ formattedObj[key] = { string_val: [value] };
+ }
+ }
+ }
+
+ return { struct_val: formattedObj };
+}
+
+module.exports = formatGoogleInputs;
diff --git a/api/app/clients/prompts/formatGoogleInputs.spec.js b/api/app/clients/prompts/formatGoogleInputs.spec.js
new file mode 100644
index 0000000..8fef9df
--- /dev/null
+++ b/api/app/clients/prompts/formatGoogleInputs.spec.js
@@ -0,0 +1,274 @@
+const formatGoogleInputs = require('./formatGoogleInputs');
+
+describe('formatGoogleInputs', () => {
+ it('formats message correctly', () => {
+ const input = {
+ messages: [
+ {
+ content: 'hi',
+ author: 'user',
+ },
+ ],
+ context: 'context',
+ examples: [
+ {
+ input: {
+ author: 'user',
+ content: 'user input',
+ },
+ output: {
+ author: 'bot',
+ content: 'bot output',
+ },
+ },
+ ],
+ parameters: {
+ temperature: 0.2,
+ topP: 0.8,
+ topK: 40,
+ maxOutputTokens: 1024,
+ },
+ };
+
+ const expectedOutput = {
+ struct_val: {
+ messages: {
+ list_val: [
+ {
+ struct_val: {
+ content: {
+ string_val: ['hi'],
+ },
+ author: {
+ string_val: ['user'],
+ },
+ },
+ },
+ ],
+ },
+ context: {
+ string_val: ['context'],
+ },
+ examples: {
+ list_val: [
+ {
+ struct_val: {
+ input: {
+ struct_val: {
+ author: {
+ string_val: ['user'],
+ },
+ content: {
+ string_val: ['user input'],
+ },
+ },
+ },
+ output: {
+ struct_val: {
+ author: {
+ string_val: ['bot'],
+ },
+ content: {
+ string_val: ['bot output'],
+ },
+ },
+ },
+ },
+ },
+ ],
+ },
+ parameters: {
+ struct_val: {
+ temperature: {
+ float_val: 0.2,
+ },
+ topP: {
+ float_val: 0.8,
+ },
+ topK: {
+ int_val: 40,
+ },
+ maxOutputTokens: {
+ int_val: 1024,
+ },
+ },
+ },
+ },
+ };
+
+ const result = formatGoogleInputs(input);
+ expect(JSON.stringify(result)).toEqual(JSON.stringify(expectedOutput));
+ });
+
+ it('formats real payload parts', () => {
+ const input = {
+ instances: [
+ {
+ context: 'context',
+ examples: [
+ {
+ input: {
+ author: 'user',
+ content: 'user input',
+ },
+ output: {
+ author: 'bot',
+ content: 'user output',
+ },
+ },
+ ],
+ messages: [
+ {
+ author: 'user',
+ content: 'hi',
+ },
+ ],
+ },
+ ],
+ parameters: {
+ candidateCount: 1,
+ maxOutputTokens: 1024,
+ temperature: 0.2,
+ topP: 0.8,
+ topK: 40,
+ },
+ };
+ const expectedOutput = {
+ struct_val: {
+ instances: {
+ list_val: [
+ {
+ struct_val: {
+ context: { string_val: ['context'] },
+ examples: {
+ list_val: [
+ {
+ struct_val: {
+ input: {
+ struct_val: {
+ author: { string_val: ['user'] },
+ content: { string_val: ['user input'] },
+ },
+ },
+ output: {
+ struct_val: {
+ author: { string_val: ['bot'] },
+ content: { string_val: ['user output'] },
+ },
+ },
+ },
+ },
+ ],
+ },
+ messages: {
+ list_val: [
+ {
+ struct_val: {
+ author: { string_val: ['user'] },
+ content: { string_val: ['hi'] },
+ },
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ parameters: {
+ struct_val: {
+ candidateCount: { int_val: 1 },
+ maxOutputTokens: { int_val: 1024 },
+ temperature: { float_val: 0.2 },
+ topP: { float_val: 0.8 },
+ topK: { int_val: 40 },
+ },
+ },
+ },
+ };
+
+ const result = formatGoogleInputs(input);
+ expect(JSON.stringify(result)).toEqual(JSON.stringify(expectedOutput));
+ });
+
+ it('helps create valid payload parts', () => {
+ const instances = {
+ context: 'context',
+ examples: [
+ {
+ input: {
+ author: 'user',
+ content: 'user input',
+ },
+ output: {
+ author: 'bot',
+ content: 'user output',
+ },
+ },
+ ],
+ messages: [
+ {
+ author: 'user',
+ content: 'hi',
+ },
+ ],
+ };
+
+ const expectedInstances = {
+ struct_val: {
+ context: { string_val: ['context'] },
+ examples: {
+ list_val: [
+ {
+ struct_val: {
+ input: {
+ struct_val: {
+ author: { string_val: ['user'] },
+ content: { string_val: ['user input'] },
+ },
+ },
+ output: {
+ struct_val: {
+ author: { string_val: ['bot'] },
+ content: { string_val: ['user output'] },
+ },
+ },
+ },
+ },
+ ],
+ },
+ messages: {
+ list_val: [
+ {
+ struct_val: {
+ author: { string_val: ['user'] },
+ content: { string_val: ['hi'] },
+ },
+ },
+ ],
+ },
+ },
+ };
+
+ const parameters = {
+ candidateCount: 1,
+ maxOutputTokens: 1024,
+ temperature: 0.2,
+ topP: 0.8,
+ topK: 40,
+ };
+ const expectedParameters = {
+ struct_val: {
+ candidateCount: { int_val: 1 },
+ maxOutputTokens: { int_val: 1024 },
+ temperature: { float_val: 0.2 },
+ topP: { float_val: 0.8 },
+ topK: { int_val: 40 },
+ },
+ };
+
+ const instancesResult = formatGoogleInputs(instances);
+ const parametersResult = formatGoogleInputs(parameters);
+ expect(JSON.stringify(instancesResult)).toEqual(JSON.stringify(expectedInstances));
+ expect(JSON.stringify(parametersResult)).toEqual(JSON.stringify(expectedParameters));
+ });
+});
diff --git a/api/app/clients/prompts/formatMessages.js b/api/app/clients/prompts/formatMessages.js
new file mode 100644
index 0000000..8435ff5
--- /dev/null
+++ b/api/app/clients/prompts/formatMessages.js
@@ -0,0 +1,283 @@
+const { EModelEndpoint, ContentTypes } = require('librechat-data-provider');
+const {
+ AIMessage,
+ ToolMessage,
+ HumanMessage,
+ SystemMessage,
+} = require('@librechat/agents/langchain/messages');
+
+/**
+ * Formats a message to OpenAI Vision API payload format.
+ *
+ * @param {Object} params - The parameters for formatting.
+ * @param {Object} params.message - The message object to format.
+ * @param {string} [params.message.role] - The role of the message sender (must be 'user').
+ * @param {string} [params.message.content] - The text content of the message.
+ * @param {EModelEndpoint} [params.endpoint] - Identifier for specific endpoint handling
+ * @param {Array} [params.image_urls] - The image_urls to attach to the message.
+ * @returns {(Object)} - The formatted message.
+ */
+const formatVisionMessage = ({ message, image_urls, endpoint }) => {
+ if (endpoint === EModelEndpoint.anthropic) {
+ message.content = [...image_urls, { type: ContentTypes.TEXT, text: message.content }];
+ return message;
+ }
+
+ message.content = [{ type: ContentTypes.TEXT, text: message.content }, ...image_urls];
+
+ return message;
+};
+
+/**
+ * Formats a message to OpenAI payload format based on the provided options.
+ *
+ * @param {Object} params - The parameters for formatting.
+ * @param {Object} params.message - The message object to format.
+ * @param {string} [params.message.role] - The role of the message sender (e.g., 'user', 'assistant').
+ * @param {string} [params.message._name] - The name associated with the message.
+ * @param {string} [params.message.sender] - The sender of the message.
+ * @param {string} [params.message.text] - The text content of the message.
+ * @param {string} [params.message.content] - The content of the message.
+ * @param {Array} [params.message.image_urls] - The image_urls attached to the message for Vision API.
+ * @param {string} [params.userName] - The name of the user.
+ * @param {string} [params.assistantName] - The name of the assistant.
+ * @param {string} [params.endpoint] - Identifier for specific endpoint handling
+ * @param {boolean} [params.langChain=false] - Whether to return a LangChain message object.
+ * @returns {(Object|HumanMessage|AIMessage|SystemMessage)} - The formatted message.
+ */
+const formatMessage = ({ message, userName, assistantName, endpoint, langChain = false }) => {
+ let { role: _role, _name, sender, text, content: _content, lc_id } = message;
+ if (lc_id && lc_id[2] && !langChain) {
+ const roleMapping = {
+ SystemMessage: 'system',
+ HumanMessage: 'user',
+ AIMessage: 'assistant',
+ };
+ _role = roleMapping[lc_id[2]];
+ }
+ const role = _role ?? (sender && sender?.toLowerCase() === 'user' ? 'user' : 'assistant');
+ const content = _content ?? text ?? '';
+ const formattedMessage = {
+ role,
+ content,
+ };
+
+ const { image_urls } = message;
+ if (Array.isArray(image_urls) && image_urls.length > 0 && role === 'user') {
+ return formatVisionMessage({
+ message: formattedMessage,
+ image_urls: message.image_urls,
+ endpoint,
+ });
+ }
+
+ if (_name) {
+ formattedMessage.name = _name;
+ }
+
+ if (userName && formattedMessage.role === 'user') {
+ formattedMessage.name = userName;
+ }
+
+ if (assistantName && formattedMessage.role === 'assistant') {
+ formattedMessage.name = assistantName;
+ }
+
+ if (formattedMessage.name) {
+ // Conform to API regex: ^[a-zA-Z0-9_-]{1,64}$
+ // https://community.openai.com/t/the-format-of-the-name-field-in-the-documentation-is-incorrect/175684/2
+ formattedMessage.name = formattedMessage.name.replace(/[^a-zA-Z0-9_-]/g, '_');
+
+ if (formattedMessage.name.length > 64) {
+ formattedMessage.name = formattedMessage.name.substring(0, 64);
+ }
+ }
+
+ if (!langChain) {
+ return formattedMessage;
+ }
+
+ if (role === 'user') {
+ return new HumanMessage(formattedMessage);
+ } else if (role === 'assistant') {
+ return new AIMessage(formattedMessage);
+ } else {
+ return new SystemMessage(formattedMessage);
+ }
+};
+
+/**
+ * Formats an array of messages for LangChain.
+ *
+ * @param {Array} messages - The array of messages to format.
+ * @param {Object} formatOptions - The options for formatting each message.
+ * @param {string} [formatOptions.userName] - The name of the user.
+ * @param {string} [formatOptions.assistantName] - The name of the assistant.
+ * @returns {Array<(HumanMessage|AIMessage|SystemMessage)>} - The array of formatted LangChain messages.
+ */
+const formatLangChainMessages = (messages, formatOptions) =>
+ messages.map((msg) => formatMessage({ ...formatOptions, message: msg, langChain: true }));
+
+/**
+ * Formats a LangChain message object by merging properties from `lc_kwargs` or `kwargs` and `additional_kwargs`.
+ *
+ * @param {Object} message - The message object to format.
+ * @param {Object} [message.lc_kwargs] - Contains properties to be merged. Either this or `message.kwargs` should be provided.
+ * @param {Object} [message.kwargs] - Contains properties to be merged. Either this or `message.lc_kwargs` should be provided.
+ * @param {Object} [message.kwargs.additional_kwargs] - Additional properties to be merged.
+ *
+ * @returns {Object} The formatted LangChain message.
+ */
+const formatFromLangChain = (message) => {
+ const { additional_kwargs, ...message_kwargs } = message.lc_kwargs ?? message.kwargs;
+ return {
+ ...message_kwargs,
+ ...additional_kwargs,
+ };
+};
+
+/**
+ * Formats an array of messages for LangChain, handling tool calls and creating ToolMessage instances.
+ *
+ * @param {Array>} payload - The array of messages to format.
+ * @returns {Array<(HumanMessage|AIMessage|SystemMessage|ToolMessage)>} - The array of formatted LangChain messages, including ToolMessages for tool calls.
+ */
+const formatAgentMessages = (payload) => {
+ const messages = [];
+
+ for (const message of payload) {
+ if (typeof message.content === 'string') {
+ message.content = [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: message.content }];
+ }
+ if (message.role !== 'assistant') {
+ messages.push(formatMessage({ message, langChain: true }));
+ continue;
+ }
+
+ let currentContent = [];
+ let lastAIMessage = null;
+ /**
+ * Every AIMessage produced from this TMessage that received `tool_calls`,
+ * in order. Multi-step tool turns (where the agent loop cycles the LLM
+ * multiple times with intervening tool results) produce one AIMessage per
+ * cycle, each owning a different `tool_call_id`. We attach persisted
+ * Vertex Gemini 3 thought signatures (`metadata.thoughtSignatures`,
+ * keyed by `tool_call_id`) onto each one so every step has its right
+ * signature on resume — Vertex validates per-step, not per-turn
+ * (issue #13006 follow-up).
+ */
+ const toolBearingAIMessages = [];
+
+ let hasReasoning = false;
+ for (const part of message.content) {
+ if (part.type === ContentTypes.TEXT && part.tool_call_ids) {
+ /*
+ If there's pending content, it needs to be aggregated as a single string to prepare for tool calls.
+ For Anthropic models, the "tool_calls" field on a message is only respected if content is a string.
+ */
+ if (currentContent.length > 0) {
+ let content = currentContent.reduce((acc, curr) => {
+ if (curr.type === ContentTypes.TEXT) {
+ return `${acc}${curr[ContentTypes.TEXT]}\n`;
+ }
+ return acc;
+ }, '');
+ content = `${content}\n${part[ContentTypes.TEXT] ?? ''}`.trim();
+ lastAIMessage = new AIMessage({ content });
+ messages.push(lastAIMessage);
+ currentContent = [];
+ continue;
+ }
+
+ // Create a new AIMessage with this text and prepare for tool calls
+ lastAIMessage = new AIMessage({
+ content: part.text || '',
+ });
+
+ messages.push(lastAIMessage);
+ } else if (part.type === ContentTypes.TOOL_CALL) {
+ if (!lastAIMessage) {
+ throw new Error('Invalid tool call structure: No preceding AIMessage with tool_call_ids');
+ }
+
+ // Note: `tool_calls` list is defined when constructed by `AIMessage` class, and outputs should be excluded from it
+ const { output, args: _args, ...tool_call } = part.tool_call;
+ // TODO: investigate; args as dictionary may need to be provider-or-tool-specific
+ let args = _args;
+ try {
+ args = JSON.parse(_args);
+ } catch (_e) {
+ if (typeof _args === 'string') {
+ args = { input: _args };
+ }
+ }
+
+ tool_call.args = args;
+ lastAIMessage.tool_calls.push(tool_call);
+ if (toolBearingAIMessages[toolBearingAIMessages.length - 1] !== lastAIMessage) {
+ toolBearingAIMessages.push(lastAIMessage);
+ }
+
+ // Add the corresponding ToolMessage
+ messages.push(
+ new ToolMessage({
+ tool_call_id: tool_call.id,
+ name: tool_call.name,
+ content: output || '',
+ }),
+ );
+ } else if (part.type === ContentTypes.THINK) {
+ hasReasoning = true;
+ continue;
+ } else if (part.type === ContentTypes.ERROR || part.type === ContentTypes.AGENT_UPDATE) {
+ continue;
+ } else {
+ currentContent.push(part);
+ }
+ }
+
+ if (hasReasoning) {
+ currentContent = currentContent
+ .reduce((acc, curr) => {
+ if (curr.type === ContentTypes.TEXT) {
+ return `${acc}${curr[ContentTypes.TEXT]}\n`;
+ }
+ return acc;
+ }, '')
+ .trim();
+ }
+
+ if (currentContent.length > 0) {
+ messages.push(new AIMessage({ content: currentContent }));
+ }
+
+ /**
+ * Restore signatures per-step. The persisted shape is
+ * `{ [tool_call_id]: signature }`; for each tool-bearing AIMessage we
+ * build a position-aligned `additional_kwargs.signatures` array (empty
+ * placeholders for tool_calls without a stored signature). Agents'
+ * `fixThoughtSignatures` then dispatches the non-empty entries to
+ * functionCall parts in order — order matches because non-empty
+ * signatures and tool_calls share their original parts ordering.
+ */
+ const sigsByCallId = message.metadata?.thoughtSignatures;
+ if (sigsByCallId && typeof sigsByCallId === 'object' && toolBearingAIMessages.length > 0) {
+ for (const aiMsg of toolBearingAIMessages) {
+ const sigs = aiMsg.tool_calls.map((tc) => sigsByCallId[tc.id] ?? '');
+ if (sigs.some((s) => typeof s === 'string' && s.length > 0)) {
+ aiMsg.additional_kwargs ??= {};
+ aiMsg.additional_kwargs.signatures = sigs;
+ }
+ }
+ }
+ }
+
+ return messages;
+};
+
+module.exports = {
+ formatMessage,
+ formatFromLangChain,
+ formatAgentMessages,
+ formatLangChainMessages,
+};
diff --git a/api/app/clients/prompts/formatMessages.spec.js b/api/app/clients/prompts/formatMessages.spec.js
new file mode 100644
index 0000000..7cee655
--- /dev/null
+++ b/api/app/clients/prompts/formatMessages.spec.js
@@ -0,0 +1,276 @@
+const { Constants } = require('librechat-data-provider');
+const { HumanMessage, AIMessage, SystemMessage } = require('@librechat/agents/langchain/messages');
+const { formatMessage, formatLangChainMessages, formatFromLangChain } = require('./formatMessages');
+
+describe('formatMessage', () => {
+ it('formats user message', () => {
+ const input = {
+ message: {
+ sender: 'user',
+ text: 'Hello',
+ },
+ userName: 'John',
+ };
+ const result = formatMessage(input);
+ expect(result).toEqual({
+ role: 'user',
+ content: 'Hello',
+ name: 'John',
+ });
+ });
+
+ it('sanitizes the name by replacing invalid characters (per OpenAI)', () => {
+ const input = {
+ message: {
+ sender: 'user',
+ text: 'Hello',
+ },
+ userName: ' John$Doe@Example! ',
+ };
+ const result = formatMessage(input);
+ expect(result).toEqual({
+ role: 'user',
+ content: 'Hello',
+ name: '_John_Doe_Example__',
+ });
+ });
+
+ it('trims the name to a maximum length of 64 characters', () => {
+ const longName = 'a'.repeat(100);
+ const input = {
+ message: {
+ sender: 'user',
+ text: 'Hello',
+ },
+ userName: longName,
+ };
+ const result = formatMessage(input);
+ expect(result.name.length).toBe(64);
+ expect(result.name).toBe('a'.repeat(64));
+ });
+
+ it('formats a realistic user message', () => {
+ const input = {
+ message: {
+ _id: '6512cdfb92cbf69fea615331',
+ messageId: 'b620bf73-c5c3-4a38-b724-76886aac24c4',
+ __v: 0,
+ conversationId: '5c23d24f-941f-4aab-85df-127b596c8aa5',
+ createdAt: Date.now(),
+ error: false,
+ finish_reason: null,
+ isCreatedByUser: true,
+ model: null,
+ parentMessageId: Constants.NO_PARENT,
+ sender: 'User',
+ text: 'hi',
+ tokenCount: 5,
+ unfinished: false,
+ updatedAt: Date.now(),
+ user: '6512cdf475f05c86d44c31d2',
+ },
+ userName: 'John',
+ };
+ const result = formatMessage(input);
+ expect(result).toEqual({
+ role: 'user',
+ content: 'hi',
+ name: 'John',
+ });
+ });
+
+ it('formats assistant message', () => {
+ const input = {
+ message: {
+ sender: 'assistant',
+ text: 'Hi there',
+ },
+ assistantName: 'Assistant',
+ };
+ const result = formatMessage(input);
+ expect(result).toEqual({
+ role: 'assistant',
+ content: 'Hi there',
+ name: 'Assistant',
+ });
+ });
+
+ it('formats system message', () => {
+ const input = {
+ message: {
+ role: 'system',
+ text: 'Hi there',
+ },
+ };
+ const result = formatMessage(input);
+ expect(result).toEqual({
+ role: 'system',
+ content: 'Hi there',
+ });
+ });
+
+ it('formats user message with langChain', () => {
+ const input = {
+ message: {
+ sender: 'user',
+ text: 'Hello',
+ },
+ userName: 'John',
+ langChain: true,
+ };
+ const result = formatMessage(input);
+ expect(result).toBeInstanceOf(HumanMessage);
+ expect(result.lc_kwargs.content).toEqual(input.message.text);
+ expect(result.lc_kwargs.name).toEqual(input.userName);
+ });
+
+ it('formats assistant message with langChain', () => {
+ const input = {
+ message: {
+ sender: 'assistant',
+ text: 'Hi there',
+ },
+ assistantName: 'Assistant',
+ langChain: true,
+ };
+ const result = formatMessage(input);
+ expect(result).toBeInstanceOf(AIMessage);
+ expect(result.lc_kwargs.content).toEqual(input.message.text);
+ expect(result.lc_kwargs.name).toEqual(input.assistantName);
+ });
+
+ it('formats system message with langChain', () => {
+ const input = {
+ message: {
+ role: 'system',
+ text: 'This is a system message.',
+ },
+ langChain: true,
+ };
+ const result = formatMessage(input);
+ expect(result).toBeInstanceOf(SystemMessage);
+ expect(result.lc_kwargs.content).toEqual(input.message.text);
+ });
+
+ it('formats langChain messages into OpenAI payload format', () => {
+ const human = {
+ message: new HumanMessage({
+ content: 'Hello',
+ }),
+ };
+ const system = {
+ message: new SystemMessage({
+ content: 'Hello',
+ }),
+ };
+ const ai = {
+ message: new AIMessage({
+ content: 'Hello',
+ }),
+ };
+ const humanResult = formatMessage(human);
+ const systemResult = formatMessage(system);
+ const aiResult = formatMessage(ai);
+ expect(humanResult).toEqual({
+ role: 'user',
+ content: 'Hello',
+ });
+ expect(systemResult).toEqual({
+ role: 'system',
+ content: 'Hello',
+ });
+ expect(aiResult).toEqual({
+ role: 'assistant',
+ content: 'Hello',
+ });
+ });
+});
+
+describe('formatLangChainMessages', () => {
+ it('formats an array of messages for LangChain', () => {
+ const messages = [
+ {
+ role: 'system',
+ content: 'This is a system message',
+ },
+ {
+ sender: 'user',
+ text: 'Hello',
+ },
+ {
+ sender: 'assistant',
+ text: 'Hi there',
+ },
+ ];
+ const formatOptions = {
+ userName: 'John',
+ assistantName: 'Assistant',
+ };
+ const result = formatLangChainMessages(messages, formatOptions);
+ expect(result).toHaveLength(3);
+ expect(result[0]).toBeInstanceOf(SystemMessage);
+ expect(result[1]).toBeInstanceOf(HumanMessage);
+ expect(result[2]).toBeInstanceOf(AIMessage);
+
+ expect(result[0].lc_kwargs.content).toEqual(messages[0].content);
+ expect(result[1].lc_kwargs.content).toEqual(messages[1].text);
+ expect(result[2].lc_kwargs.content).toEqual(messages[2].text);
+
+ expect(result[1].lc_kwargs.name).toEqual(formatOptions.userName);
+ expect(result[2].lc_kwargs.name).toEqual(formatOptions.assistantName);
+ });
+
+ describe('formatFromLangChain', () => {
+ it('should merge kwargs and additional_kwargs', () => {
+ const message = {
+ kwargs: {
+ content: 'some content',
+ name: 'dan',
+ additional_kwargs: {
+ function_call: {
+ name: 'dall-e',
+ arguments: '{\n "input": "Subject: hedgehog, Style: cute"\n}',
+ },
+ },
+ },
+ };
+
+ const expected = {
+ content: 'some content',
+ name: 'dan',
+ function_call: {
+ name: 'dall-e',
+ arguments: '{\n "input": "Subject: hedgehog, Style: cute"\n}',
+ },
+ };
+
+ expect(formatFromLangChain(message)).toEqual(expected);
+ });
+
+ it('should handle messages without additional_kwargs', () => {
+ const message = {
+ kwargs: {
+ content: 'some content',
+ name: 'dan',
+ },
+ };
+
+ const expected = {
+ content: 'some content',
+ name: 'dan',
+ };
+
+ expect(formatFromLangChain(message)).toEqual(expected);
+ });
+
+ it('should handle empty messages', () => {
+ const message = {
+ kwargs: {},
+ };
+
+ const expected = {};
+
+ expect(formatFromLangChain(message)).toEqual(expected);
+ });
+ });
+});
diff --git a/api/app/clients/prompts/index.js b/api/app/clients/prompts/index.js
new file mode 100644
index 0000000..ba4859e
--- /dev/null
+++ b/api/app/clients/prompts/index.js
@@ -0,0 +1,13 @@
+const formatMessages = require('./formatMessages');
+const summaryPrompts = require('./summaryPrompts');
+const truncate = require('./truncate');
+const createVisionPrompt = require('./createVisionPrompt');
+const createContextHandlers = require('./createContextHandlers');
+
+module.exports = {
+ ...formatMessages,
+ ...summaryPrompts,
+ ...truncate,
+ createVisionPrompt,
+ createContextHandlers,
+};
diff --git a/api/app/clients/prompts/shadcn-docs/components.js b/api/app/clients/prompts/shadcn-docs/components.js
new file mode 100644
index 0000000..b67c47d
--- /dev/null
+++ b/api/app/clients/prompts/shadcn-docs/components.js
@@ -0,0 +1,495 @@
+// Essential Components
+const essentialComponents = {
+ avatar: {
+ componentName: 'Avatar',
+ importDocs: 'import { Avatar, AvatarFallback, AvatarImage } from "/components/ui/avatar"',
+ usageDocs: `
+
+
+ CN
+ `,
+ },
+ button: {
+ componentName: 'Button',
+ importDocs: 'import { Button } from "/components/ui/button"',
+ usageDocs: `
+Button `,
+ },
+ card: {
+ componentName: 'Card',
+ importDocs: `
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "/components/ui/card"`,
+ usageDocs: `
+
+
+ Card Title
+ Card Description
+
+
+ Card Content
+
+
+ Card Footer
+
+ `,
+ },
+ checkbox: {
+ componentName: 'Checkbox',
+ importDocs: 'import { Checkbox } from "/components/ui/checkbox"',
+ usageDocs: ' ',
+ },
+ input: {
+ componentName: 'Input',
+ importDocs: 'import { Input } from "/components/ui/input"',
+ usageDocs: ' ',
+ },
+ label: {
+ componentName: 'Label',
+ importDocs: 'import { Label } from "/components/ui/label"',
+ usageDocs: 'Your email address ',
+ },
+ radioGroup: {
+ componentName: 'RadioGroup',
+ importDocs: `
+import { Label } from "/components/ui/label"
+import { RadioGroup, RadioGroupItem } from "/components/ui/radio-group"`,
+ usageDocs: `
+
+
+
+ Option One
+
+
+
+ Option Two
+
+ `,
+ },
+ select: {
+ componentName: 'Select',
+ importDocs: `
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "/components/ui/select"`,
+ usageDocs: `
+
+
+
+
+
+ Light
+ Dark
+ System
+
+ `,
+ },
+ textarea: {
+ componentName: 'Textarea',
+ importDocs: 'import { Textarea } from "/components/ui/textarea"',
+ usageDocs: '',
+ },
+};
+
+// Extra Components
+const extraComponents = {
+ accordion: {
+ componentName: 'Accordion',
+ importDocs: `
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "/components/ui/accordion"`,
+ usageDocs: `
+
+
+ Is it accessible?
+
+ Yes. It adheres to the WAI-ARIA design pattern.
+
+
+ `,
+ },
+ alertDialog: {
+ componentName: 'AlertDialog',
+ importDocs: `
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "/components/ui/alert-dialog"`,
+ usageDocs: `
+
+ Open
+
+
+ Are you absolutely sure?
+
+ This action cannot be undone.
+
+
+
+ Cancel
+ Continue
+
+
+ `,
+ },
+ alert: {
+ componentName: 'Alert',
+ importDocs: `
+import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
+} from "/components/ui/alert"`,
+ usageDocs: `
+
+ Heads up!
+
+ You can add components to your app using the cli.
+
+ `,
+ },
+ aspectRatio: {
+ componentName: 'AspectRatio',
+ importDocs: 'import { AspectRatio } from "/components/ui/aspect-ratio"',
+ usageDocs: `
+
+
+ `,
+ },
+ badge: {
+ componentName: 'Badge',
+ importDocs: 'import { Badge } from "/components/ui/badge"',
+ usageDocs: 'Badge ',
+ },
+ calendar: {
+ componentName: 'Calendar',
+ importDocs: 'import { Calendar } from "/components/ui/calendar"',
+ usageDocs: ' ',
+ },
+ carousel: {
+ componentName: 'Carousel',
+ importDocs: `
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselNext,
+ CarouselPrevious,
+} from "/components/ui/carousel"`,
+ usageDocs: `
+
+
+ ...
+ ...
+ ...
+
+
+
+ `,
+ },
+ collapsible: {
+ componentName: 'Collapsible',
+ importDocs: `
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "/components/ui/collapsible"`,
+ usageDocs: `
+
+ Can I use this in my project?
+
+ Yes. Free to use for personal and commercial projects. No attribution required.
+
+ `,
+ },
+ dialog: {
+ componentName: 'Dialog',
+ importDocs: `
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "/components/ui/dialog"`,
+ usageDocs: `
+
+ Open
+
+
+ Are you sure absolutely sure?
+
+ This action cannot be undone.
+
+
+
+ `,
+ },
+ dropdownMenu: {
+ componentName: 'DropdownMenu',
+ importDocs: `
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "/components/ui/dropdown-menu"`,
+ usageDocs: `
+
+ Open
+
+ My Account
+
+ Profile
+ Billing
+ Team
+ Subscription
+
+ `,
+ },
+ menubar: {
+ componentName: 'Menubar',
+ importDocs: `
+import {
+ Menubar,
+ MenubarContent,
+ MenubarItem,
+ MenubarMenu,
+ MenubarSeparator,
+ MenubarShortcut,
+ MenubarTrigger,
+} from "/components/ui/menubar"`,
+ usageDocs: `
+
+
+ File
+
+
+ New Tab ⌘T
+
+ New Window
+
+ Share
+
+ Print
+
+
+ `,
+ },
+ navigationMenu: {
+ componentName: 'NavigationMenu',
+ importDocs: `
+import {
+ NavigationMenu,
+ NavigationMenuContent,
+ NavigationMenuItem,
+ NavigationMenuLink,
+ NavigationMenuList,
+ NavigationMenuTrigger,
+ navigationMenuTriggerStyle,
+} from "/components/ui/navigation-menu"`,
+ usageDocs: `
+
+
+
+ Item One
+
+ Link
+
+
+
+ `,
+ },
+ popover: {
+ componentName: 'Popover',
+ importDocs: `
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "/components/ui/popover"`,
+ usageDocs: `
+
+ Open
+ Place content for the popover here.
+ `,
+ },
+ progress: {
+ componentName: 'Progress',
+ importDocs: 'import { Progress } from "/components/ui/progress"',
+ usageDocs: ' ',
+ },
+ separator: {
+ componentName: 'Separator',
+ importDocs: 'import { Separator } from "/components/ui/separator"',
+ usageDocs: ' ',
+ },
+ sheet: {
+ componentName: 'Sheet',
+ importDocs: `
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "/components/ui/sheet"`,
+ usageDocs: `
+
+ Open
+
+
+ Are you sure absolutely sure?
+
+ This action cannot be undone.
+
+
+
+ `,
+ },
+ skeleton: {
+ componentName: 'Skeleton',
+ importDocs: 'import { Skeleton } from "/components/ui/skeleton"',
+ usageDocs: ' ',
+ },
+ slider: {
+ componentName: 'Slider',
+ importDocs: 'import { Slider } from "/components/ui/slider"',
+ usageDocs: ' ',
+ },
+ switch: {
+ componentName: 'Switch',
+ importDocs: 'import { Switch } from "/components/ui/switch"',
+ usageDocs: ' ',
+ },
+ table: {
+ componentName: 'Table',
+ importDocs: `
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "/components/ui/table"`,
+ usageDocs: `
+
+ A list of your recent invoices.
+
+
+ Invoice
+ Status
+ Method
+ Amount
+
+
+
+
+ INV001
+ Paid
+ Credit Card
+ $250.00
+
+
+
`,
+ },
+ tabs: {
+ componentName: 'Tabs',
+ importDocs: `
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "/components/ui/tabs"`,
+ usageDocs: `
+
+
+ Account
+ Password
+
+ Make changes to your account here.
+ Change your password here.
+ `,
+ },
+ toast: {
+ componentName: 'Toast',
+ importDocs: `
+import { useToast } from "/components/ui/use-toast"
+import { Button } from "/components/ui/button"`,
+ usageDocs: `
+export function ToastDemo() {
+ const { toast } = useToast()
+ return (
+ {
+ toast({
+ title: "Scheduled: Catch up",
+ description: "Friday, February 10, 2023 at 5:57 PM",
+ })
+ }}
+ >
+ Show Toast
+
+ )
+}`,
+ },
+ toggle: {
+ componentName: 'Toggle',
+ importDocs: 'import { Toggle } from "/components/ui/toggle"',
+ usageDocs: 'Toggle ',
+ },
+ tooltip: {
+ componentName: 'Tooltip',
+ importDocs: `
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "/components/ui/tooltip"`,
+ usageDocs: `
+
+
+ Hover
+
+ Add to library
+
+
+ `,
+ },
+};
+
+const components = Object.assign({}, essentialComponents, extraComponents);
+
+module.exports = {
+ components,
+};
diff --git a/api/app/clients/prompts/shadcn-docs/generate.js b/api/app/clients/prompts/shadcn-docs/generate.js
new file mode 100644
index 0000000..6a4cf8c
--- /dev/null
+++ b/api/app/clients/prompts/shadcn-docs/generate.js
@@ -0,0 +1,50 @@
+const dedent = require('dedent');
+
+/**
+ * Generate system prompt for AI-assisted React component creation
+ * @param {Object} options - Configuration options
+ * @param {Object} options.components - Documentation for shadcn components
+ * @param {boolean} [options.useXML=false] - Whether to use XML-style formatting for component instructions
+ * @returns {string} The generated system prompt
+ */
+function generateShadcnPrompt(options) {
+ const { components, useXML = false } = options;
+
+ let systemPrompt = dedent`
+ ## Additional Artifact Instructions for React Components: "application/vnd.react"
+
+ There are some prestyled components (primitives) available for use. Please use your best judgement to use any of these components if the app calls for one.
+
+ Here are the components that are available, along with how to import them, and how to use them:
+
+ ${Object.values(components)
+ .map((component) => {
+ if (useXML) {
+ return dedent`
+
+ ${component.componentName}
+ ${component.importDocs}
+ ${component.usageDocs}
+
+ `;
+ } else {
+ return dedent`
+ # ${component.componentName}
+
+ ## Import Instructions
+ ${component.importDocs}
+
+ ## Usage Instructions
+ ${component.usageDocs}
+ `;
+ }
+ })
+ .join('\n\n')}
+ `;
+
+ return systemPrompt;
+}
+
+module.exports = {
+ generateShadcnPrompt,
+};
diff --git a/api/app/clients/prompts/summaryPrompts.js b/api/app/clients/prompts/summaryPrompts.js
new file mode 100644
index 0000000..1897b19
--- /dev/null
+++ b/api/app/clients/prompts/summaryPrompts.js
@@ -0,0 +1,53 @@
+const { PromptTemplate } = require('@librechat/agents/langchain/prompts');
+/*
+ * Without `{summary}` and `{new_lines}`, token count is 98
+ * We are counting this towards the max context tokens for summaries, +3 for the assistant label (101)
+ * If this prompt changes, use https://tiktokenizer.vercel.app/ to count the tokens
+ */
+const _DEFAULT_SUMMARIZER_TEMPLATE = `Summarize the conversation by integrating new lines into the current summary.
+
+EXAMPLE:
+Current summary:
+The human inquires about the AI's view on artificial intelligence. The AI believes it's beneficial.
+
+New lines:
+Human: Why is it beneficial?
+AI: It helps humans achieve their potential.
+
+New summary:
+The human inquires about the AI's view on artificial intelligence. The AI believes it's beneficial because it helps humans achieve their potential.
+
+Current summary:
+{summary}
+
+New lines:
+{new_lines}
+
+New summary:`;
+
+const SUMMARY_PROMPT = new PromptTemplate({
+ inputVariables: ['summary', 'new_lines'],
+ template: _DEFAULT_SUMMARIZER_TEMPLATE,
+});
+
+/*
+ * Without `{new_lines}`, token count is 27
+ * We are counting this towards the max context tokens for summaries, rounded up to 30
+ * If this prompt changes, use https://tiktokenizer.vercel.app/ to count the tokens
+ */
+const _CUT_OFF_SUMMARIZER = `The following text is cut-off:
+{new_lines}
+
+Summarize the content as best as you can, noting that it was cut-off.
+
+Summary:`;
+
+const CUT_OFF_PROMPT = new PromptTemplate({
+ inputVariables: ['new_lines'],
+ template: _CUT_OFF_SUMMARIZER,
+});
+
+module.exports = {
+ SUMMARY_PROMPT,
+ CUT_OFF_PROMPT,
+};
diff --git a/api/app/clients/prompts/truncate.js b/api/app/clients/prompts/truncate.js
new file mode 100644
index 0000000..e744b40
--- /dev/null
+++ b/api/app/clients/prompts/truncate.js
@@ -0,0 +1,40 @@
+const MAX_CHAR = 255;
+
+/**
+ * Truncates a given text to a specified maximum length, appending ellipsis and a notification
+ * if the original text exceeds the maximum length.
+ *
+ * @param {string} text - The text to be truncated.
+ * @param {number} [maxLength=MAX_CHAR] - The maximum length of the text after truncation. Defaults to MAX_CHAR.
+ * @returns {string} The truncated text if the original text length exceeds maxLength, otherwise returns the original text.
+ */
+function truncateText(text, maxLength = MAX_CHAR) {
+ if (text.length > maxLength) {
+ return `${text.slice(0, maxLength)}... [text truncated for brevity]`;
+ }
+ return text;
+}
+
+/**
+ * Truncates a given text to a specified maximum length by showing the first half and the last half of the text,
+ * separated by ellipsis. This method ensures the output does not exceed the maximum length, including the addition
+ * of ellipsis and notification if the original text exceeds the maximum length.
+ *
+ * @param {string} text - The text to be truncated.
+ * @param {number} [maxLength=MAX_CHAR] - The maximum length of the output text after truncation. Defaults to MAX_CHAR.
+ * @returns {string} The truncated text showing the first half and the last half, or the original text if it does not exceed maxLength.
+ */
+function smartTruncateText(text, maxLength = MAX_CHAR) {
+ const ellipsis = '...';
+ const notification = ' [text truncated for brevity]';
+ const halfMaxLength = Math.floor((maxLength - ellipsis.length - notification.length) / 2);
+
+ if (text.length > maxLength) {
+ const startLastHalf = text.length - halfMaxLength;
+ return `${text.slice(0, halfMaxLength)}${ellipsis}${text.slice(startLastHalf)}${notification}`;
+ }
+
+ return text;
+}
+
+module.exports = { truncateText, smartTruncateText };
diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js
new file mode 100644
index 0000000..d565f87
--- /dev/null
+++ b/api/app/clients/specs/BaseClient.test.js
@@ -0,0 +1,1611 @@
+const { Constants } = require('librechat-data-provider');
+const { FakeClient, initializeFakeClient } = require('./FakeClient');
+
+jest.mock('~/db/connect');
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn().mockResolvedValue({
+ // Default app config for tests
+ paths: { uploads: '/tmp' },
+ fileStrategy: 'local',
+ memory: { disabled: false },
+ }),
+}));
+jest.mock('~/models', () => ({
+ User: jest.fn(),
+ Key: jest.fn(),
+ Session: jest.fn(),
+ Balance: jest.fn(),
+ Transaction: jest.fn(),
+ getMessages: jest.fn().mockResolvedValue([]),
+ saveMessage: jest.fn(),
+ updateMessage: jest.fn(),
+ deleteMessagesSince: jest.fn(),
+ deleteMessages: jest.fn(),
+ getConvoTitle: jest.fn(),
+ getConvo: jest.fn(),
+ saveConvo: jest.fn(),
+ deleteConvos: jest.fn(),
+ getPreset: jest.fn(),
+ getPresets: jest.fn(),
+ savePreset: jest.fn(),
+ deletePresets: jest.fn(),
+ findFileById: jest.fn(),
+ createFile: jest.fn(),
+ updateFile: jest.fn(),
+ deleteFile: jest.fn(),
+ deleteFiles: jest.fn(),
+ getFiles: jest.fn(),
+ updateFileUsage: jest.fn(),
+}));
+
+const { getConvo, getFiles, getMessages, saveConvo, saveMessage } = require('~/models');
+
+jest.mock('@librechat/agents', () => {
+ const actual = jest.requireActual('@librechat/agents');
+ return {
+ ...actual,
+ ChatOpenAI: jest.fn().mockImplementation(() => {
+ return {};
+ }),
+ };
+});
+
+let parentMessageId;
+let conversationId;
+const fakeMessages = [];
+const userMessage = 'Hello, ChatGPT!';
+const apiKey = 'fake-api-key';
+
+const messageHistory = [
+ { role: 'user', isCreatedByUser: true, text: 'Hello', messageId: '1' },
+ { role: 'assistant', isCreatedByUser: false, text: 'Hi', messageId: '2', parentMessageId: '1' },
+ {
+ role: 'user',
+ isCreatedByUser: true,
+ text: "What's up",
+ messageId: '3',
+ parentMessageId: '2',
+ },
+];
+
+describe('BaseClient', () => {
+ let TestClient;
+ const options = {
+ // debug: true,
+ modelOptions: {
+ model: 'gpt-4o-mini',
+ temperature: 0,
+ },
+ };
+
+ beforeEach(() => {
+ TestClient = initializeFakeClient(apiKey, options, fakeMessages);
+ TestClient.summarizeMessages = jest.fn().mockResolvedValue({
+ summaryMessage: {
+ role: 'system',
+ content: 'Refined answer',
+ },
+ summaryTokenCount: 5,
+ });
+ });
+
+ test('returns the input messages without instructions when addInstructions() is called with empty instructions', () => {
+ const messages = [{ content: 'Hello' }, { content: 'How are you?' }, { content: 'Goodbye' }];
+ const instructions = '';
+ const result = TestClient.addInstructions(messages, instructions);
+ expect(result).toEqual(messages);
+ });
+
+ test('returns the input messages with instructions properly added when addInstructions() is called with non-empty instructions', () => {
+ const messages = [{ content: 'Hello' }, { content: 'How are you?' }, { content: 'Goodbye' }];
+ const instructions = { content: 'Please respond to the question.' };
+ const result = TestClient.addInstructions(messages, instructions);
+ const expected = [
+ { content: 'Please respond to the question.' },
+ { content: 'Hello' },
+ { content: 'How are you?' },
+ { content: 'Goodbye' },
+ ];
+ expect(result).toEqual(expected);
+ });
+
+ test('returns the input messages with instructions properly added when addInstructions() with legacy flag', () => {
+ const messages = [{ content: 'Hello' }, { content: 'How are you?' }, { content: 'Goodbye' }];
+ const instructions = { content: 'Please respond to the question.' };
+ const result = TestClient.addInstructions(messages, instructions, true);
+ const expected = [
+ { content: 'Hello' },
+ { content: 'How are you?' },
+ { content: 'Please respond to the question.' },
+ { content: 'Goodbye' },
+ ];
+ expect(result).toEqual(expected);
+ });
+
+ test('concats messages correctly in concatenateMessages()', () => {
+ const messages = [
+ { name: 'User', content: 'Hello' },
+ { name: 'Assistant', content: 'How can I help you?' },
+ { name: 'User', content: 'I have a question.' },
+ ];
+ const result = TestClient.concatenateMessages(messages);
+ const expected =
+ 'User:\nHello\n\nAssistant:\nHow can I help you?\n\nUser:\nI have a question.\n\n';
+ expect(result).toBe(expected);
+ });
+
+ test('refines messages correctly in summarizeMessages()', async () => {
+ const messagesToRefine = [
+ { role: 'user', content: 'Hello', tokenCount: 10 },
+ { role: 'assistant', content: 'How can I help you?', tokenCount: 20 },
+ ];
+ const remainingContextTokens = 100;
+ const expectedRefinedMessage = {
+ role: 'system',
+ content: 'Refined answer',
+ };
+
+ const result = await TestClient.summarizeMessages({ messagesToRefine, remainingContextTokens });
+ expect(result.summaryMessage).toEqual(expectedRefinedMessage);
+ });
+
+ test('gets messages within token limit (under limit) correctly in getMessagesWithinTokenLimit()', async () => {
+ TestClient.maxContextTokens = 100;
+ TestClient.shouldSummarize = true;
+
+ const messages = [
+ { role: 'user', content: 'Hello', tokenCount: 5 },
+ { role: 'assistant', content: 'How can I help you?', tokenCount: 19 },
+ { role: 'user', content: 'I have a question.', tokenCount: 18 },
+ ];
+ const expectedContext = [
+ { role: 'user', content: 'Hello', tokenCount: 5 }, // 'Hello'.length
+ { role: 'assistant', content: 'How can I help you?', tokenCount: 19 },
+ { role: 'user', content: 'I have a question.', tokenCount: 18 },
+ ];
+ // Subtract 3 tokens for Assistant Label priming after all messages have been counted.
+ const expectedRemainingContextTokens = 58 - 3; // (100 - 5 - 19 - 18) - 3
+ const expectedMessagesToRefine = [];
+
+ const lastExpectedMessage =
+ expectedMessagesToRefine?.[expectedMessagesToRefine.length - 1] ?? {};
+ const expectedIndex = messages.findIndex((msg) => msg.content === lastExpectedMessage?.content);
+
+ const result = await TestClient.getMessagesWithinTokenLimit({ messages });
+
+ expect(result.context).toEqual(expectedContext);
+ expect(result.messagesToRefine.length - 1).toEqual(expectedIndex);
+ expect(result.remainingContextTokens).toBe(expectedRemainingContextTokens);
+ expect(result.messagesToRefine).toEqual(expectedMessagesToRefine);
+ });
+
+ test('gets result over token limit correctly in getMessagesWithinTokenLimit()', async () => {
+ TestClient.maxContextTokens = 50; // Set a lower limit
+ TestClient.shouldSummarize = true;
+
+ const messages = [
+ { role: 'user', content: 'Hello', tokenCount: 30 },
+ { role: 'assistant', content: 'How can I help you?', tokenCount: 30 },
+ { role: 'user', content: 'I have a question.', tokenCount: 5 },
+ { role: 'user', content: 'I need a coffee, stat!', tokenCount: 19 },
+ { role: 'assistant', content: 'Sure, I can help with that.', tokenCount: 18 },
+ ];
+
+ // Subtract 3 tokens for Assistant Label priming after all messages have been counted.
+ const expectedRemainingContextTokens = 5; // (50 - 18 - 19 - 5) - 3
+ const expectedMessagesToRefine = [
+ { role: 'user', content: 'Hello', tokenCount: 30 },
+ { role: 'assistant', content: 'How can I help you?', tokenCount: 30 },
+ ];
+ const expectedContext = [
+ { role: 'user', content: 'I have a question.', tokenCount: 5 },
+ { role: 'user', content: 'I need a coffee, stat!', tokenCount: 19 },
+ { role: 'assistant', content: 'Sure, I can help with that.', tokenCount: 18 },
+ ];
+
+ const lastExpectedMessage =
+ expectedMessagesToRefine?.[expectedMessagesToRefine.length - 1] ?? {};
+ const expectedIndex = messages.findIndex((msg) => msg.content === lastExpectedMessage?.content);
+
+ const result = await TestClient.getMessagesWithinTokenLimit({ messages });
+
+ expect(result.context).toEqual(expectedContext);
+ expect(result.messagesToRefine.length - 1).toEqual(expectedIndex);
+ expect(result.remainingContextTokens).toBe(expectedRemainingContextTokens);
+ expect(result.messagesToRefine).toEqual(expectedMessagesToRefine);
+ });
+
+ describe('getMessagesForConversation', () => {
+ it('should return an empty array if the parentMessageId does not exist', () => {
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: unorderedMessages,
+ parentMessageId: '999',
+ });
+ expect(result).toEqual([]);
+ });
+
+ it('should handle messages with messageId property', () => {
+ const messagesWithMessageId = [
+ { messageId: '1', parentMessageId: null, text: 'Message 1' },
+ { messageId: '2', parentMessageId: '1', text: 'Message 2' },
+ ];
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: messagesWithMessageId,
+ parentMessageId: '2',
+ });
+ expect(result).toEqual([
+ { messageId: '1', parentMessageId: null, text: 'Message 1' },
+ { messageId: '2', parentMessageId: '1', text: 'Message 2' },
+ ]);
+ });
+
+ const messagesWithNullParent = [
+ { id: '1', parentMessageId: null, text: 'Message 1' },
+ { id: '2', parentMessageId: null, text: 'Message 2' },
+ ];
+
+ it('should handle messages with null parentMessageId that are not root', () => {
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: messagesWithNullParent,
+ parentMessageId: '2',
+ });
+ expect(result).toEqual([{ id: '2', parentMessageId: null, text: 'Message 2' }]);
+ });
+
+ const cyclicMessages = [
+ { id: '3', parentMessageId: '2', text: 'Message 3' },
+ { id: '1', parentMessageId: '3', text: 'Message 1' },
+ { id: '2', parentMessageId: '1', text: 'Message 2' },
+ ];
+
+ it('should handle cyclic references without going into an infinite loop', () => {
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: cyclicMessages,
+ parentMessageId: '3',
+ });
+ expect(result).toEqual([
+ { id: '1', parentMessageId: '3', text: 'Message 1' },
+ { id: '2', parentMessageId: '1', text: 'Message 2' },
+ { id: '3', parentMessageId: '2', text: 'Message 3' },
+ ]);
+ });
+
+ const unorderedMessages = [
+ { id: '3', parentMessageId: '2', text: 'Message 3' },
+ { id: '2', parentMessageId: '1', text: 'Message 2' },
+ { id: '1', parentMessageId: Constants.NO_PARENT, text: 'Message 1' },
+ ];
+
+ it('should return ordered messages based on parentMessageId', () => {
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: unorderedMessages,
+ parentMessageId: '3',
+ });
+ expect(result).toEqual([
+ { id: '1', parentMessageId: Constants.NO_PARENT, text: 'Message 1' },
+ { id: '2', parentMessageId: '1', text: 'Message 2' },
+ { id: '3', parentMessageId: '2', text: 'Message 3' },
+ ]);
+ });
+
+ const unorderedBranchedMessages = [
+ { id: '4', parentMessageId: '2', text: 'Message 4', summary: 'Summary for Message 4' },
+ { id: '10', parentMessageId: '7', text: 'Message 10' },
+ { id: '1', parentMessageId: null, text: 'Message 1' },
+ { id: '6', parentMessageId: '5', text: 'Message 7' },
+ { id: '7', parentMessageId: '5', text: 'Message 7' },
+ { id: '2', parentMessageId: '1', text: 'Message 2' },
+ { id: '8', parentMessageId: '6', text: 'Message 8' },
+ { id: '5', parentMessageId: '3', text: 'Message 5' },
+ { id: '3', parentMessageId: '1', text: 'Message 3' },
+ { id: '6', parentMessageId: '4', text: 'Message 6' },
+ { id: '8', parentMessageId: '7', text: 'Message 9' },
+ { id: '9', parentMessageId: '7', text: 'Message 9' },
+ { id: '11', parentMessageId: '2', text: 'Message 11', summary: 'Summary for Message 11' },
+ ];
+
+ it('should return ordered messages from a branched array based on parentMessageId', () => {
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: unorderedBranchedMessages,
+ parentMessageId: '10',
+ summary: true,
+ });
+ expect(result).toEqual([
+ { id: '1', parentMessageId: null, text: 'Message 1' },
+ { id: '3', parentMessageId: '1', text: 'Message 3' },
+ { id: '5', parentMessageId: '3', text: 'Message 5' },
+ { id: '7', parentMessageId: '5', text: 'Message 7' },
+ { id: '10', parentMessageId: '7', text: 'Message 10' },
+ ]);
+ });
+
+ it('should return an empty array if no messages are provided', () => {
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: [],
+ parentMessageId: '3',
+ });
+ expect(result).toEqual([]);
+ });
+
+ it('should map over the ordered messages if mapMethod is provided', () => {
+ const mapMethod = (msg) => msg.text;
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: unorderedMessages,
+ parentMessageId: '3',
+ mapMethod,
+ });
+ expect(result).toEqual(['Message 1', 'Message 2', 'Message 3']);
+ });
+
+ let unorderedMessagesWithSummary = [
+ { id: '4', parentMessageId: '3', text: 'Message 4' },
+ { id: '2', parentMessageId: '1', text: 'Message 2', summary: 'Summary for Message 2' },
+ { id: '3', parentMessageId: '2', text: 'Message 3', summary: 'Summary for Message 3' },
+ { id: '1', parentMessageId: null, text: 'Message 1' },
+ ];
+
+ it('should start with the message that has a summary property and continue until the specified parentMessageId', () => {
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: unorderedMessagesWithSummary,
+ parentMessageId: '4',
+ summary: true,
+ });
+ expect(result).toEqual([
+ {
+ id: '3',
+ parentMessageId: '2',
+ role: 'system',
+ text: 'Message 3',
+ content: [{ type: 'text', text: 'Summary for Message 3' }],
+ summary: 'Summary for Message 3',
+ },
+ { id: '4', parentMessageId: '3', text: 'Message 4' },
+ ]);
+ });
+
+ it('should handle multiple summaries and return the branch from the latest to the parentMessageId', () => {
+ unorderedMessagesWithSummary = [
+ { id: '5', parentMessageId: '4', text: 'Message 5' },
+ { id: '2', parentMessageId: '1', text: 'Message 2', summary: 'Summary for Message 2' },
+ { id: '3', parentMessageId: '2', text: 'Message 3', summary: 'Summary for Message 3' },
+ { id: '4', parentMessageId: '3', text: 'Message 4', summary: 'Summary for Message 4' },
+ { id: '1', parentMessageId: null, text: 'Message 1' },
+ ];
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: unorderedMessagesWithSummary,
+ parentMessageId: '5',
+ summary: true,
+ });
+ expect(result).toEqual([
+ {
+ id: '4',
+ parentMessageId: '3',
+ role: 'system',
+ 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 handle summary at root edge case and continue until the parentMessageId', () => {
+ unorderedMessagesWithSummary = [
+ { id: '5', parentMessageId: '4', text: 'Message 5' },
+ { id: '1', parentMessageId: null, text: 'Message 1', summary: 'Summary for Message 1' },
+ { id: '4', parentMessageId: '3', text: 'Message 4', summary: 'Summary for Message 4' },
+ { id: '2', parentMessageId: '1', text: 'Message 2', summary: 'Summary for Message 2' },
+ { id: '3', parentMessageId: '2', text: 'Message 3', summary: 'Summary for Message 3' },
+ ];
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: unorderedMessagesWithSummary,
+ parentMessageId: '5',
+ summary: true,
+ });
+ expect(result).toEqual([
+ {
+ id: '4',
+ parentMessageId: '3',
+ role: 'system',
+ 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', () => {
+ test('sendMessage should return a response message', async () => {
+ const expectedResult = expect.objectContaining({
+ sender: TestClient.sender,
+ text: expect.any(String),
+ isCreatedByUser: false,
+ messageId: expect.any(String),
+ parentMessageId: expect.any(String),
+ conversationId: expect.any(String),
+ });
+
+ const response = await TestClient.sendMessage(userMessage);
+ parentMessageId = response.messageId;
+ conversationId = response.conversationId;
+ expect(response).toEqual(expectedResult);
+ });
+
+ test('should replace responseMessageId with new UUID when isRegenerate is true and messageId ends with underscore', async () => {
+ const mockCrypto = require('crypto');
+ const newUUID = 'new-uuid-1234';
+ jest.spyOn(mockCrypto, 'randomUUID').mockReturnValue(newUUID);
+
+ const opts = {
+ isRegenerate: true,
+ responseMessageId: 'existing-message-id_',
+ };
+
+ await TestClient.setMessageOptions(opts);
+
+ expect(TestClient.responseMessageId).toBe(newUUID);
+ expect(TestClient.responseMessageId).not.toBe('existing-message-id_');
+
+ mockCrypto.randomUUID.mockRestore();
+ });
+
+ test('should not replace responseMessageId when isRegenerate is false', async () => {
+ const opts = {
+ isRegenerate: false,
+ responseMessageId: 'existing-message-id_',
+ };
+
+ await TestClient.setMessageOptions(opts);
+
+ expect(TestClient.responseMessageId).toBe('existing-message-id_');
+ });
+
+ test('should not replace responseMessageId when it does not end with underscore', async () => {
+ const opts = {
+ isRegenerate: true,
+ responseMessageId: 'existing-message-id',
+ };
+
+ await TestClient.setMessageOptions(opts);
+
+ expect(TestClient.responseMessageId).toBe('existing-message-id');
+ });
+
+ test('sendMessage should work with provided conversationId and parentMessageId', async () => {
+ const userMessage = 'Second message in the conversation';
+ const opts = {
+ conversationId,
+ parentMessageId,
+ getReqData: jest.fn(),
+ onStart: jest.fn(),
+ };
+
+ const expectedResult = expect.objectContaining({
+ sender: TestClient.sender,
+ text: expect.any(String),
+ isCreatedByUser: false,
+ messageId: expect.any(String),
+ parentMessageId: expect.any(String),
+ conversationId: opts.conversationId,
+ });
+
+ const response = await TestClient.sendMessage(userMessage, opts);
+ parentMessageId = response.messageId;
+ expect(response.conversationId).toEqual(conversationId);
+ expect(response).toEqual(expectedResult);
+ expect(opts.getReqData).toHaveBeenCalled();
+ expect(opts.onStart).toHaveBeenCalled();
+ expect(TestClient.getBuildMessagesOptions).toHaveBeenCalled();
+ expect(TestClient.getSaveOptions).toHaveBeenCalled();
+ });
+
+ test('should return chat history', async () => {
+ TestClient = initializeFakeClient(apiKey, options, messageHistory);
+ const chatMessages = await TestClient.loadHistory(conversationId, '2');
+ expect(TestClient.currentMessages).toHaveLength(2);
+ expect(chatMessages[0].text).toEqual('Hello');
+
+ const chatMessages2 = await TestClient.loadHistory(conversationId, '3');
+ expect(TestClient.currentMessages).toHaveLength(3);
+ expect(chatMessages2[chatMessages2.length - 1].text).toEqual("What's up");
+ });
+
+ test('loadHistory should scope database reads to the current user', async () => {
+ const user = 'user-123';
+ TestClient = new FakeClient(apiKey, options);
+ TestClient.user = user;
+ getMessages.mockResolvedValueOnce([
+ {
+ role: 'user',
+ isCreatedByUser: true,
+ text: 'Hello',
+ messageId: '1',
+ conversationId,
+ },
+ ]);
+
+ const chatMessages = await TestClient.loadHistory(conversationId, '1');
+
+ expect(getMessages).toHaveBeenCalledWith({ conversationId, user });
+ expect(chatMessages).toHaveLength(1);
+ expect(chatMessages[0].text).toBe('Hello');
+ });
+
+ /* Most of the new sendMessage logic revolving around edited/continued AI messages
+ * can be summarized by the following test. The condition will load the entire history up to
+ * the message that is being edited, which will trigger the AI API to 'continue' the response.
+ * The 'userMessage' is only passed by convention and is not necessary for the generation.
+ */
+ it('should not push userMessage to currentMessages when isEdited is true and vice versa', async () => {
+ const overrideParentMessageId = 'user-message-id';
+ const responseMessageId = 'response-message-id';
+ const newHistory = messageHistory.slice();
+ newHistory.push({
+ role: 'assistant',
+ isCreatedByUser: false,
+ text: 'test message',
+ messageId: responseMessageId,
+ parentMessageId: '3',
+ });
+
+ TestClient = initializeFakeClient(apiKey, options, newHistory);
+ const sendMessageOptions = {
+ isEdited: true,
+ overrideParentMessageId,
+ parentMessageId: '3',
+ responseMessageId,
+ };
+
+ await TestClient.sendMessage('test message', sendMessageOptions);
+ const currentMessages = TestClient.currentMessages;
+ expect(currentMessages[currentMessages.length - 1].messageId).not.toEqual(
+ overrideParentMessageId,
+ );
+
+ // Test the opposite case
+ sendMessageOptions.isEdited = false;
+ await TestClient.sendMessage('test message', sendMessageOptions);
+ const currentMessages2 = TestClient.currentMessages;
+ expect(currentMessages2[currentMessages2.length - 1].messageId).toEqual(
+ overrideParentMessageId,
+ );
+ });
+
+ test('setOptions is called with the correct arguments only when replaceOptions is set to true', async () => {
+ TestClient.setOptions = jest.fn();
+ const opts = { conversationId: '123', parentMessageId: '456', replaceOptions: true };
+ await TestClient.sendMessage('Hello, world!', opts);
+ expect(TestClient.setOptions).toHaveBeenCalledWith(opts);
+ TestClient.setOptions.mockClear();
+ });
+
+ test('loadHistory is called with the correct arguments', async () => {
+ const opts = { conversationId: '123', parentMessageId: '456' };
+ await TestClient.sendMessage('Hello, world!', opts);
+ expect(TestClient.loadHistory).toHaveBeenCalledWith(
+ opts.conversationId,
+ opts.parentMessageId,
+ );
+ });
+
+ test('getReqData is called with the correct arguments', async () => {
+ const getReqData = jest.fn();
+ const opts = { getReqData };
+ const response = await TestClient.sendMessage('Hello, world!', opts);
+ expect(getReqData).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userMessage: expect.objectContaining({ text: 'Hello, world!' }),
+ conversationId: response.conversationId,
+ responseMessageId: response.messageId,
+ }),
+ );
+ });
+
+ test('onStart is called with the correct arguments', async () => {
+ const onStart = jest.fn();
+ const opts = { onStart };
+ await TestClient.sendMessage('Hello, world!', opts);
+
+ expect(onStart).toHaveBeenCalledWith(
+ expect.objectContaining({ text: 'Hello, world!' }),
+ expect.any(String),
+ /** `isNewConvo` */
+ true,
+ );
+ });
+
+ test('saveMessageToDatabase is called with the correct arguments', async () => {
+ const saveOptions = TestClient.getSaveOptions();
+ const user = {};
+ const opts = { user };
+ const saveSpy = jest.spyOn(TestClient, 'saveMessageToDatabase');
+ await TestClient.sendMessage('Hello, world!', opts);
+ expect(saveSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sender: expect.any(String),
+ text: expect.any(String),
+ isCreatedByUser: expect.any(Boolean),
+ messageId: expect.any(String),
+ parentMessageId: expect.any(String),
+ conversationId: expect.any(String),
+ }),
+ saveOptions,
+ user,
+ );
+ });
+
+ test('should handle existing conversation when getConvo retrieves one', async () => {
+ const existingConvo = {
+ conversationId: 'existing-convo-id',
+ endpoint: 'openai',
+ endpointType: 'openai',
+ model: 'gpt-3.5-turbo',
+ messages: [
+ { role: 'user', content: 'Existing message 1' },
+ { role: 'assistant', content: 'Existing response 1' },
+ ],
+ temperature: 1,
+ };
+
+ const { temperature: _temp, ...newConvo } = existingConvo;
+
+ const user = {
+ id: 'user-id',
+ };
+
+ getConvo.mockResolvedValue(existingConvo);
+ saveConvo.mockResolvedValue(newConvo);
+
+ TestClient = initializeFakeClient(
+ apiKey,
+ {
+ ...options,
+ req: {
+ user,
+ },
+ },
+ [],
+ );
+
+ const saveSpy = jest.spyOn(TestClient, 'saveMessageToDatabase');
+
+ const newMessage = 'New message in existing conversation';
+ const response = await TestClient.sendMessage(newMessage, {
+ user,
+ conversationId: existingConvo.conversationId,
+ });
+
+ expect(getConvo).toHaveBeenCalledWith(user.id, existingConvo.conversationId);
+ expect(TestClient.conversationId).toBe(existingConvo.conversationId);
+ expect(response.conversationId).toBe(existingConvo.conversationId);
+ expect(TestClient.fetchedConvo).toBe(true);
+
+ expect(saveSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ conversationId: existingConvo.conversationId,
+ text: newMessage,
+ }),
+ expect.any(Object),
+ expect.any(Object),
+ );
+
+ expect(saveConvo).toHaveBeenCalledTimes(2);
+ expect(saveConvo).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.objectContaining({
+ conversationId: existingConvo.conversationId,
+ }),
+ expect.objectContaining({
+ context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveConvo',
+ unsetFields: {
+ temperature: 1,
+ },
+ }),
+ );
+
+ await TestClient.sendMessage('Another message', {
+ conversationId: existingConvo.conversationId,
+ });
+ expect(getConvo).toHaveBeenCalledTimes(1);
+ });
+
+ test('should correctly handle existing conversation and unset fields appropriately', async () => {
+ const existingConvo = {
+ conversationId: 'existing-convo-id',
+ endpoint: 'openai',
+ endpointType: 'openai',
+ model: 'gpt-3.5-turbo',
+ messages: [
+ { role: 'user', content: 'Existing message 1' },
+ { role: 'assistant', content: 'Existing response 1' },
+ ],
+ title: 'Existing Conversation',
+ someExistingField: 'existingValue',
+ anotherExistingField: 'anotherValue',
+ temperature: 0.7,
+ modelLabel: 'GPT-3.5',
+ };
+
+ getConvo.mockResolvedValue(existingConvo);
+ saveConvo.mockResolvedValue(existingConvo);
+
+ TestClient = initializeFakeClient(
+ apiKey,
+ {
+ ...options,
+ modelOptions: {
+ model: 'gpt-4',
+ temperature: 0.5,
+ },
+ },
+ [],
+ );
+
+ const newMessage = 'New message in existing conversation';
+ await TestClient.sendMessage(newMessage, {
+ conversationId: existingConvo.conversationId,
+ });
+
+ expect(saveConvo).toHaveBeenCalledTimes(2);
+
+ const saveConvoCall = saveConvo.mock.calls[0];
+ const [, savedFields, saveOptions] = saveConvoCall;
+
+ // Instead of checking all excludedKeys, we'll just check specific fields
+ // that we know should be excluded
+ expect(savedFields).not.toHaveProperty('messages');
+ expect(savedFields).not.toHaveProperty('title');
+
+ // Only check that someExistingField is in unsetFields
+ expect(saveOptions.unsetFields).toHaveProperty('someExistingField', 1);
+
+ // Mock saveConvo to return the expected fields
+ saveConvo.mockImplementation((req, fields) => {
+ return Promise.resolve({
+ ...fields,
+ endpoint: 'openai',
+ endpointType: 'openai',
+ model: 'gpt-4',
+ temperature: 0.5,
+ });
+ });
+
+ // Only check the conversationId since that's the only field we can be sure about
+ expect(savedFields).toHaveProperty('conversationId', 'existing-convo-id');
+
+ expect(TestClient.fetchedConvo).toBe(true);
+
+ await TestClient.sendMessage('Another message', {
+ conversationId: existingConvo.conversationId,
+ });
+
+ expect(getConvo).toHaveBeenCalledTimes(1);
+
+ const secondSaveConvoCall = saveConvo.mock.calls[1];
+ expect(secondSaveConvoCall[2]).toHaveProperty('unsetFields', {});
+ });
+
+ test('sendCompletion is called with the correct arguments', async () => {
+ const payload = {}; // Mock payload
+ TestClient.buildMessages.mockReturnValue({ prompt: payload, tokenCountMap: null });
+ const opts = {};
+ await TestClient.sendMessage('Hello, world!', opts);
+ expect(TestClient.sendCompletion).toHaveBeenCalledWith(payload, opts);
+ });
+
+ test('getTokenCount for response is called with the correct arguments', async () => {
+ const tokenCountMap = {}; // Mock tokenCountMap
+ TestClient.buildMessages.mockReturnValue({ prompt: [], tokenCountMap });
+ TestClient.getTokenCountForResponse = jest.fn();
+ const response = await TestClient.sendMessage('Hello, world!', {});
+ expect(TestClient.getTokenCountForResponse).toHaveBeenCalledWith(response);
+ });
+
+ test('returns an object with the correct shape', async () => {
+ const response = await TestClient.sendMessage('Hello, world!', {});
+ expect(response).toEqual(
+ expect.objectContaining({
+ sender: expect.any(String),
+ text: expect.any(String),
+ isCreatedByUser: expect.any(Boolean),
+ messageId: expect.any(String),
+ parentMessageId: expect.any(String),
+ conversationId: expect.any(String),
+ }),
+ );
+ });
+
+ 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('saveMessageToDatabase reuses conversation resolved on the request', async () => {
+ const existingConvo = {
+ conversationId: 'cached-convo-id',
+ endpoint: 'openai',
+ endpointType: 'openai',
+ temperature: 0.7,
+ };
+ const user = { id: 'user-id' };
+ const req = { user, resolvedConversation: existingConvo };
+
+ getConvo.mockClear();
+ saveMessage.mockResolvedValue({ messageId: 'msg-1' });
+ saveConvo.mockResolvedValue(existingConvo);
+
+ TestClient = initializeFakeClient(apiKey, { ...options, endpoint: 'openai', req }, []);
+
+ await TestClient.saveMessageToDatabase(
+ {
+ messageId: 'msg-1',
+ conversationId: existingConvo.conversationId,
+ isCreatedByUser: true,
+ text: 'hi',
+ },
+ { endpoint: 'openai' },
+ user,
+ );
+
+ expect(getConvo).not.toHaveBeenCalled();
+ expect(req).not.toHaveProperty('resolvedConversation');
+ expect(TestClient.fetchedConvo).toBe(true);
+ expect(saveConvo).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.objectContaining({ conversationId: existingConvo.conversationId }),
+ expect.objectContaining({
+ unsetFields: expect.objectContaining({ temperature: 1 }),
+ }),
+ );
+ });
+
+ test('userMessagePromise is awaited before saving response message', async () => {
+ // Mock the saveMessageToDatabase method
+ TestClient.saveMessageToDatabase = jest.fn().mockImplementation(() => {
+ return new Promise((resolve) => setTimeout(resolve, 100)); // Simulate a delay
+ });
+
+ // Send a message
+ const messagePromise = TestClient.sendMessage('Hello, world!');
+
+ // Wait a short time to ensure the user message save has started
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ // Check that saveMessageToDatabase has been called once (for the user message)
+ expect(TestClient.saveMessageToDatabase).toHaveBeenCalledTimes(1);
+
+ // Wait for the message to be fully processed
+ await messagePromise;
+
+ // Check that saveMessageToDatabase has been called twice (once for user message, once for response)
+ expect(TestClient.saveMessageToDatabase).toHaveBeenCalledTimes(2);
+
+ // Check the order of calls
+ const calls = TestClient.saveMessageToDatabase.mock.calls;
+ expect(calls[0][0].isCreatedByUser).toBe(true); // First call should be for user message
+ expect(calls[1][0].isCreatedByUser).toBe(false); // Second call should be for response message
+ });
+ });
+
+ describe('recordTokenUsage model assignment', () => {
+ test('should pass this.model to recordTokenUsage, not the agent ID from responseMessage.model', async () => {
+ const actualModel = 'claude-opus-4-5';
+ const agentId = 'agent_p5Z_IU6EIxBoqn1BoqLBp';
+
+ TestClient.model = actualModel;
+ TestClient.options.endpoint = 'agents';
+ TestClient.options.agent = { id: agentId };
+
+ TestClient.getTokenCountForResponse = jest.fn().mockReturnValue(50);
+ TestClient.recordTokenUsage = jest.fn().mockResolvedValue(undefined);
+ TestClient.buildMessages.mockReturnValue({
+ prompt: [],
+ tokenCountMap: { res: 50 },
+ });
+
+ await TestClient.sendMessage('Hello', {});
+
+ expect(TestClient.recordTokenUsage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ model: actualModel,
+ }),
+ );
+
+ const callArgs = TestClient.recordTokenUsage.mock.calls[0][0];
+ expect(callArgs.model).not.toBe(agentId);
+ });
+
+ test('should pass this.model even when this.model differs from modelOptions.model', async () => {
+ const instanceModel = 'gpt-4o';
+ TestClient.model = instanceModel;
+ TestClient.modelOptions = { model: 'gpt-4o-mini' };
+
+ TestClient.getTokenCountForResponse = jest.fn().mockReturnValue(50);
+ TestClient.recordTokenUsage = jest.fn().mockResolvedValue(undefined);
+ TestClient.buildMessages.mockReturnValue({
+ prompt: [],
+ tokenCountMap: { res: 50 },
+ });
+
+ await TestClient.sendMessage('Hello', {});
+
+ expect(TestClient.recordTokenUsage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ model: instanceModel,
+ }),
+ );
+ });
+ });
+
+ describe('getMessagesWithinTokenLimit with instructions', () => {
+ test('should always include instructions when present', async () => {
+ TestClient.maxContextTokens = 50;
+ const instructions = {
+ role: 'system',
+ content: 'System instructions',
+ tokenCount: 20,
+ };
+
+ const messages = [
+ instructions,
+ { role: 'user', content: 'Hello', tokenCount: 10 },
+ { role: 'assistant', content: 'Hi there', tokenCount: 15 },
+ ];
+
+ const result = await TestClient.getMessagesWithinTokenLimit({
+ messages,
+ instructions,
+ });
+
+ expect(result.context[0]).toBe(instructions);
+ expect(result.remainingContextTokens).toBe(2);
+ });
+
+ test('should handle case when messages exceed limit but instructions must be preserved', async () => {
+ TestClient.maxContextTokens = 30;
+ const instructions = {
+ role: 'system',
+ content: 'System instructions',
+ tokenCount: 20,
+ };
+
+ const messages = [
+ instructions,
+ { role: 'user', content: 'Hello', tokenCount: 10 },
+ { role: 'assistant', content: 'Hi there', tokenCount: 15 },
+ ];
+
+ const result = await TestClient.getMessagesWithinTokenLimit({
+ messages,
+ instructions,
+ });
+
+ // Should only include instructions and the last message that fits
+ expect(result.context).toHaveLength(1);
+ expect(result.context[0].content).toBe(instructions.content);
+ expect(result.messagesToRefine).toHaveLength(2);
+ expect(result.remainingContextTokens).toBe(7); // 30 - 20 - 3 (assistant label)
+ });
+
+ test('should work correctly without instructions (1/2)', async () => {
+ TestClient.maxContextTokens = 50;
+ const messages = [
+ { role: 'user', content: 'Hello', tokenCount: 10 },
+ { role: 'assistant', content: 'Hi there', tokenCount: 15 },
+ ];
+
+ const result = await TestClient.getMessagesWithinTokenLimit({
+ messages,
+ });
+
+ expect(result.context).toHaveLength(2);
+ expect(result.remainingContextTokens).toBe(22); // 50 - 10 - 15 - 3(assistant label)
+ expect(result.messagesToRefine).toHaveLength(0);
+ });
+
+ test('should work correctly without instructions (2/2)', async () => {
+ TestClient.maxContextTokens = 30;
+ const messages = [
+ { role: 'user', content: 'Hello', tokenCount: 10 },
+ { role: 'assistant', content: 'Hi there', tokenCount: 20 },
+ ];
+
+ const result = await TestClient.getMessagesWithinTokenLimit({
+ messages,
+ });
+
+ expect(result.context).toHaveLength(1);
+ expect(result.remainingContextTokens).toBe(7);
+ expect(result.messagesToRefine).toHaveLength(1);
+ });
+
+ test('should handle case when only instructions fit within limit', async () => {
+ TestClient.maxContextTokens = 25;
+ const instructions = {
+ role: 'system',
+ content: 'System instructions',
+ tokenCount: 20,
+ };
+
+ const messages = [
+ instructions,
+ { role: 'user', content: 'Hello', tokenCount: 10 },
+ { role: 'assistant', content: 'Hi there', tokenCount: 15 },
+ ];
+
+ const result = await TestClient.getMessagesWithinTokenLimit({
+ messages,
+ instructions,
+ });
+
+ expect(result.context).toHaveLength(1);
+ expect(result.context[0]).toBe(instructions);
+ expect(result.messagesToRefine).toHaveLength(2);
+ expect(result.remainingContextTokens).toBe(2); // 25 - 20 - 3(assistant label)
+ });
+ });
+
+ describe('sendMessage file population', () => {
+ const attachment = {
+ file_id: 'file-abc',
+ filename: 'image.png',
+ filepath: '/uploads/image.png',
+ type: 'image/png',
+ bytes: 1024,
+ object: 'file',
+ user: 'user-1',
+ embedded: false,
+ usage: 0,
+ text: 'large ocr blob that should be stripped',
+ _id: 'mongo-id-1',
+ };
+
+ beforeEach(() => {
+ TestClient.options.req = { body: { files: [{ file_id: 'file-abc' }] } };
+ TestClient.options.attachments = [attachment];
+ });
+
+ test('populates userMessage.files before saveMessageToDatabase is called', async () => {
+ TestClient.saveMessageToDatabase = jest.fn().mockImplementation((msg) => {
+ return Promise.resolve({ message: msg });
+ });
+
+ await TestClient.sendMessage('Hello');
+
+ const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
+ ([msg]) => msg.isCreatedByUser,
+ );
+ expect(userSave).toBeDefined();
+ expect(userSave[0].files).toBeDefined();
+ expect(userSave[0].files).toHaveLength(1);
+ expect(userSave[0].files[0].file_id).toBe('file-abc');
+ });
+
+ test('strips text and _id from files before saving', async () => {
+ TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
+
+ await TestClient.sendMessage('Hello');
+
+ const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
+ ([msg]) => msg.isCreatedByUser,
+ );
+ expect(userSave[0].files[0].text).toBeUndefined();
+ expect(userSave[0].files[0]._id).toBeUndefined();
+ expect(userSave[0].files[0].filename).toBe('image.png');
+ });
+
+ test('deletes image_urls from userMessage when files are present', async () => {
+ TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
+ TestClient.options.attachments = [
+ { ...attachment, image_urls: ['data:image/png;base64,...'] },
+ ];
+
+ await TestClient.sendMessage('Hello');
+
+ const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
+ ([msg]) => msg.isCreatedByUser,
+ );
+ expect(userSave[0].image_urls).toBeUndefined();
+ });
+
+ test('does not set files when no attachments match request file IDs', async () => {
+ TestClient.options.req = { body: { files: [{ file_id: 'file-nomatch' }] } };
+ TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
+
+ await TestClient.sendMessage('Hello');
+
+ const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
+ ([msg]) => msg.isCreatedByUser,
+ );
+ expect(userSave[0].files).toBeUndefined();
+ });
+
+ test('skips file population when attachments is not an array (Promise case)', async () => {
+ TestClient.options.attachments = Promise.resolve([attachment]);
+ TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
+
+ await TestClient.sendMessage('Hello');
+
+ const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
+ ([msg]) => msg.isCreatedByUser,
+ );
+ expect(userSave[0].files).toBeUndefined();
+ });
+
+ test('skips file population when skipSaveUserMessage is true', async () => {
+ TestClient.skipSaveUserMessage = true;
+ TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
+
+ await TestClient.sendMessage('Hello');
+
+ const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
+ ([msg]) => msg?.isCreatedByUser,
+ );
+ expect(userSave).toBeUndefined();
+ });
+
+ test('ignores file_id: undefined entries in req.body.files (no set poisoning)', async () => {
+ TestClient.options.req = {
+ body: { files: [{ file_id: undefined }, { file_id: 'file-abc' }] },
+ };
+ TestClient.options.attachments = [
+ { ...attachment, file_id: undefined },
+ { ...attachment, file_id: 'file-abc' },
+ ];
+ TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
+
+ await TestClient.sendMessage('Hello');
+
+ const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
+ ([msg]) => msg.isCreatedByUser,
+ );
+ expect(userSave[0].files).toHaveLength(1);
+ expect(userSave[0].files[0].file_id).toBe('file-abc');
+ });
+ });
+
+ describe('addPreviousAttachments authorization', () => {
+ const ownerFile = {
+ file_id: 'owner-file',
+ filename: 'owner.txt',
+ filepath: '/uploads/owner.txt',
+ source: 'local',
+ type: 'text/plain',
+ bytes: 100,
+ object: 'file',
+ user: 'user-1',
+ embedded: false,
+ usage: 0,
+ text: 'authorized owner text',
+ _id: 'owner-mongo-id',
+ metadata: {
+ codeEnvRef: {
+ kind: 'user',
+ id: 'user-1',
+ storage_session_id: 'owner-session',
+ file_id: 'owner-code-file',
+ },
+ },
+ };
+
+ beforeEach(() => {
+ getFiles.mockReset();
+ TestClient.options.resendFiles = true;
+ TestClient.options.attachments = undefined;
+ TestClient.options.req = {
+ user: {
+ id: 'user-1',
+ tenantId: 'tenant-a',
+ },
+ };
+ TestClient.addFileContextToMessage = jest.fn(async (message, files) => {
+ const text = files
+ .map((file) => file.text)
+ .filter(Boolean)
+ .join('\n');
+ if (text) {
+ message.fileContext = text;
+ }
+ });
+ TestClient.processAttachments = jest.fn(async (_message, files) => files);
+ TestClient.checkVisionRequest = jest.fn();
+ });
+
+ test('rehydrates historical file refs from owner-scoped DB rows only', async () => {
+ getFiles.mockResolvedValueOnce([ownerFile]);
+
+ const [message] = await TestClient.addPreviousAttachments([
+ {
+ messageId: 'msg-1',
+ text: 'Use the attachment',
+ files: [
+ {
+ file_id: 'owner-file',
+ filename: 'attacker-controlled-owner-name.txt',
+ filepath: '/forged/owner.txt',
+ text: 'forged owner text',
+ },
+ {
+ file_id: 'victim-file',
+ filename: 'victim.txt',
+ filepath: '/victim/private.txt',
+ text: 'victim private text',
+ },
+ ],
+ attachments: [
+ {
+ file_id: 'victim-file',
+ filename: 'victim-output.csv',
+ text: 'victim output text',
+ },
+ ],
+ fileContext: 'stale victim private text',
+ },
+ ]);
+
+ expect(getFiles).toHaveBeenCalledWith(
+ {
+ file_id: { $in: ['owner-file', 'victim-file'] },
+ user: 'user-1',
+ tenantId: 'tenant-a',
+ },
+ {},
+ {},
+ );
+ expect(TestClient.addFileContextToMessage).toHaveBeenCalledWith(message, [ownerFile]);
+ expect(TestClient.processAttachments).toHaveBeenCalledWith(message, [ownerFile]);
+ expect(message.fileContext).toBe('authorized owner text');
+ expect(message.files).toEqual([
+ expect.objectContaining({
+ file_id: 'owner-file',
+ filename: 'owner.txt',
+ filepath: '/uploads/owner.txt',
+ source: 'local',
+ metadata: ownerFile.metadata,
+ }),
+ ]);
+ expect(message.files[0].text).toBeUndefined();
+ expect(message.files[0]._id).toBeUndefined();
+ expect(message.attachments).toBeUndefined();
+ expect(JSON.stringify(message)).not.toContain('victim');
+ expect(JSON.stringify(message)).not.toContain('forged owner text');
+ });
+
+ test('strips historical file context when no authenticated owner scope is available', async () => {
+ TestClient.options.req = {};
+
+ const [message] = await TestClient.addPreviousAttachments([
+ {
+ messageId: 'msg-2',
+ files: [{ file_id: 'victim-file', filename: 'victim.txt' }],
+ fileContext: 'stale victim private text',
+ },
+ ]);
+
+ expect(getFiles).not.toHaveBeenCalled();
+ expect(message.files).toBeUndefined();
+ expect(message.fileContext).toBeUndefined();
+ });
+
+ test('preserves repeated owner-authorized historical file refs after the first context use', async () => {
+ getFiles.mockResolvedValueOnce([ownerFile]);
+
+ const [firstMessage, secondMessage] = await TestClient.addPreviousAttachments([
+ {
+ messageId: 'msg-repeat-1',
+ files: [{ file_id: 'owner-file', filename: 'first-forged.txt' }],
+ },
+ {
+ messageId: 'msg-repeat-2',
+ files: [{ file_id: 'owner-file', filename: 'second-forged.txt' }],
+ },
+ ]);
+
+ expect(getFiles).toHaveBeenCalledTimes(1);
+ expect(getFiles).toHaveBeenCalledWith(
+ {
+ file_id: { $in: ['owner-file'] },
+ user: 'user-1',
+ tenantId: 'tenant-a',
+ },
+ {},
+ {},
+ );
+ expect(TestClient.addFileContextToMessage).toHaveBeenCalledTimes(1);
+ expect(TestClient.addFileContextToMessage).toHaveBeenCalledWith(firstMessage, [ownerFile]);
+ expect(secondMessage.fileContext).toBeUndefined();
+ expect(firstMessage.files).toEqual([
+ expect.objectContaining({ file_id: 'owner-file', filename: 'owner.txt' }),
+ ]);
+ expect(secondMessage.files).toEqual([
+ expect.objectContaining({ file_id: 'owner-file', filename: 'owner.txt' }),
+ ]);
+ expect(JSON.stringify(secondMessage)).not.toContain('second-forged');
+ });
+
+ test('preserves download-only historical attachments without trusting file fields', async () => {
+ const [message] = await TestClient.addPreviousAttachments([
+ {
+ messageId: 'msg-download-only',
+ attachments: [
+ {
+ filename: 'report.csv',
+ filepath: '/api/files/code/download/session/file',
+ expiresAt: 123456,
+ conversationId: 'conversation-1',
+ messageId: 'assistant-message',
+ toolCallId: 'tool-call-1',
+ text: 'untrusted text should not survive',
+ source: 'forged-source',
+ metadata: { codeEnvRef: { id: 'victim' } },
+ },
+ ],
+ fileContext: 'stale context',
+ },
+ ]);
+
+ expect(getFiles).not.toHaveBeenCalled();
+ expect(message.fileContext).toBeUndefined();
+ expect(message.attachments).toEqual([
+ {
+ filename: 'report.csv',
+ filepath: '/api/files/code/download/session/file',
+ expiresAt: 123456,
+ conversationId: 'conversation-1',
+ messageId: 'assistant-message',
+ toolCallId: 'tool-call-1',
+ },
+ ]);
+ expect(JSON.stringify(message)).not.toContain('untrusted text');
+ expect(JSON.stringify(message)).not.toContain('forged-source');
+ expect(JSON.stringify(message)).not.toContain('victim');
+ });
+
+ test('merges safe per-message metadata onto authorized DB-backed attachments', async () => {
+ getFiles.mockResolvedValueOnce([ownerFile]);
+
+ const [message] = await TestClient.addPreviousAttachments([
+ {
+ messageId: 'msg-artifact',
+ attachments: [
+ {
+ file_id: 'owner-file',
+ filename: 'forged-artifact.csv',
+ filepath: '/forged/artifact.csv',
+ source: 'forged-source',
+ metadata: { codeEnvRef: { id: 'victim' } },
+ text: 'forged artifact text',
+ messageId: 'assistant-message',
+ toolCallId: 'tool-call-2',
+ },
+ ],
+ },
+ ]);
+
+ expect(message.attachments).toEqual([
+ expect.objectContaining({
+ file_id: 'owner-file',
+ filename: 'owner.txt',
+ filepath: '/uploads/owner.txt',
+ source: 'local',
+ metadata: ownerFile.metadata,
+ messageId: 'assistant-message',
+ toolCallId: 'tool-call-2',
+ }),
+ ]);
+ expect(message.attachments[0].text).toBeUndefined();
+ expect(message.attachments[0]._id).toBeUndefined();
+ expect(JSON.stringify(message)).not.toContain('forged-artifact');
+ expect(JSON.stringify(message)).not.toContain('forged artifact text');
+ });
+ });
+
+ describe('sendMessage quote references', () => {
+ // The blockquote merge itself lives in AgentClient.buildMessages / prependQuotes
+ // (covered by packages/api specs). BaseClient's job is to attach the normalized
+ // quotes onto the user message early and keep the stored text clean.
+ test('attaches normalized quotes before getReqData fires and keeps stored text clean', async () => {
+ TestClient.options.req = { body: { quotes: [' the selected text ', '', 42] } };
+ TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
+ let captured;
+ await TestClient.sendMessage('What does this mean?', {
+ getReqData: (data) => {
+ if (data.userMessage) {
+ captured = { text: data.userMessage.text, quotes: data.userMessage.quotes };
+ }
+ },
+ });
+
+ // Quotes are present (trimmed, non-strings dropped) at getReqData time, and
+ // the user text is never mutated by the merge.
+ expect(captured).toBeDefined();
+ expect(captured.quotes).toEqual(['the selected text']);
+ expect(captured.text).toBe('What does this mean?');
+
+ const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
+ ([msg]) => msg.isCreatedByUser,
+ );
+ expect(userSave[0].text).toBe('What does this mean?');
+ expect(userSave[0].quotes).toEqual(['the selected text']);
+ });
+
+ test('persists multiple quotes in order on the saved message', async () => {
+ TestClient.options.req = { body: { quotes: ['first excerpt', 'second excerpt'] } };
+ TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
+
+ await TestClient.sendMessage('Compare these');
+
+ const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
+ ([msg]) => msg.isCreatedByUser,
+ );
+ expect(userSave[0].text).toBe('Compare these');
+ expect(userSave[0].quotes).toEqual(['first excerpt', 'second excerpt']);
+ });
+
+ test('leaves the message untouched when no quotes are provided', async () => {
+ TestClient.options.req = { body: {} };
+ TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
+
+ await TestClient.sendMessage('Just a question');
+
+ const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
+ ([msg]) => msg.isCreatedByUser,
+ );
+ expect(userSave[0].text).toBe('Just a question');
+ expect(userSave[0].quotes).toBeUndefined();
+ });
+ });
+});
diff --git a/api/app/clients/specs/FakeClient.js b/api/app/clients/specs/FakeClient.js
new file mode 100644
index 0000000..d1d07a9
--- /dev/null
+++ b/api/app/clients/specs/FakeClient.js
@@ -0,0 +1,126 @@
+const { getModelMaxTokens } = require('@librechat/api');
+const BaseClient = require('../BaseClient');
+
+class FakeClient extends BaseClient {
+ constructor(apiKey, options = {}) {
+ super(apiKey, options);
+ this.sender = 'AI Assistant';
+ this.setOptions(options);
+ }
+ setOptions(options) {
+ if (this.options && !this.options.replaceOptions) {
+ this.options.modelOptions = {
+ ...this.options.modelOptions,
+ ...options.modelOptions,
+ };
+ delete options.modelOptions;
+ this.options = {
+ ...this.options,
+ ...options,
+ };
+ } else {
+ this.options = options;
+ }
+
+ if (this.options.openaiApiKey) {
+ this.apiKey = this.options.openaiApiKey;
+ }
+
+ const modelOptions = this.options.modelOptions || {};
+ if (!this.modelOptions) {
+ this.modelOptions = {
+ ...modelOptions,
+ model: modelOptions.model || 'gpt-3.5-turbo',
+ temperature:
+ typeof modelOptions.temperature === 'undefined' ? 0.8 : modelOptions.temperature,
+ top_p: typeof modelOptions.top_p === 'undefined' ? 1 : modelOptions.top_p,
+ presence_penalty:
+ typeof modelOptions.presence_penalty === 'undefined' ? 1 : modelOptions.presence_penalty,
+ stop: modelOptions.stop,
+ };
+ }
+
+ this.maxContextTokens =
+ this.options.maxContextTokens ?? getModelMaxTokens(this.modelOptions.model) ?? 4097;
+ }
+ buildMessages() {}
+ getTokenCount(str) {
+ return str.length;
+ }
+ getTokenCountForMessage(message) {
+ return message?.content?.length || message.length;
+ }
+}
+
+const initializeFakeClient = (apiKey, options, fakeMessages) => {
+ let TestClient = new FakeClient(apiKey);
+ TestClient.options = options;
+ TestClient.abortController = { abort: jest.fn() };
+ TestClient.loadHistory = jest
+ .fn()
+ .mockImplementation((conversationId, parentMessageId = null) => {
+ if (!conversationId) {
+ TestClient.currentMessages = [];
+ return Promise.resolve([]);
+ }
+
+ const orderedMessages = TestClient.constructor.getMessagesForConversation({
+ messages: fakeMessages,
+ parentMessageId,
+ });
+
+ TestClient.currentMessages = orderedMessages;
+ return Promise.resolve(orderedMessages);
+ });
+
+ TestClient.getSaveOptions = jest.fn().mockImplementation(() => {
+ return {};
+ });
+
+ TestClient.getBuildMessagesOptions = jest.fn().mockImplementation(() => {
+ return {};
+ });
+
+ TestClient.sendCompletion = jest.fn(async () => {
+ return {
+ completion: 'Mock response text',
+ metadata: undefined,
+ };
+ });
+
+ TestClient.getCompletion = jest.fn().mockImplementation(async (..._args) => {
+ return {
+ choices: [
+ {
+ message: {
+ content: 'Mock response text',
+ },
+ },
+ ],
+ };
+ });
+
+ TestClient.buildMessages = jest.fn(async (messages, parentMessageId) => {
+ const orderedMessages = TestClient.constructor.getMessagesForConversation({
+ messages,
+ parentMessageId,
+ });
+ const formattedMessages = orderedMessages.map((message) => {
+ let { role: _role, sender, text } = message;
+ const role = _role ?? sender;
+ const content = text ?? '';
+ return {
+ role: role?.toLowerCase() === 'user' ? 'user' : 'assistant',
+ content,
+ };
+ });
+ return {
+ prompt: formattedMessages,
+ tokenCountMap: null, // Simplified for the mock
+ };
+ });
+
+ return TestClient;
+};
+
+module.exports = { FakeClient, initializeFakeClient };
diff --git a/api/app/clients/tools/index.js b/api/app/clients/tools/index.js
new file mode 100644
index 0000000..bb58e81
--- /dev/null
+++ b/api/app/clients/tools/index.js
@@ -0,0 +1,30 @@
+const manifest = require('./manifest');
+
+// Structured Tools
+const DALLE3 = require('./structured/DALLE3');
+const FluxAPI = require('./structured/FluxAPI');
+const OpenWeather = require('./structured/OpenWeather');
+const StructuredWolfram = require('./structured/Wolfram');
+const StructuredACS = require('./structured/AzureAISearch');
+const StructuredSD = require('./structured/StableDiffusion');
+const GoogleSearchAPI = require('./structured/GoogleSearch');
+const TraversaalSearch = require('./structured/TraversaalSearch');
+const createOpenAIImageTools = require('./structured/OpenAIImageTools');
+const TavilySearchResults = require('./structured/TavilySearchResults');
+const createGeminiImageTool = require('./structured/GeminiImageGen');
+
+module.exports = {
+ ...manifest,
+ // Structured Tools
+ DALLE3,
+ FluxAPI,
+ OpenWeather,
+ StructuredSD,
+ StructuredACS,
+ GoogleSearchAPI,
+ TraversaalSearch,
+ StructuredWolfram,
+ TavilySearchResults,
+ createOpenAIImageTools,
+ createGeminiImageTool,
+};
diff --git a/api/app/clients/tools/manifest.js b/api/app/clients/tools/manifest.js
new file mode 100644
index 0000000..0cf1eea
--- /dev/null
+++ b/api/app/clients/tools/manifest.js
@@ -0,0 +1,41 @@
+const availableTools = require('./manifest.json');
+
+/** @type {Record} */
+const manifestToolMap = {};
+
+/** @type {Array} */
+const toolkits = [];
+
+availableTools.forEach((tool) => {
+ manifestToolMap[tool.pluginKey] = tool;
+ if (tool.toolkit === true) {
+ toolkits.push(tool);
+ }
+});
+
+/**
+ * Whether a tool (string pluginKey, or an OpenAI function-tool object) is
+ * flagged `agentsOnly` in the manifest — usable only on the agents runtime
+ * (e.g. `ask_user_question`, which pauses a LangGraph run via `interrupt()`).
+ * The legacy assistants runtime executes tools with no run to pause and no
+ * resume surface, so these must be rejected before assistant create/update —
+ * the tools-dialog scoping alone doesn't stop a REST client or a stale saved
+ * payload from posting the tool string directly.
+ *
+ * @param {string | { function?: { name?: string } } | undefined} tool
+ * @returns {boolean}
+ */
+function isAgentsOnlyTool(tool) {
+ const name = typeof tool === 'string' ? tool : tool?.function?.name;
+ if (!name) {
+ return false;
+ }
+ return manifestToolMap[name]?.agentsOnly === true;
+}
+
+module.exports = {
+ toolkits,
+ availableTools,
+ manifestToolMap,
+ isAgentsOnlyTool,
+};
diff --git a/api/app/clients/tools/manifest.json b/api/app/clients/tools/manifest.json
new file mode 100644
index 0000000..a45fca7
--- /dev/null
+++ b/api/app/clients/tools/manifest.json
@@ -0,0 +1,177 @@
+[
+ {
+ "name": "Traversaal",
+ "pluginKey": "traversaal_search",
+ "description": "Traversaal is a robust search API tailored for LLM Agents. Get an API key here: https://api.traversaal.ai",
+ "icon": "https://traversaal.ai/favicon.ico",
+ "authConfig": [
+ {
+ "authField": "TRAVERSAAL_API_KEY",
+ "label": "Traversaal API Key",
+ "description": "Get your API key here: https://api.traversaal.ai "
+ }
+ ]
+ },
+ {
+ "name": "Google",
+ "pluginKey": "google",
+ "description": "Use Google Search to find information about the weather, news, sports, and more.",
+ "icon": "assets/google-search.svg",
+ "authConfig": [
+ {
+ "authField": "GOOGLE_CSE_ID",
+ "label": "Google CSE ID",
+ "description": "This is your Google Custom Search Engine ID. For instructions on how to obtain this, see Our Docs ."
+ },
+ {
+ "authField": "GOOGLE_SEARCH_API_KEY",
+ "label": "Google API Key",
+ "description": "This is your Google Custom Search API Key. For instructions on how to obtain this, see Our Docs ."
+ }
+ ]
+ },
+ {
+ "name": "OpenAI Image Tools",
+ "pluginKey": "image_gen_oai",
+ "toolkit": true,
+ "description": "Image Generation and Editing using OpenAI's latest state-of-the-art models",
+ "icon": "assets/image_gen_oai.png",
+ "authConfig": [
+ {
+ "authField": "IMAGE_GEN_OAI_API_KEY",
+ "label": "OpenAI Image Tools API Key",
+ "description": "Your OpenAI API Key for Image Generation and Editing"
+ }
+ ]
+ },
+ {
+ "name": "Wolfram",
+ "pluginKey": "wolfram",
+ "description": "Access computation, math, curated knowledge & real-time data through Wolfram|Alpha and Wolfram Language.",
+ "icon": "https://www.wolframcdn.com/images/icons/Wolfram.png",
+ "authConfig": [
+ {
+ "authField": "WOLFRAM_APP_ID",
+ "label": "Wolfram App ID",
+ "description": "An AppID must be supplied in all calls to the Wolfram|Alpha API. You can get one by registering at Wolfram|Alpha and going to the Developer Portal ."
+ }
+ ]
+ },
+ {
+ "name": "DALL-E-3",
+ "pluginKey": "dalle",
+ "description": "[DALL-E-3] Create realistic images and art from a description in natural language",
+ "icon": "assets/openai.svg",
+ "authConfig": [
+ {
+ "authField": "DALLE3_API_KEY||DALLE_API_KEY",
+ "label": "OpenAI API Key",
+ "description": "You can use DALL-E with your API Key from OpenAI."
+ }
+ ]
+ },
+ {
+ "name": "Tavily Search",
+ "pluginKey": "tavily_search_results_json",
+ "description": "Tavily Search is a robust search API tailored for LLM Agents. It seamlessly integrates with diverse data sources to ensure a superior, relevant search experience.",
+ "icon": "assets/tavily.svg",
+ "authConfig": [
+ {
+ "authField": "TAVILY_API_KEY",
+ "label": "Tavily API Key",
+ "description": "Get your API key here: https://app.tavily.com/"
+ }
+ ]
+ },
+ {
+ "name": "Calculator",
+ "pluginKey": "calculator",
+ "description": "Perform simple and complex mathematical calculations.",
+ "icon": "assets/calculator.svg",
+ "authConfig": []
+ },
+ {
+ "name": "Ask User",
+ "pluginKey": "ask_user_question",
+ "description": "Let the agent pause mid-run to ask you a clarifying question and wait for your answer.",
+ "agentsOnly": true,
+ "authConfig": []
+ },
+ {
+ "name": "Stable Diffusion",
+ "pluginKey": "stable-diffusion",
+ "description": "Generate photo-realistic images given any text input.",
+ "icon": "assets/stability-ai.svg",
+ "authConfig": [
+ {
+ "authField": "SD_WEBUI_URL",
+ "label": "Your Stable Diffusion WebUI API URL",
+ "description": "You need to provide the URL of your Stable Diffusion WebUI API. For instructions on how to obtain this, see Our Docs ."
+ }
+ ]
+ },
+ {
+ "name": "Azure AI Search",
+ "pluginKey": "azure-ai-search",
+ "description": "Use Azure AI Search to find information",
+ "icon": "assets/azure-ai-search.svg",
+ "authConfig": [
+ {
+ "authField": "AZURE_AI_SEARCH_SERVICE_ENDPOINT",
+ "label": "Azure AI Search Endpoint",
+ "description": "You need to provide your Endpoint for Azure AI Search."
+ },
+ {
+ "authField": "AZURE_AI_SEARCH_INDEX_NAME",
+ "label": "Azure AI Search Index Name",
+ "description": "You need to provide your Index Name for Azure AI Search."
+ },
+ {
+ "authField": "AZURE_AI_SEARCH_API_KEY",
+ "label": "Azure AI Search API Key",
+ "description": "You need to provide your API Key for Azure AI Search."
+ }
+ ]
+ },
+ {
+ "name": "OpenWeather",
+ "pluginKey": "open_weather",
+ "description": "Get weather forecasts and historical data from the OpenWeather API",
+ "icon": "assets/openweather.png",
+ "authConfig": [
+ {
+ "authField": "OPENWEATHER_API_KEY",
+ "label": "OpenWeather API Key",
+ "description": "Sign up at OpenWeather , then get your key at API keys ."
+ }
+ ]
+ },
+ {
+ "name": "Flux",
+ "pluginKey": "flux",
+ "description": "Generate images using text with the Flux API.",
+ "icon": "assets/bfl-ai.svg",
+ "isAuthRequired": "true",
+ "authConfig": [
+ {
+ "authField": "FLUX_API_KEY",
+ "label": "Your Flux API Key",
+ "description": "Provide your Flux API key from your user profile."
+ }
+ ]
+ },
+ {
+ "name": "Gemini Image Tools",
+ "pluginKey": "gemini_image_gen",
+ "description": "Generate high-quality images using Google's Gemini Image Models. Supports Gemini API or Vertex AI.",
+ "icon": "assets/gemini_image_gen.svg",
+ "authConfig": [
+ {
+ "authField": "GEMINI_API_KEY||GOOGLE_KEY||GOOGLE_SERVICE_KEY_FILE",
+ "label": "Gemini API Key (optional)",
+ "description": "Your Google Gemini API Key from Google AI Studio . Leave blank to use Vertex AI with a service account (GOOGLE_SERVICE_KEY_FILE or api/data/auth.json).",
+ "optional": true
+ }
+ ]
+ }
+]
diff --git a/api/app/clients/tools/manifest.spec.js b/api/app/clients/tools/manifest.spec.js
new file mode 100644
index 0000000..820c322
--- /dev/null
+++ b/api/app/clients/tools/manifest.spec.js
@@ -0,0 +1,22 @@
+const { manifestToolMap, isAgentsOnlyTool } = require('./manifest');
+
+describe('isAgentsOnlyTool', () => {
+ it('flags ask_user_question (agentsOnly in the real manifest) in both wire shapes', () => {
+ // Guard the data too: the whole assistants-rejection path keys on this flag.
+ expect(manifestToolMap['ask_user_question']?.agentsOnly).toBe(true);
+
+ expect(isAgentsOnlyTool('ask_user_question')).toBe(true);
+ expect(isAgentsOnlyTool({ type: 'function', function: { name: 'ask_user_question' } })).toBe(
+ true,
+ );
+ });
+
+ it('does not flag ordinary manifest tools, unknown tools, or malformed inputs', () => {
+ expect(isAgentsOnlyTool('calculator')).toBe(false);
+ expect(isAgentsOnlyTool('nonexistent_tool')).toBe(false);
+ expect(isAgentsOnlyTool({ type: 'function', function: { name: 'calculator' } })).toBe(false);
+ expect(isAgentsOnlyTool(undefined)).toBe(false);
+ expect(isAgentsOnlyTool({})).toBe(false);
+ expect(isAgentsOnlyTool({ type: 'code_interpreter' })).toBe(false);
+ });
+});
diff --git a/api/app/clients/tools/structured/AzureAISearch.js b/api/app/clients/tools/structured/AzureAISearch.js
new file mode 100644
index 0000000..0579614
--- /dev/null
+++ b/api/app/clients/tools/structured/AzureAISearch.js
@@ -0,0 +1,115 @@
+const { logger } = require('@librechat/data-schemas');
+const { Tool } = require('@librechat/agents/langchain/tools');
+const { SearchClient, AzureKeyCredential } = require('@azure/search-documents');
+
+const azureAISearchJsonSchema = {
+ type: 'object',
+ properties: {
+ query: {
+ type: 'string',
+ description: 'Search word or phrase to Azure AI Search',
+ },
+ },
+ required: ['query'],
+};
+
+class AzureAISearch extends Tool {
+ // Constants for default values
+ static DEFAULT_API_VERSION = '2023-11-01';
+ static DEFAULT_QUERY_TYPE = 'simple';
+ static DEFAULT_TOP = 5;
+
+ static get jsonSchema() {
+ return azureAISearchJsonSchema;
+ }
+
+ // Helper function for initializing properties
+ _initializeField(field, envVar, defaultValue) {
+ return field || process.env[envVar] || defaultValue;
+ }
+
+ constructor(fields = {}) {
+ super();
+ this.name = 'azure-ai-search';
+ this.description =
+ "Use the 'azure-ai-search' tool to retrieve search results relevant to your input";
+ /* Used to initialize the Tool without necessary variables. */
+ this.override = fields.override ?? false;
+
+ this.schema = azureAISearchJsonSchema;
+
+ // Initialize properties using helper function
+ this.serviceEndpoint = this._initializeField(
+ fields.AZURE_AI_SEARCH_SERVICE_ENDPOINT,
+ 'AZURE_AI_SEARCH_SERVICE_ENDPOINT',
+ );
+ this.indexName = this._initializeField(
+ fields.AZURE_AI_SEARCH_INDEX_NAME,
+ 'AZURE_AI_SEARCH_INDEX_NAME',
+ );
+ this.apiKey = this._initializeField(fields.AZURE_AI_SEARCH_API_KEY, 'AZURE_AI_SEARCH_API_KEY');
+ this.apiVersion = this._initializeField(
+ fields.AZURE_AI_SEARCH_API_VERSION,
+ 'AZURE_AI_SEARCH_API_VERSION',
+ AzureAISearch.DEFAULT_API_VERSION,
+ );
+ this.queryType = this._initializeField(
+ fields.AZURE_AI_SEARCH_SEARCH_OPTION_QUERY_TYPE,
+ 'AZURE_AI_SEARCH_SEARCH_OPTION_QUERY_TYPE',
+ AzureAISearch.DEFAULT_QUERY_TYPE,
+ );
+ this.top = this._initializeField(
+ fields.AZURE_AI_SEARCH_SEARCH_OPTION_TOP,
+ 'AZURE_AI_SEARCH_SEARCH_OPTION_TOP',
+ AzureAISearch.DEFAULT_TOP,
+ );
+ this.select = this._initializeField(
+ fields.AZURE_AI_SEARCH_SEARCH_OPTION_SELECT,
+ 'AZURE_AI_SEARCH_SEARCH_OPTION_SELECT',
+ );
+
+ // Check for required fields
+ if (!this.override && (!this.serviceEndpoint || !this.indexName || !this.apiKey)) {
+ throw new Error(
+ 'Missing AZURE_AI_SEARCH_SERVICE_ENDPOINT, AZURE_AI_SEARCH_INDEX_NAME, or AZURE_AI_SEARCH_API_KEY environment variable.',
+ );
+ }
+
+ if (this.override) {
+ return;
+ }
+
+ // Create SearchClient
+ this.client = new SearchClient(
+ this.serviceEndpoint,
+ this.indexName,
+ new AzureKeyCredential(this.apiKey),
+ { apiVersion: this.apiVersion },
+ );
+ }
+
+ // Improved error handling and logging
+ async _call(data) {
+ const { query } = data;
+ try {
+ const searchOption = {
+ queryType: this.queryType,
+ top: typeof this.top === 'string' ? Number(this.top) : this.top,
+ };
+ if (this.select) {
+ searchOption.select = this.select.split(',');
+ }
+ const searchResults = await this.client.search(query, searchOption);
+ const resultDocuments = [];
+ for await (const result of searchResults.results) {
+ resultDocuments.push(result.document);
+ }
+ return JSON.stringify(resultDocuments);
+ } catch (error) {
+ logger.error('Azure AI Search request failed', error);
+ return 'There was an error with Azure AI Search.';
+ }
+ }
+}
+
+module.exports = AzureAISearch;
diff --git a/api/app/clients/tools/structured/DALLE3.js b/api/app/clients/tools/structured/DALLE3.js
new file mode 100644
index 0000000..5bcd87a
--- /dev/null
+++ b/api/app/clients/tools/structured/DALLE3.js
@@ -0,0 +1,257 @@
+const path = require('path');
+const OpenAI = require('openai');
+const { v4: uuidv4 } = require('uuid');
+const { fetch } = require('undici');
+const { logger } = require('@librechat/data-schemas');
+const { Tool } = require('@librechat/agents/langchain/tools');
+const {
+ getImageBasename,
+ extractBaseURL,
+ getProxyDispatcher,
+ getEnvProxyDispatcher,
+ createMinimalRetentionRequest,
+} = require('@librechat/api');
+const { FileContext, ContentTypes } = require('librechat-data-provider');
+
+const dalle3JsonSchema = {
+ type: 'object',
+ properties: {
+ prompt: {
+ type: 'string',
+ maxLength: 4000,
+ description:
+ 'A text description of the desired image, following the rules, up to 4000 characters.',
+ },
+ style: {
+ type: 'string',
+ enum: ['vivid', 'natural'],
+ description:
+ 'Must be one of `vivid` or `natural`. `vivid` generates hyper-real and dramatic images, `natural` produces more natural, less hyper-real looking images',
+ },
+ quality: {
+ type: 'string',
+ enum: ['hd', 'standard'],
+ description: 'The quality of the generated image. Only `hd` and `standard` are supported.',
+ },
+ size: {
+ type: 'string',
+ enum: ['1024x1024', '1792x1024', '1024x1792'],
+ description:
+ 'The size of the requested image. Use 1024x1024 (square) as the default, 1792x1024 if the user requests a wide image, and 1024x1792 for full-body portraits. Always include this parameter in the request.',
+ },
+ },
+ required: ['prompt', 'style', 'quality', 'size'],
+};
+
+const displayMessage =
+ "DALL-E displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.";
+class DALLE3 extends Tool {
+ constructor(fields = {}) {
+ super();
+ /** @type {boolean} Used to initialize the Tool without necessary variables. */
+ this.override = fields.override ?? false;
+ /** @type {boolean} Necessary for output to contain all image metadata. */
+ this.returnMetadata = fields.returnMetadata ?? false;
+
+ this.userId = fields.userId;
+ this.tenantId = fields.req?.user?.tenantId;
+ this.retentionRequest = createMinimalRetentionRequest(fields.req);
+ 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);
+ }
+
+ let apiKey = fields.DALLE3_API_KEY ?? fields.DALLE_API_KEY ?? this.getApiKey();
+ const config = { apiKey };
+ if (process.env.DALLE_REVERSE_PROXY) {
+ config.baseURL = extractBaseURL(process.env.DALLE_REVERSE_PROXY);
+ }
+
+ if (process.env.DALLE3_AZURE_API_VERSION && process.env.DALLE3_BASEURL) {
+ config.baseURL = process.env.DALLE3_BASEURL;
+ config.defaultQuery = { 'api-version': process.env.DALLE3_AZURE_API_VERSION };
+ config.defaultHeaders = {
+ 'api-key': process.env.DALLE3_API_KEY,
+ 'Content-Type': 'application/json',
+ };
+ config.apiKey = process.env.DALLE3_API_KEY;
+ }
+
+ const proxyDispatcher = getProxyDispatcher();
+ if (proxyDispatcher) {
+ config.fetchOptions = {
+ dispatcher: proxyDispatcher,
+ };
+ }
+
+ /** @type {OpenAI} */
+ this.openai = new OpenAI(config);
+ this.name = 'dalle';
+ this.description = `Use DALLE to create images from text descriptions.
+ - It requires prompts to be in English, detailed, and to specify image type and human features for diversity.
+ - Create only one image, without repeating or listing descriptions outside the "prompts" field.
+ - Maintains the original intent of the description, with parameters for image style, quality, and size to tailor the output.`;
+ this.description_for_model =
+ process.env.DALLE3_SYSTEM_PROMPT ??
+ `// Whenever a description of an image is given, generate prompts (following these rules), and use dalle to create the image. If the user does not ask for a specific number of images, default to creating 2 prompts to send to dalle that are written to be as diverse as possible. All prompts sent to dalle must abide by the following policies:
+ // 1. Prompts must be in English. Translate to English if needed.
+ // 2. One image per function call. Create only 1 image per request unless explicitly told to generate more than 1 image.
+ // 3. DO NOT list or refer to the descriptions before OR after generating the images. They should ONLY ever be written out ONCE, in the \`"prompts"\` field of the request. You do not need to ask for permission to generate, just do it!
+ // 4. Always mention the image type (photo, oil painting, watercolor painting, illustration, cartoon, drawing, vector, render, etc.) at the beginning of the caption. Unless the captions suggests otherwise, make one of the images a photo.
+ // 5. Diversify depictions of ALL images with people to always include always DESCENT and GENDER for EACH person using direct terms. Adjust only human descriptions.
+ // - EXPLICITLY specify these attributes, not abstractly reference them. The attributes should be specified in a minimal way and should directly describe their physical form.
+ // - Your choices should be grounded in reality. For example, all of a given OCCUPATION should not be the same gender or race. Additionally, focus on creating diverse, inclusive, and exploratory scenes via the properties you choose during rewrites. Make choices that may be insightful or unique sometimes.
+ // - Use "various" or "diverse" ONLY IF the description refers to groups of more than 3 people. Do not change the number of people requested in the original description.
+ // - Don't alter memes, fictional character origins, or unseen people. Maintain the original prompt's intent and prioritize quality.
+ // The prompt must intricately describe every part of the image in concrete, objective detail. THINK about what the end goal of the description is, and extrapolate that to what would make satisfying images.
+ // All descriptions sent to dalle should be a paragraph of text that is extremely descriptive and detailed. Each should be more than 3 sentences long.
+ // - The "vivid" style is HIGHLY preferred, but "natural" is also supported.`;
+ this.schema = dalle3JsonSchema;
+ }
+
+ static get jsonSchema() {
+ return dalle3JsonSchema;
+ }
+
+ getApiKey() {
+ const apiKey = process.env.DALLE3_API_KEY ?? process.env.DALLE_API_KEY ?? '';
+ if (!apiKey && !this.override) {
+ throw new Error('Missing DALLE_API_KEY environment variable.');
+ }
+ return apiKey;
+ }
+
+ replaceUnwantedChars(inputString) {
+ return inputString
+ .replace(/\r\n|\r|\n/g, ' ')
+ .replace(/"/g, '')
+ .trim();
+ }
+
+ wrapInMarkdown(imageUrl) {
+ return ``;
+ }
+
+ returnValue(value) {
+ if (this.isAgent === true && typeof value === 'string') {
+ return [value, {}];
+ } else if (this.isAgent === true && typeof value === 'object') {
+ return [displayMessage, value];
+ }
+
+ return value;
+ }
+
+ async _call(data) {
+ const { prompt, quality = 'standard', size = '1024x1024', style = 'vivid' } = data;
+ if (!prompt) {
+ throw new Error('Missing required field: prompt');
+ }
+
+ let resp;
+ try {
+ resp = await this.openai.images.generate({
+ model: 'dall-e-3',
+ quality,
+ style,
+ size,
+ prompt: this.replaceUnwantedChars(prompt),
+ n: 1,
+ });
+ } catch (error) {
+ logger.error('[DALL-E-3] Problem generating the image:', error);
+ return this
+ .returnValue(`Something went wrong when trying to generate the image. The DALL-E API may be unavailable:
+Error Message: ${error.message}`);
+ }
+
+ if (!resp) {
+ return this.returnValue(
+ 'Something went wrong when trying to generate the image. The DALL-E API may be unavailable',
+ );
+ }
+
+ const theImageUrl = resp.data[0].url;
+
+ if (!theImageUrl) {
+ return this.returnValue(
+ 'No image URL returned from OpenAI API. There may be a problem with the API or your configuration.',
+ );
+ }
+
+ if (this.isAgent) {
+ let fetchOptions = {};
+ const dispatcher = getEnvProxyDispatcher();
+ if (dispatcher) {
+ fetchOptions.dispatcher = dispatcher;
+ }
+ const imageResponse = await fetch(theImageUrl, 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 }];
+ }
+
+ const imageBasename = getImageBasename(theImageUrl);
+ const imageExt = path.extname(imageBasename);
+
+ const extension = imageExt.startsWith('.') ? imageExt.slice(1) : imageExt;
+ const imageName = `img-${uuidv4()}.${extension}`;
+
+ logger.debug('[DALL-E-3]', {
+ imageName,
+ imageBasename,
+ imageExt,
+ extension,
+ theImageUrl,
+ data: resp.data[0],
+ });
+
+ try {
+ const result = await this.processFileURL({
+ URL: theImageUrl,
+ basePath: 'images',
+ userId: this.userId,
+ fileName: imageName,
+ fileStrategy: this.fileStrategy,
+ context: FileContext.image_generation,
+ tenantId: this.tenantId,
+ req: this.retentionRequest,
+ });
+
+ if (this.returnMetadata) {
+ this.result = result;
+ } else {
+ this.result = this.wrapInMarkdown(result.filepath);
+ }
+ } catch (error) {
+ logger.error('Error while saving the image:', error);
+ this.result = `Failed to save the image locally. ${error.message}`;
+ }
+
+ return this.returnValue(this.result);
+ }
+}
+
+module.exports = DALLE3;
diff --git a/api/app/clients/tools/structured/FluxAPI.js b/api/app/clients/tools/structured/FluxAPI.js
new file mode 100644
index 0000000..fd0464c
--- /dev/null
+++ b/api/app/clients/tools/structured/FluxAPI.js
@@ -0,0 +1,597 @@
+const axios = require('axios');
+const fetch = require('node-fetch');
+const { v4: uuidv4 } = require('uuid');
+const { logger } = require('@librechat/data-schemas');
+const { Tool } = require('@librechat/agents/langchain/tools');
+const {
+ applyAxiosProxyConfig,
+ createMinimalRetentionRequest,
+ getHttpsProxyAgent,
+} = require('@librechat/api');
+const { FileContext, ContentTypes } = require('librechat-data-provider');
+
+const fluxApiJsonSchema = {
+ type: 'object',
+ properties: {
+ action: {
+ type: 'string',
+ enum: ['generate', 'list_finetunes', 'generate_finetuned'],
+ description:
+ 'Action to perform: "generate" for image generation, "generate_finetuned" for finetuned model generation, "list_finetunes" to get available custom models',
+ },
+ prompt: {
+ type: 'string',
+ description:
+ 'Text prompt for image generation. Required when action is "generate". Not used for list_finetunes.',
+ },
+ width: {
+ type: 'number',
+ description:
+ 'Width of the generated image in pixels. Must be a multiple of 32. Default is 1024.',
+ },
+ height: {
+ type: 'number',
+ description:
+ 'Height of the generated image in pixels. Must be a multiple of 32. Default is 768.',
+ },
+ prompt_upsampling: {
+ type: 'boolean',
+ description: 'Whether to perform upsampling on the prompt.',
+ },
+ steps: {
+ type: 'integer',
+ description: 'Number of steps to run the model for, a number from 1 to 50. Default is 40.',
+ },
+ seed: {
+ type: 'number',
+ description: 'Optional seed for reproducibility.',
+ },
+ safety_tolerance: {
+ type: 'number',
+ description:
+ 'Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict.',
+ },
+ endpoint: {
+ type: 'string',
+ enum: [
+ '/v1/flux-pro-1.1',
+ '/v1/flux-pro',
+ '/v1/flux-dev',
+ '/v1/flux-pro-1.1-ultra',
+ '/v1/flux-pro-finetuned',
+ '/v1/flux-pro-1.1-ultra-finetuned',
+ ],
+ description: 'Endpoint to use for image generation.',
+ },
+ raw: {
+ type: 'boolean',
+ description:
+ 'Generate less processed, more natural-looking images. Only works for /v1/flux-pro-1.1-ultra.',
+ },
+ finetune_id: {
+ type: 'string',
+ description: 'ID of the finetuned model to use',
+ },
+ finetune_strength: {
+ type: 'number',
+ description: 'Strength of the finetuning effect (typically between 0.1 and 1.2)',
+ },
+ guidance: {
+ type: 'number',
+ description: 'Guidance scale for finetuned models',
+ },
+ aspect_ratio: {
+ type: 'string',
+ description: 'Aspect ratio for ultra models (e.g., "16:9")',
+ },
+ },
+ required: [],
+};
+
+const displayMessage =
+ "Flux displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.";
+
+/**
+ * FluxAPI - A tool for generating high-quality images from text prompts using the Flux API.
+ * Each call generates one image. If multiple images are needed, make multiple consecutive calls with the same or varied prompts.
+ */
+class FluxAPI extends Tool {
+ // Pricing constants in USD per image
+ static PRICING = {
+ FLUX_PRO_1_1_ULTRA: -0.06, // /v1/flux-pro-1.1-ultra
+ FLUX_PRO_1_1: -0.04, // /v1/flux-pro-1.1
+ FLUX_PRO: -0.05, // /v1/flux-pro
+ FLUX_DEV: -0.025, // /v1/flux-dev
+ FLUX_PRO_FINETUNED: -0.06, // /v1/flux-pro-finetuned
+ FLUX_PRO_1_1_ULTRA_FINETUNED: -0.07, // /v1/flux-pro-1.1-ultra-finetuned
+ };
+
+ constructor(fields = {}) {
+ super();
+
+ /** @type {boolean} Used to initialize the Tool without necessary variables. */
+ this.override = fields.override ?? false;
+
+ this.userId = fields.userId;
+ this.tenantId = fields.req?.user?.tenantId;
+ this.retentionRequest = createMinimalRetentionRequest(fields.req);
+ 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';
+ }
+ this.returnMetadata = fields.returnMetadata ?? false;
+
+ if (fields.processFileURL) {
+ /** @type {processFileURL} Necessary for output to contain all image metadata. */
+ this.processFileURL = fields.processFileURL.bind(this);
+ }
+
+ this.apiKey = fields.FLUX_API_KEY || this.getApiKey();
+
+ this.name = 'flux';
+ this.description =
+ 'Use Flux to generate images from text descriptions. This tool can generate images and list available finetunes. Each generate call creates one image. For multiple images, make multiple consecutive calls.';
+
+ this.description_for_model = `// Transform any image description into a detailed, high-quality prompt. Never submit a prompt under 3 sentences. Follow these core rules:
+ // 1. ALWAYS enhance basic prompts into 5-10 detailed sentences (e.g., "a cat" becomes: "A close-up photo of a sleek Siamese cat with piercing blue eyes. The cat sits elegantly on a vintage leather armchair, its tail curled gracefully around its paws. Warm afternoon sunlight streams through a nearby window, casting gentle shadows across its face and highlighting the subtle variations in its cream and chocolate-point fur. The background is softly blurred, creating a shallow depth of field that draws attention to the cat's expressive features. The overall composition has a peaceful, contemplative mood with a professional photography style.")
+ // 2. Each prompt MUST be 3-6 descriptive sentences minimum, focusing on visual elements: lighting, composition, mood, and style
+ // Use action: 'list_finetunes' to see available custom models. When using finetunes, use endpoint: '/v1/flux-pro-finetuned' (default) or '/v1/flux-pro-1.1-ultra-finetuned' for higher quality and aspect ratio.`;
+
+ // Add base URL from environment variable with fallback
+ this.baseUrl = process.env.FLUX_API_BASE_URL || 'https://api.us1.bfl.ai';
+
+ this.schema = fluxApiJsonSchema;
+ }
+
+ static get jsonSchema() {
+ return fluxApiJsonSchema;
+ }
+
+ getAxiosConfig() {
+ const config = {};
+ return applyAxiosProxyConfig(config, this.baseUrl);
+ }
+
+ /** @param {Object|string} value */
+ getDetails(value) {
+ if (typeof value === 'string') {
+ return value;
+ }
+ return JSON.stringify(value, null, 2);
+ }
+
+ getApiKey() {
+ const apiKey = process.env.FLUX_API_KEY || '';
+ if (!apiKey && !this.override) {
+ throw new Error('Missing FLUX_API_KEY environment variable.');
+ }
+ return apiKey;
+ }
+
+ wrapInMarkdown(imageUrl) {
+ const serverDomain = process.env.DOMAIN_SERVER || 'http://localhost:3080';
+ return ``;
+ }
+
+ returnValue(value) {
+ if (this.isAgent === true && typeof value === 'string') {
+ return [value, {}];
+ } else if (this.isAgent === true && typeof value === 'object') {
+ if (Array.isArray(value)) {
+ return value;
+ }
+ return [displayMessage, value];
+ }
+ return value;
+ }
+
+ async _call(data) {
+ const { action = 'generate', ...imageData } = data;
+
+ // Use provided API key for this request if available, otherwise use default
+ const requestApiKey = this.apiKey || this.getApiKey();
+
+ // Handle list_finetunes action
+ if (action === 'list_finetunes') {
+ return this.getMyFinetunes(requestApiKey);
+ }
+
+ // Handle finetuned generation
+ if (action === 'generate_finetuned') {
+ return this.generateFinetunedImage(imageData, requestApiKey);
+ }
+
+ // For generate action, ensure prompt is provided
+ if (!imageData.prompt) {
+ throw new Error('Missing required field: prompt');
+ }
+
+ let payload = {
+ prompt: imageData.prompt,
+ prompt_upsampling: imageData.prompt_upsampling || false,
+ safety_tolerance: imageData.safety_tolerance || 6,
+ output_format: imageData.output_format || 'png',
+ };
+
+ // Add optional parameters if provided
+ if (imageData.width) {
+ payload.width = imageData.width;
+ }
+ if (imageData.height) {
+ payload.height = imageData.height;
+ }
+ if (imageData.steps) {
+ payload.steps = imageData.steps;
+ }
+ if (imageData.seed !== undefined) {
+ payload.seed = imageData.seed;
+ }
+ if (imageData.raw) {
+ payload.raw = imageData.raw;
+ }
+
+ const generateUrl = `${this.baseUrl}${imageData.endpoint || '/v1/flux-pro'}`;
+ const resultUrl = `${this.baseUrl}/v1/get_result`;
+
+ logger.debug('[FluxAPI] Generating image with payload:', payload);
+ logger.debug('[FluxAPI] Using endpoint:', generateUrl);
+
+ let taskResponse;
+ try {
+ taskResponse = await axios.post(generateUrl, payload, {
+ headers: {
+ 'x-key': requestApiKey,
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ ...this.getAxiosConfig(),
+ });
+ } catch (error) {
+ const details = this.getDetails(error?.response?.data || error.message);
+ logger.error('[FluxAPI] Error while submitting task:', details);
+
+ return this.returnValue(
+ `Something went wrong when trying to generate the image. The Flux API may be unavailable:
+ Error Message: ${details}`,
+ );
+ }
+
+ const taskId = taskResponse.data.id;
+
+ // Polling for the result
+ let status = 'Pending';
+ let resultData = null;
+ while (status !== 'Ready' && status !== 'Error') {
+ try {
+ // Wait 2 seconds between polls
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ const resultResponse = await axios.get(resultUrl, {
+ headers: {
+ 'x-key': requestApiKey,
+ Accept: 'application/json',
+ },
+ params: { id: taskId },
+ ...this.getAxiosConfig(),
+ });
+ status = resultResponse.data.status;
+
+ if (status === 'Ready') {
+ resultData = resultResponse.data.result;
+ break;
+ } else if (status === 'Error') {
+ logger.error('[FluxAPI] Error in task:', resultResponse.data);
+ return this.returnValue('An error occurred during image generation.');
+ }
+ } catch (error) {
+ const details = this.getDetails(error?.response?.data || error.message);
+ logger.error('[FluxAPI] Error while getting result:', details);
+ return this.returnValue('An error occurred while retrieving the image.');
+ }
+ }
+
+ // If no result data
+ if (!resultData || !resultData.sample) {
+ logger.error('[FluxAPI] No image data received from API. Response:', resultData);
+ 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 {
+ // Fetch the image and convert to base64
+ const fetchOptions = {};
+ const agent = getHttpsProxyAgent(imageUrl);
+ if (agent) {
+ fetchOptions.agent = agent;
+ }
+ 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('Error processing image for agent:', error);
+ return this.returnValue(`Failed to process the image. ${error.message}`);
+ }
+ }
+
+ try {
+ logger.debug('[FluxAPI] Saving image:', imageUrl);
+ const result = await this.processFileURL({
+ fileStrategy: this.fileStrategy,
+ userId: this.userId,
+ URL: imageUrl,
+ fileName: imageName,
+ basePath: 'images',
+ context: FileContext.image_generation,
+ tenantId: this.tenantId,
+ req: this.retentionRequest,
+ });
+
+ logger.debug('[FluxAPI] Image saved to path:', result.filepath);
+
+ // Calculate cost based on endpoint
+ /**
+ * TODO: Cost handling
+ const endpoint = imageData.endpoint || '/v1/flux-pro';
+ const endpointKey = Object.entries(FluxAPI.PRICING).find(([key, _]) =>
+ endpoint.includes(key.toLowerCase().replace(/_/g, '-')),
+ )?.[0];
+ const cost = FluxAPI.PRICING[endpointKey] || 0;
+ */
+ this.result = this.returnMetadata ? result : this.wrapInMarkdown(result.filepath);
+ return this.returnValue(this.result);
+ } catch (error) {
+ const details = this.getDetails(error?.message ?? 'No additional error details.');
+ logger.error('Error while saving the image:', details);
+ return this.returnValue(`Failed to save the image locally. ${details}`);
+ }
+ }
+
+ async getMyFinetunes(apiKey = null) {
+ const finetunesUrl = `${this.baseUrl}/v1/my_finetunes`;
+ const detailsUrl = `${this.baseUrl}/v1/finetune_details`;
+
+ try {
+ const headers = {
+ 'x-key': apiKey || this.getApiKey(),
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ };
+
+ // Get list of finetunes
+ const response = await axios.get(finetunesUrl, {
+ headers,
+ ...this.getAxiosConfig(),
+ });
+ const finetunes = response.data.finetunes;
+
+ // Fetch details for each finetune
+ const finetuneDetails = await Promise.all(
+ finetunes.map(async (finetuneId) => {
+ try {
+ const detailResponse = await axios.get(`${detailsUrl}?finetune_id=${finetuneId}`, {
+ headers,
+ ...this.getAxiosConfig(),
+ });
+ return {
+ id: finetuneId,
+ ...detailResponse.data,
+ };
+ } catch (error) {
+ logger.error(`[FluxAPI] Error fetching details for finetune ${finetuneId}:`, error);
+ return {
+ id: finetuneId,
+ error: 'Failed to fetch details',
+ };
+ }
+ }),
+ );
+
+ if (this.isAgent) {
+ const formattedDetails = JSON.stringify(finetuneDetails, null, 2);
+ return [`Here are the available finetunes:\n${formattedDetails}`, null];
+ }
+ return JSON.stringify(finetuneDetails);
+ } catch (error) {
+ const details = this.getDetails(error?.response?.data || error.message);
+ logger.error('[FluxAPI] Error while getting finetunes:', details);
+ const errorMsg = `Failed to get finetunes: ${details}`;
+ return this.isAgent ? this.returnValue([errorMsg, {}]) : new Error(errorMsg);
+ }
+ }
+
+ async generateFinetunedImage(imageData, requestApiKey) {
+ if (!imageData.prompt) {
+ throw new Error('Missing required field: prompt');
+ }
+
+ if (!imageData.finetune_id) {
+ throw new Error(
+ 'Missing required field: finetune_id for finetuned generation. Please supply a finetune_id!',
+ );
+ }
+
+ // Validate endpoint is appropriate for finetuned generation
+ const validFinetunedEndpoints = ['/v1/flux-pro-finetuned', '/v1/flux-pro-1.1-ultra-finetuned'];
+ const endpoint = imageData.endpoint || '/v1/flux-pro-finetuned';
+
+ if (!validFinetunedEndpoints.includes(endpoint)) {
+ throw new Error(
+ `Invalid endpoint for finetuned generation. Must be one of: ${validFinetunedEndpoints.join(', ')}`,
+ );
+ }
+
+ let payload = {
+ prompt: imageData.prompt,
+ prompt_upsampling: imageData.prompt_upsampling || false,
+ safety_tolerance: imageData.safety_tolerance || 6,
+ output_format: imageData.output_format || 'png',
+ finetune_id: imageData.finetune_id,
+ finetune_strength: imageData.finetune_strength || 1.0,
+ guidance: imageData.guidance || 2.5,
+ };
+
+ // Add optional parameters if provided
+ if (imageData.width) {
+ payload.width = imageData.width;
+ }
+ if (imageData.height) {
+ payload.height = imageData.height;
+ }
+ if (imageData.steps) {
+ payload.steps = imageData.steps;
+ }
+ if (imageData.seed !== undefined) {
+ payload.seed = imageData.seed;
+ }
+ if (imageData.raw) {
+ payload.raw = imageData.raw;
+ }
+
+ const generateUrl = `${this.baseUrl}${endpoint}`;
+ const resultUrl = `${this.baseUrl}/v1/get_result`;
+
+ logger.debug('[FluxAPI] Generating finetuned image with payload:', payload);
+ logger.debug('[FluxAPI] Using endpoint:', generateUrl);
+
+ let taskResponse;
+ try {
+ taskResponse = await axios.post(generateUrl, payload, {
+ headers: {
+ 'x-key': requestApiKey,
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ ...this.getAxiosConfig(),
+ });
+ } catch (error) {
+ const details = this.getDetails(error?.response?.data || error.message);
+ logger.error('[FluxAPI] Error while submitting finetuned task:', details);
+ return this.returnValue(
+ `Something went wrong when trying to generate the finetuned image. The Flux API may be unavailable:
+ Error Message: ${details}`,
+ );
+ }
+
+ const taskId = taskResponse.data.id;
+
+ // Polling for the result
+ let status = 'Pending';
+ let resultData = null;
+ while (status !== 'Ready' && status !== 'Error') {
+ try {
+ // Wait 2 seconds between polls
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ const resultResponse = await axios.get(resultUrl, {
+ headers: {
+ 'x-key': requestApiKey,
+ Accept: 'application/json',
+ },
+ params: { id: taskId },
+ ...this.getAxiosConfig(),
+ });
+ status = resultResponse.data.status;
+
+ if (status === 'Ready') {
+ resultData = resultResponse.data.result;
+ break;
+ } else if (status === 'Error') {
+ logger.error('[FluxAPI] Error in finetuned task:', resultResponse.data);
+ return this.returnValue('An error occurred during finetuned image generation.');
+ }
+ } catch (error) {
+ const details = this.getDetails(error?.response?.data || error.message);
+ logger.error('[FluxAPI] Error while getting finetuned result:', details);
+ return this.returnValue('An error occurred while retrieving the finetuned image.');
+ }
+ }
+
+ // If no result data
+ if (!resultData || !resultData.sample) {
+ logger.error('[FluxAPI] No image data received from API. Response:', resultData);
+ return this.returnValue('No image data received from Flux API.');
+ }
+
+ const imageUrl = resultData.sample;
+ const imageName = `img-${uuidv4()}.png`;
+
+ if (this.isAgent) {
+ try {
+ const fetchOptions = {};
+ const agent = getHttpsProxyAgent(imageUrl);
+ if (agent) {
+ fetchOptions.agent = agent;
+ }
+ 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({
+ fileStrategy: this.fileStrategy,
+ userId: this.userId,
+ URL: imageUrl,
+ fileName: imageName,
+ basePath: 'images',
+ context: FileContext.image_generation,
+ tenantId: this.tenantId,
+ req: this.retentionRequest,
+ });
+
+ logger.debug('[FluxAPI] Finetuned image saved to path:', result.filepath);
+
+ this.result = this.returnMetadata ? result : this.wrapInMarkdown(result.filepath);
+ return this.returnValue(this.result);
+ } catch (error) {
+ const details = this.getDetails(error?.message ?? 'No additional error details.');
+ logger.error('Error while saving the finetuned image:', details);
+ return this.returnValue(`Failed to save the finetuned image locally. ${details}`);
+ }
+ }
+}
+
+module.exports = FluxAPI;
diff --git a/api/app/clients/tools/structured/GeminiImageGen.js b/api/app/clients/tools/structured/GeminiImageGen.js
new file mode 100644
index 0000000..04265bb
--- /dev/null
+++ b/api/app/clients/tools/structured/GeminiImageGen.js
@@ -0,0 +1,476 @@
+const path = require('path');
+const sharp = require('sharp');
+const { v4 } = require('uuid');
+const { GoogleGenAI } = require('@google/genai');
+const { logger } = require('@librechat/data-schemas');
+const { tool } = require('@librechat/agents/langchain/tools');
+const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
+const {
+ geminiToolkit,
+ loadServiceKey,
+ getBalanceConfig,
+ getEnvProxyDispatcher,
+ getTransactionsConfig,
+} = require('@librechat/api');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { spendTokens, getFiles } = require('~/models');
+
+/**
+ * Configure proxy support for Google APIs
+ * This wraps globalThis.fetch to add a proxy dispatcher only for googleapis.com URLs
+ * This is necessary because @google/genai SDK doesn't support custom fetch or httpOptions.dispatcher
+ */
+const googleApiProxyDispatcher = getEnvProxyDispatcher();
+if (googleApiProxyDispatcher) {
+ const originalFetch = globalThis.fetch;
+
+ globalThis.fetch = function (url, options = {}) {
+ const urlString = url.toString();
+ if (urlString.includes('googleapis.com')) {
+ options = { ...options, dispatcher: googleApiProxyDispatcher };
+ }
+ return originalFetch.call(this, url, options);
+ };
+}
+
+/**
+ * Get the default service key file path (consistent with main Google endpoint)
+ * @returns {string} - The default path to the service key file
+ */
+function getDefaultServiceKeyPath() {
+ return (
+ process.env.GOOGLE_SERVICE_KEY_FILE || path.join(process.cwd(), 'api', 'data', 'auth.json')
+ );
+}
+
+const displayMessage =
+ "Gemini displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.";
+
+/**
+ * Replaces unwanted characters from the input string
+ * @param {string} inputString - The input string to process
+ * @returns {string} - The processed string
+ */
+function replaceUnwantedChars(inputString) {
+ return (
+ inputString
+ ?.replace(/\r\n|\r|\n/g, ' ')
+ .replace(/"/g, '')
+ .trim() || ''
+ );
+}
+
+/**
+ * Convert image buffer to target format if needed
+ * @param {Buffer} inputBuffer - The input image buffer
+ * @param {string} targetFormat - The target format (png, jpeg, webp)
+ * @returns {Promise<{buffer: Buffer, format: string}>} - Converted buffer and format
+ */
+async function convertImageFormat(inputBuffer, targetFormat) {
+ const metadata = await sharp(inputBuffer).metadata();
+ const currentFormat = metadata.format;
+
+ // Normalize format names (jpg -> jpeg)
+ const normalizedTarget = targetFormat === 'jpg' ? 'jpeg' : targetFormat.toLowerCase();
+ const normalizedCurrent = currentFormat === 'jpg' ? 'jpeg' : currentFormat;
+
+ // If already in target format, return as-is
+ if (normalizedCurrent === normalizedTarget) {
+ return { buffer: inputBuffer, format: normalizedTarget };
+ }
+
+ // Convert to target format
+ const convertedBuffer = await sharp(inputBuffer).toFormat(normalizedTarget).toBuffer();
+ return { buffer: convertedBuffer, format: normalizedTarget };
+}
+
+/**
+ * Initialize Gemini client (supports both Gemini API and Vertex AI)
+ * Priority: API key (from options, resolved by loadAuthValues) > Vertex AI service account
+ * @param {Object} options - Initialization options
+ * @param {string} [options.GEMINI_API_KEY] - Gemini API key (resolved by loadAuthValues)
+ * @param {string} [options.GOOGLE_KEY] - Google API key (resolved by loadAuthValues)
+ * @returns {Promise} - The initialized client
+ */
+async function initializeGeminiClient(options = {}) {
+ const geminiKey = options.GEMINI_API_KEY;
+ if (geminiKey) {
+ logger.debug('[GeminiImageGen] Using Gemini API with GEMINI_API_KEY');
+ return new GoogleGenAI({ apiKey: geminiKey });
+ }
+
+ const googleKey = options.GOOGLE_KEY;
+ if (googleKey) {
+ logger.debug('[GeminiImageGen] Using Gemini API with GOOGLE_KEY');
+ return new GoogleGenAI({ apiKey: googleKey });
+ }
+
+ logger.debug('[GeminiImageGen] Using Vertex AI with service account');
+ const credentialsPath = getDefaultServiceKeyPath();
+ const serviceKey = await loadServiceKey(credentialsPath);
+
+ if (!serviceKey || !serviceKey.project_id) {
+ throw new Error(
+ 'Gemini Image Generation requires one of: user-provided API key, GEMINI_API_KEY or GOOGLE_KEY env var, or a valid Google service account. ' +
+ `Service account file not found or invalid at: ${credentialsPath}`,
+ );
+ }
+
+ return new GoogleGenAI({
+ vertexai: true,
+ project: serviceKey.project_id,
+ location: process.env.GOOGLE_CLOUD_LOCATION || process.env.GOOGLE_LOC || 'global',
+ googleAuthOptions: { credentials: serviceKey },
+ });
+}
+
+/**
+ * Convert image files to Gemini inline data format
+ * @param {Object} params - Parameters
+ * @returns {Promise} - Array of inline data objects
+ */
+async function convertImagesToInlineData({ imageFiles, image_ids, req, fileStrategy }) {
+ if (!image_ids || image_ids.length === 0) {
+ return [];
+ }
+
+ const streamMethods = {};
+ const requestFilesMap = Object.fromEntries(imageFiles.map((f) => [f.file_id, { ...f }]));
+ const orderedFiles = new Array(image_ids.length);
+ const idsToFetch = [];
+ const indexOfMissing = Object.create(null);
+
+ for (let i = 0; i < image_ids.length; i++) {
+ const id = image_ids[i];
+ const file = requestFilesMap[id];
+ if (file) {
+ orderedFiles[i] = file;
+ } else {
+ idsToFetch.push(id);
+ indexOfMissing[id] = i;
+ }
+ }
+
+ if (idsToFetch.length && req?.user?.id) {
+ const fetchedFiles = await getFiles(
+ {
+ user: req.user.id,
+ file_id: { $in: idsToFetch },
+ height: { $exists: true },
+ width: { $exists: true },
+ },
+ {},
+ {},
+ );
+
+ for (const file of fetchedFiles) {
+ requestFilesMap[file.file_id] = file;
+ orderedFiles[indexOfMissing[file.file_id]] = file;
+ }
+ }
+
+ const inlineDataArray = [];
+ for (const imageFile of orderedFiles) {
+ if (!imageFile) continue;
+
+ try {
+ const source = imageFile.source || fileStrategy;
+ if (!source) continue;
+
+ let getDownloadStream = streamMethods[source];
+ if (!getDownloadStream) {
+ ({ getDownloadStream } = getStrategyFunctions(source));
+ streamMethods[source] = getDownloadStream;
+ }
+ if (!getDownloadStream) continue;
+
+ const stream = await getDownloadStream(req, imageFile.filepath);
+ if (!stream) continue;
+
+ const chunks = [];
+ for await (const chunk of stream) {
+ chunks.push(chunk);
+ }
+ const buffer = Buffer.concat(chunks);
+ const base64Data = buffer.toString('base64');
+ const mimeType = imageFile.type || 'image/png';
+
+ inlineDataArray.push({
+ inlineData: { mimeType, data: base64Data },
+ });
+ } catch (error) {
+ logger.error('[GeminiImageGen] Error processing image:', imageFile.file_id, error);
+ }
+ }
+
+ return inlineDataArray;
+}
+
+/**
+ * Check for safety blocks in API response
+ * @param {Object} response - The API response
+ * @returns {Object|null} - Safety block info or null
+ */
+function checkForSafetyBlock(response) {
+ if (!response?.candidates?.length) {
+ return { reason: 'NO_CANDIDATES', message: 'No candidates returned' };
+ }
+
+ const candidate = response.candidates[0];
+ const finishReason = candidate.finishReason;
+
+ if (finishReason === 'SAFETY' || finishReason === 'PROHIBITED_CONTENT') {
+ return { reason: finishReason, message: 'Content blocked by safety filters' };
+ }
+
+ if (finishReason === 'RECITATION') {
+ return { reason: finishReason, message: 'Content blocked due to recitation concerns' };
+ }
+
+ if (candidate.safetyRatings) {
+ for (const rating of candidate.safetyRatings) {
+ if (rating.probability === 'HIGH' || rating.blocked === true) {
+ return {
+ reason: 'SAFETY_RATING',
+ message: `Blocked due to ${rating.category}`,
+ category: rating.category,
+ };
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Record token usage for balance tracking
+ * @param {Object} params - Parameters
+ * @param {Object} params.usageMetadata - The usage metadata from API response
+ * @param {Object} params.req - The request object
+ * @param {string} params.userId - The user ID
+ * @param {string} params.conversationId - The conversation ID
+ * @param {string} params.model - The model name
+ * @param {string} [params.messageId] - The response message ID for transaction correlation
+ */
+async function recordTokenUsage({ usageMetadata, req, userId, conversationId, model, messageId }) {
+ if (!usageMetadata) {
+ logger.debug('[GeminiImageGen] No usage metadata available for balance tracking');
+ return;
+ }
+
+ const appConfig = req?.config;
+ const balance = getBalanceConfig(appConfig);
+ const transactions = getTransactionsConfig(appConfig);
+
+ // Skip if neither balance nor transactions are enabled
+ if (!balance?.enabled && transactions?.enabled === false) {
+ return;
+ }
+
+ const promptTokens = usageMetadata.prompt_token_count || usageMetadata.promptTokenCount || 0;
+ const completionTokens =
+ usageMetadata.candidates_token_count || usageMetadata.candidatesTokenCount || 0;
+
+ if (promptTokens === 0 && completionTokens === 0) {
+ logger.debug('[GeminiImageGen] No tokens to record');
+ return;
+ }
+
+ logger.debug('[GeminiImageGen] Recording token usage:', {
+ promptTokens,
+ completionTokens,
+ model,
+ conversationId,
+ });
+
+ try {
+ await spendTokens(
+ {
+ user: userId,
+ model,
+ messageId,
+ conversationId,
+ context: 'image_generation',
+ balance,
+ transactions,
+ },
+ {
+ promptTokens,
+ completionTokens,
+ },
+ );
+ } catch (error) {
+ logger.error('[GeminiImageGen] Error recording token usage:', error);
+ }
+}
+
+/**
+ * Creates Gemini Image Generation tool
+ * @param {Object} fields - Configuration fields
+ * @returns {ReturnType} - The image generation tool
+ */
+function createGeminiImageTool(fields = {}) {
+ const override = fields.override ?? false;
+
+ if (!override && !fields.isAgent) {
+ throw new Error('This tool is only available for agents.');
+ }
+
+ const { req, imageFiles = [], userId, fileStrategy, GEMINI_API_KEY, GOOGLE_KEY } = fields;
+
+ const imageOutputType = fields.imageOutputType || EImageOutputType.PNG;
+
+ const geminiImageGenTool = tool(
+ async ({ prompt, image_ids, aspectRatio, imageSize }, runnableConfig) => {
+ if (!prompt) {
+ throw new Error('Missing required field: prompt');
+ }
+
+ logger.debug('[GeminiImageGen] Generating image', { aspectRatio, imageSize });
+
+ let ai;
+ try {
+ ai = await initializeGeminiClient({
+ GEMINI_API_KEY,
+ GOOGLE_KEY,
+ });
+ } catch (error) {
+ logger.error('[GeminiImageGen] Failed to initialize client:', error);
+ return [
+ [{ type: ContentTypes.TEXT, text: `Failed to initialize Gemini: ${error.message}` }],
+ { content: [], file_ids: [] },
+ ];
+ }
+
+ const contents = [{ text: replaceUnwantedChars(prompt) }];
+
+ if (image_ids?.length > 0) {
+ const contextImages = await convertImagesToInlineData({
+ imageFiles,
+ image_ids,
+ req,
+ fileStrategy,
+ });
+ contents.push(...contextImages);
+ logger.debug('[GeminiImageGen] Added', contextImages.length, 'context images');
+ }
+
+ let apiResponse;
+ const geminiModel = process.env.GEMINI_IMAGE_MODEL || 'gemini-2.5-flash-image';
+ const config = {
+ responseModalities: ['TEXT', 'IMAGE'],
+ };
+
+ const supportsImageSize = !geminiModel.includes('gemini-2.5-flash-image');
+ if (aspectRatio || (imageSize && supportsImageSize)) {
+ config.imageConfig = {};
+ if (aspectRatio) {
+ config.imageConfig.aspectRatio = aspectRatio;
+ }
+ if (imageSize && supportsImageSize) {
+ config.imageConfig.imageSize = imageSize;
+ }
+ }
+
+ let derivedSignal = null;
+ let abortHandler = null;
+
+ if (runnableConfig?.signal) {
+ derivedSignal = AbortSignal.any([runnableConfig.signal]);
+ abortHandler = () => logger.debug('[GeminiImageGen] Image generation aborted');
+ derivedSignal.addEventListener('abort', abortHandler, { once: true });
+ config.abortSignal = derivedSignal;
+ }
+
+ try {
+ apiResponse = await ai.models.generateContent({
+ model: geminiModel,
+ contents,
+ config,
+ });
+ } catch (error) {
+ logger.error('[GeminiImageGen] API error:', error);
+ return [
+ [{ type: ContentTypes.TEXT, text: `Image generation failed: ${error.message}` }],
+ { content: [], file_ids: [] },
+ ];
+ } finally {
+ if (abortHandler && derivedSignal) {
+ derivedSignal.removeEventListener('abort', abortHandler);
+ }
+ }
+
+ const safetyBlock = checkForSafetyBlock(apiResponse);
+ if (safetyBlock) {
+ logger.warn('[GeminiImageGen] Safety block:', safetyBlock);
+ const errorMsg = 'Image blocked by content safety filters. Please try different content.';
+ return [[{ type: ContentTypes.TEXT, text: errorMsg }], { content: [], file_ids: [] }];
+ }
+
+ const rawImageData = apiResponse.candidates?.[0]?.content?.parts?.find((p) => p.inlineData)
+ ?.inlineData?.data;
+
+ if (!rawImageData) {
+ logger.warn('[GeminiImageGen] No image data in response');
+ return [
+ [{ type: ContentTypes.TEXT, text: 'No image was generated. Please try again.' }],
+ { content: [], file_ids: [] },
+ ];
+ }
+
+ const rawBuffer = Buffer.from(rawImageData, 'base64');
+ const { buffer: convertedBuffer, format: outputFormat } = await convertImageFormat(
+ rawBuffer,
+ imageOutputType,
+ );
+ const imageData = convertedBuffer.toString('base64');
+ const mimeType = outputFormat === 'jpeg' ? 'image/jpeg' : `image/${outputFormat}`;
+
+ const dataUrl = `data:${mimeType};base64,${imageData}`;
+ const file_ids = [v4()];
+ const content = [
+ {
+ type: ContentTypes.IMAGE_URL,
+ image_url: { url: dataUrl },
+ },
+ ];
+
+ const textResponse = [
+ {
+ type: ContentTypes.TEXT,
+ text:
+ displayMessage +
+ `\n\ngenerated_image_id: "${file_ids[0]}"` +
+ (image_ids?.length > 0 ? `\nreferenced_image_ids: ["${image_ids.join('", "')}"]` : ''),
+ },
+ ];
+
+ const conversationId = runnableConfig?.configurable?.thread_id;
+ const messageId =
+ runnableConfig?.configurable?.run_id ??
+ runnableConfig?.configurable?.requestBody?.messageId;
+ recordTokenUsage({
+ usageMetadata: apiResponse.usageMetadata,
+ req,
+ userId,
+ messageId,
+ conversationId,
+ model: geminiModel,
+ }).catch((error) => {
+ logger.error('[GeminiImageGen] Failed to record token usage:', error);
+ });
+
+ return [textResponse, { content, file_ids }];
+ },
+ {
+ ...geminiToolkit.gemini_image_gen,
+ responseFormat: 'content_and_artifact',
+ },
+ );
+
+ return geminiImageGenTool;
+}
+
+// Export both for compatibility
+module.exports = createGeminiImageTool;
+module.exports.createGeminiImageTool = createGeminiImageTool;
diff --git a/api/app/clients/tools/structured/GoogleSearch.js b/api/app/clients/tools/structured/GoogleSearch.js
new file mode 100644
index 0000000..a6ad5fd
--- /dev/null
+++ b/api/app/clients/tools/structured/GoogleSearch.js
@@ -0,0 +1,79 @@
+const { Tool } = require('@librechat/agents/langchain/tools');
+const { getEnvironmentVariable } = require('@librechat/agents/langchain/utils/env');
+
+const googleSearchJsonSchema = {
+ type: 'object',
+ properties: {
+ query: {
+ type: 'string',
+ minLength: 1,
+ description: 'The search query string.',
+ },
+ max_results: {
+ type: 'integer',
+ minimum: 1,
+ maximum: 10,
+ description: 'The maximum number of search results to return. Defaults to 5.',
+ },
+ },
+ required: ['query'],
+};
+
+class GoogleSearchResults extends Tool {
+ static lc_name() {
+ return 'google';
+ }
+
+ static get jsonSchema() {
+ return googleSearchJsonSchema;
+ }
+
+ constructor(fields = {}) {
+ super(fields);
+ this.name = 'google';
+ this.envVarApiKey = 'GOOGLE_SEARCH_API_KEY';
+ this.envVarSearchEngineId = 'GOOGLE_CSE_ID';
+ this.override = fields.override ?? false;
+ this.apiKey = fields[this.envVarApiKey] ?? getEnvironmentVariable(this.envVarApiKey);
+ this.searchEngineId =
+ fields[this.envVarSearchEngineId] ?? getEnvironmentVariable(this.envVarSearchEngineId);
+
+ if (!this.override && (!this.apiKey || !this.searchEngineId)) {
+ throw new Error(
+ `Missing ${this.envVarApiKey} or ${this.envVarSearchEngineId} environment variable.`,
+ );
+ }
+
+ this.kwargs = fields?.kwargs ?? {};
+ this.name = 'google';
+ this.description =
+ 'A search engine optimized for comprehensive, accurate, and trusted results. Useful for when you need to answer questions about current events.';
+
+ this.schema = googleSearchJsonSchema;
+ }
+
+ async _call(input) {
+ const { query, max_results = 5 } = input;
+
+ const response = await fetch(
+ `https://www.googleapis.com/customsearch/v1?key=${this.apiKey}&cx=${
+ this.searchEngineId
+ }&q=${encodeURIComponent(query)}&num=${max_results}`,
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+ );
+
+ const json = await response.json();
+ if (!response.ok) {
+ throw new Error(`Request failed with status ${response.status}: ${json.error.message}`);
+ }
+
+ return JSON.stringify(json);
+ }
+}
+
+module.exports = GoogleSearchResults;
diff --git a/api/app/clients/tools/structured/OpenAIImageTools.js b/api/app/clients/tools/structured/OpenAIImageTools.js
new file mode 100644
index 0000000..d92d17b
--- /dev/null
+++ b/api/app/clients/tools/structured/OpenAIImageTools.js
@@ -0,0 +1,415 @@
+const axios = require('axios');
+const { v4 } = require('uuid');
+const OpenAI = require('openai');
+const FormData = require('form-data');
+const { logger } = require('@librechat/data-schemas');
+const { tool } = require('@librechat/agents/langchain/tools');
+const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
+const {
+ logAxiosError,
+ oaiToolkit,
+ extractBaseURL,
+ getProxyDispatcher,
+ applyAxiosProxyConfig,
+} = require('@librechat/api');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { getFiles } = require('~/models');
+
+const displayMessage =
+ "The tool displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.";
+
+/**
+ * Replaces unwanted characters from the input string
+ * @param {string} inputString - The input string to process
+ * @returns {string} - The processed string
+ */
+function replaceUnwantedChars(inputString) {
+ return inputString
+ .replace(/\r\n|\r|\n/g, ' ')
+ .replace(/"/g, '')
+ .trim();
+}
+
+function returnValue(value) {
+ if (typeof value === 'string') {
+ return [value, {}];
+ } else if (typeof value === 'object') {
+ if (Array.isArray(value)) {
+ return value;
+ }
+ return [displayMessage, value];
+ }
+ return value;
+}
+
+function createAbortHandler() {
+ return function () {
+ logger.debug('[ImageGenOAI] Image generation aborted');
+ };
+}
+
+/**
+ * Creates OpenAI Image tools (generation and editing)
+ * @param {Object} fields - Configuration fields
+ * @param {ServerRequest} fields.req - Whether the tool is being used in an agent context
+ * @param {boolean} fields.isAgent - Whether the tool is being used in an agent context
+ * @param {string} fields.IMAGE_GEN_OAI_API_KEY - The OpenAI API key
+ * @param {boolean} [fields.override] - Whether to override the API key check, necessary for app initialization
+ * @param {MongoFile[]} [fields.imageFiles] - The images to be used for editing
+ * @param {string} [fields.imageOutputType] - The image output type configuration
+ * @param {string} [fields.fileStrategy] - The file storage strategy
+ * @returns {Array>} - Array of image tools
+ */
+function createOpenAIImageTools(fields = {}) {
+ /** @type {boolean} Used to initialize the Tool without necessary variables. */
+ const override = fields.override ?? false;
+ /** @type {boolean} */
+ if (!override && !fields.isAgent) {
+ throw new Error('This tool is only available for agents.');
+ }
+ const { req } = fields;
+ const imageOutputType = fields.imageOutputType || EImageOutputType.PNG;
+ const appFileStrategy = fields.fileStrategy;
+
+ const getApiKey = () => {
+ const apiKey = process.env.IMAGE_GEN_OAI_API_KEY ?? '';
+ if (!apiKey && !override) {
+ throw new Error('Missing IMAGE_GEN_OAI_API_KEY environment variable.');
+ }
+ return apiKey;
+ };
+
+ let apiKey = fields.IMAGE_GEN_OAI_API_KEY ?? getApiKey();
+ const closureConfig = { apiKey };
+
+ const imageModel = process.env.IMAGE_GEN_OAI_MODEL || 'gpt-image-1';
+
+ let baseURL = 'https://api.openai.com/v1/';
+ if (!override && process.env.IMAGE_GEN_OAI_BASEURL) {
+ baseURL = extractBaseURL(process.env.IMAGE_GEN_OAI_BASEURL);
+ closureConfig.baseURL = baseURL;
+ }
+
+ // Note: Azure may not yet support the latest image generation models
+ if (
+ !override &&
+ process.env.IMAGE_GEN_OAI_AZURE_API_VERSION &&
+ process.env.IMAGE_GEN_OAI_BASEURL
+ ) {
+ baseURL = process.env.IMAGE_GEN_OAI_BASEURL;
+ closureConfig.baseURL = baseURL;
+ closureConfig.defaultQuery = { 'api-version': process.env.IMAGE_GEN_OAI_AZURE_API_VERSION };
+ closureConfig.defaultHeaders = {
+ 'api-key': process.env.IMAGE_GEN_OAI_API_KEY,
+ 'Content-Type': 'application/json',
+ };
+ closureConfig.apiKey = process.env.IMAGE_GEN_OAI_API_KEY;
+ }
+
+ const imageFiles = fields.imageFiles ?? [];
+
+ /**
+ * Image Generation Tool
+ */
+ const imageGenTool = tool(
+ async (
+ {
+ prompt,
+ background = 'auto',
+ n = 1,
+ output_compression = 100,
+ quality = 'auto',
+ size = 'auto',
+ },
+ runnableConfig,
+ ) => {
+ if (!prompt) {
+ throw new Error('Missing required field: prompt');
+ }
+ const clientConfig = { ...closureConfig };
+ const proxyDispatcher = getProxyDispatcher();
+ if (proxyDispatcher) {
+ clientConfig.fetchOptions = {
+ dispatcher: proxyDispatcher,
+ };
+ }
+
+ /** @type {OpenAI} */
+ const openai = new OpenAI(clientConfig);
+ let output_format = imageOutputType;
+ if (
+ background === 'transparent' &&
+ output_format !== EImageOutputType.PNG &&
+ output_format !== EImageOutputType.WEBP
+ ) {
+ logger.warn(
+ '[ImageGenOAI] Transparent background requires PNG or WebP format, defaulting to PNG',
+ );
+ output_format = EImageOutputType.PNG;
+ }
+
+ let resp;
+ /** @type {AbortSignal} */
+ let derivedSignal = null;
+ /** @type {() => void} */
+ let abortHandler = null;
+
+ try {
+ if (runnableConfig?.signal) {
+ derivedSignal = AbortSignal.any([runnableConfig.signal]);
+ abortHandler = createAbortHandler();
+ derivedSignal.addEventListener('abort', abortHandler, { once: true });
+ }
+
+ resp = await openai.images.generate(
+ {
+ model: imageModel,
+ prompt: replaceUnwantedChars(prompt),
+ n: Math.min(Math.max(1, n), 10),
+ background,
+ output_format,
+ output_compression:
+ output_format === EImageOutputType.WEBP || output_format === EImageOutputType.JPEG
+ ? output_compression
+ : undefined,
+ quality,
+ size,
+ },
+ {
+ signal: derivedSignal,
+ },
+ );
+ } catch (error) {
+ const message = '[image_gen_oai] Problem generating the image:';
+ logAxiosError({ error, message });
+ return returnValue(`Something went wrong when trying to generate the image. The OpenAI API may be unavailable:
+Error Message: ${error.message}`);
+ } finally {
+ if (abortHandler && derivedSignal) {
+ derivedSignal.removeEventListener('abort', abortHandler);
+ }
+ }
+
+ if (!resp) {
+ return returnValue(
+ 'Something went wrong when trying to generate the image. The OpenAI API may be unavailable',
+ );
+ }
+
+ // For gpt-image-1, the response contains base64-encoded images
+ // TODO: handle cost in `resp.usage`
+ const base64Image = resp.data[0].b64_json;
+
+ if (!base64Image) {
+ return returnValue(
+ 'No image data returned from OpenAI API. There may be a problem with the API or your configuration.',
+ );
+ }
+
+ const content = [
+ {
+ type: ContentTypes.IMAGE_URL,
+ image_url: {
+ url: `data:image/${output_format};base64,${base64Image}`,
+ },
+ },
+ ];
+
+ const file_ids = [v4()];
+ const response = [
+ {
+ type: ContentTypes.TEXT,
+ text: displayMessage + `\n\ngenerated_image_id: "${file_ids[0]}"`,
+ },
+ ];
+ return [response, { content, file_ids }];
+ },
+ oaiToolkit.image_gen_oai,
+ );
+
+ /**
+ * Image Editing Tool
+ */
+ const imageEditTool = tool(
+ async ({ prompt, image_ids, quality = 'auto', size = 'auto' }, runnableConfig) => {
+ if (!prompt) {
+ throw new Error('Missing required field: prompt');
+ }
+
+ const clientConfig = { ...closureConfig };
+ const proxyDispatcher = getProxyDispatcher();
+ if (proxyDispatcher) {
+ clientConfig.fetchOptions = {
+ dispatcher: proxyDispatcher,
+ };
+ }
+
+ const formData = new FormData();
+ formData.append('model', imageModel);
+ formData.append('prompt', replaceUnwantedChars(prompt));
+ // TODO: `mask` support
+ // TODO: more than 1 image support
+ // formData.append('n', n.toString());
+ formData.append('quality', quality);
+ formData.append('size', size);
+
+ /** @type {Record>} */
+ const streamMethods = {};
+
+ const requestFilesMap = Object.fromEntries(imageFiles.map((f) => [f.file_id, { ...f }]));
+
+ const orderedFiles = new Array(image_ids.length);
+ const idsToFetch = [];
+ const indexOfMissing = Object.create(null);
+
+ for (let i = 0; i < image_ids.length; i++) {
+ const id = image_ids[i];
+ const file = requestFilesMap[id];
+
+ if (file) {
+ orderedFiles[i] = file;
+ } else {
+ idsToFetch.push(id);
+ indexOfMissing[id] = i;
+ }
+ }
+
+ if (idsToFetch.length) {
+ const fetchedFiles = await getFiles(
+ {
+ user: req.user.id,
+ file_id: { $in: idsToFetch },
+ height: { $exists: true },
+ width: { $exists: true },
+ },
+ {},
+ {},
+ );
+
+ for (const file of fetchedFiles) {
+ requestFilesMap[file.file_id] = file;
+ orderedFiles[indexOfMissing[file.file_id]] = file;
+ }
+ }
+ for (const imageFile of orderedFiles) {
+ if (!imageFile) {
+ continue;
+ }
+ /** @type {NodeStream} */
+ let stream;
+ /** @type {NodeStreamDownloader} */
+ let getDownloadStream;
+ const source = imageFile.source || appFileStrategy;
+ if (!source) {
+ throw new Error('No source found for image file');
+ }
+ if (streamMethods[source]) {
+ getDownloadStream = streamMethods[source];
+ } else {
+ ({ getDownloadStream } = getStrategyFunctions(source));
+ streamMethods[source] = getDownloadStream;
+ }
+ if (!getDownloadStream) {
+ throw new Error(`No download stream method found for source: ${source}`);
+ }
+ stream = await getDownloadStream(req, imageFile.filepath);
+ if (!stream) {
+ throw new Error('Failed to get download stream for image file');
+ }
+ formData.append('image[]', stream, {
+ filename: imageFile.filename,
+ contentType: imageFile.type,
+ });
+ }
+
+ /** @type {import('axios').RawAxiosHeaders} */
+ let headers = {
+ ...formData.getHeaders(),
+ };
+
+ if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) {
+ headers['api-key'] = apiKey;
+ } else {
+ headers['Authorization'] = `Bearer ${apiKey}`;
+ }
+
+ /** @type {AbortSignal} */
+ let derivedSignal = null;
+ /** @type {() => void} */
+ let abortHandler = null;
+
+ try {
+ if (runnableConfig?.signal) {
+ derivedSignal = AbortSignal.any([runnableConfig.signal]);
+ abortHandler = createAbortHandler();
+ derivedSignal.addEventListener('abort', abortHandler, { once: true });
+ }
+
+ /** @type {import('axios').AxiosRequestConfig} */
+ const axiosConfig = {
+ headers,
+ ...clientConfig,
+ signal: derivedSignal,
+ baseURL,
+ };
+
+ applyAxiosProxyConfig(axiosConfig, baseURL);
+
+ if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) {
+ axiosConfig.params = {
+ 'api-version': process.env.IMAGE_GEN_OAI_AZURE_API_VERSION,
+ ...axiosConfig.params,
+ };
+ }
+ const response = await axios.post('/images/edits', formData, axiosConfig);
+
+ if (!response.data || !response.data.data || !response.data.data.length) {
+ return returnValue(
+ 'No image data returned from OpenAI API. There may be a problem with the API or your configuration.',
+ );
+ }
+
+ const base64Image = response.data.data[0].b64_json;
+ if (!base64Image) {
+ return returnValue(
+ 'No image data returned from OpenAI API. There may be a problem with the API or your configuration.',
+ );
+ }
+
+ const content = [
+ {
+ type: ContentTypes.IMAGE_URL,
+ image_url: {
+ url: `data:image/${imageOutputType};base64,${base64Image}`,
+ },
+ },
+ ];
+
+ const file_ids = [v4()];
+ const textResponse = [
+ {
+ type: ContentTypes.TEXT,
+ text:
+ displayMessage +
+ `\n\ngenerated_image_id: "${file_ids[0]}"\nreferenced_image_ids: ["${image_ids.join('", "')}"]`,
+ },
+ ];
+ return [textResponse, { content, file_ids }];
+ } catch (error) {
+ const message = '[image_edit_oai] Problem editing the image:';
+ logAxiosError({ error, message });
+ return returnValue(`Something went wrong when trying to edit the image. The OpenAI API may be unavailable:
+Error Message: ${error.message || 'Unknown error'}`);
+ } finally {
+ if (abortHandler && derivedSignal) {
+ derivedSignal.removeEventListener('abort', abortHandler);
+ }
+ }
+ },
+ oaiToolkit.image_edit_oai,
+ );
+
+ return [imageGenTool, imageEditTool];
+}
+
+module.exports = createOpenAIImageTools;
diff --git a/api/app/clients/tools/structured/OpenWeather.js b/api/app/clients/tools/structured/OpenWeather.js
new file mode 100644
index 0000000..9b68f8f
--- /dev/null
+++ b/api/app/clients/tools/structured/OpenWeather.js
@@ -0,0 +1,355 @@
+const fetch = require('node-fetch');
+const { Tool } = require('@librechat/agents/langchain/tools');
+const { getEnvironmentVariable } = require('@librechat/agents/langchain/utils/env');
+
+const openWeatherJsonSchema = {
+ type: 'object',
+ properties: {
+ action: {
+ type: 'string',
+ enum: ['help', 'current_forecast', 'timestamp', 'daily_aggregation', 'overview'],
+ description: 'The action to perform',
+ },
+ city: {
+ type: 'string',
+ description: 'City name for geocoding if lat/lon not provided',
+ },
+ lat: {
+ type: 'number',
+ description: 'Latitude coordinate',
+ },
+ lon: {
+ type: 'number',
+ description: 'Longitude coordinate',
+ },
+ exclude: {
+ type: 'string',
+ description: 'Parts to exclude from the response',
+ },
+ units: {
+ type: 'string',
+ enum: ['Celsius', 'Kelvin', 'Fahrenheit'],
+ description: 'Temperature units',
+ },
+ lang: {
+ type: 'string',
+ description: 'Language code',
+ },
+ date: {
+ type: 'string',
+ description: 'Date in YYYY-MM-DD format for timestamp and daily_aggregation',
+ },
+ tz: {
+ type: 'string',
+ description: 'Timezone',
+ },
+ },
+ required: ['action'],
+};
+
+/**
+ * Map user-friendly units to OpenWeather units.
+ * Defaults to Celsius if not specified.
+ */
+function mapUnitsToOpenWeather(unit) {
+ if (!unit) {
+ return 'metric';
+ } // Default to Celsius
+ switch (unit) {
+ case 'Celsius':
+ return 'metric';
+ case 'Kelvin':
+ return 'standard';
+ case 'Fahrenheit':
+ return 'imperial';
+ default:
+ return 'metric'; // fallback
+ }
+}
+
+/**
+ * Recursively round temperature fields in the API response.
+ */
+function roundTemperatures(obj) {
+ const tempKeys = new Set([
+ 'temp',
+ 'feels_like',
+ 'dew_point',
+ 'day',
+ 'min',
+ 'max',
+ 'night',
+ 'eve',
+ 'morn',
+ 'afternoon',
+ 'morning',
+ 'evening',
+ ]);
+
+ if (Array.isArray(obj)) {
+ return obj.map((item) => roundTemperatures(item));
+ } else if (obj && typeof obj === 'object') {
+ for (const key of Object.keys(obj)) {
+ const value = obj[key];
+ if (value && typeof value === 'object') {
+ obj[key] = roundTemperatures(value);
+ } else if (typeof value === 'number' && tempKeys.has(key)) {
+ obj[key] = Math.round(value);
+ }
+ }
+ }
+ return obj;
+}
+
+class OpenWeather extends Tool {
+ name = 'open_weather';
+ description =
+ 'Provides weather data from OpenWeather One Call API 3.0. ' +
+ 'Actions: help, current_forecast, timestamp, daily_aggregation, overview. ' +
+ 'If lat/lon not provided, specify "city" for geocoding. ' +
+ 'Units: "Celsius", "Kelvin", or "Fahrenheit" (default: Celsius). ' +
+ 'For timestamp action, use "date" in YYYY-MM-DD format.';
+
+ schema = openWeatherJsonSchema;
+
+ static get jsonSchema() {
+ return openWeatherJsonSchema;
+ }
+
+ constructor(fields = {}) {
+ super();
+ this.envVar = 'OPENWEATHER_API_KEY';
+ this.override = fields.override ?? false;
+ this.apiKey = fields[this.envVar] ?? this.getApiKey();
+ }
+
+ getApiKey() {
+ const key = getEnvironmentVariable(this.envVar);
+ if (!key && !this.override) {
+ throw new Error(`Missing ${this.envVar} environment variable.`);
+ }
+ return key;
+ }
+
+ async geocodeCity(city) {
+ const geocodeUrl = `https://api.openweathermap.org/geo/1.0/direct?q=${encodeURIComponent(
+ city,
+ )}&limit=1&appid=${this.apiKey}`;
+ const res = await fetch(geocodeUrl);
+ const data = await res.json();
+ if (!res.ok || !Array.isArray(data) || data.length === 0) {
+ throw new Error(`Could not find coordinates for city: ${city}`);
+ }
+ return { lat: data[0].lat, lon: data[0].lon };
+ }
+
+ convertDateToUnix(dateStr) {
+ const parts = dateStr.split('-');
+ if (parts.length !== 3) {
+ throw new Error('Invalid date format. Expected YYYY-MM-DD.');
+ }
+ const year = parseInt(parts[0], 10);
+ const month = parseInt(parts[1], 10);
+ const day = parseInt(parts[2], 10);
+ if (isNaN(year) || isNaN(month) || isNaN(day)) {
+ throw new Error('Invalid date format. Expected YYYY-MM-DD with valid numbers.');
+ }
+
+ const dateObj = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
+ if (isNaN(dateObj.getTime())) {
+ throw new Error('Invalid date provided. Cannot parse into a valid date.');
+ }
+
+ return Math.floor(dateObj.getTime() / 1000);
+ }
+
+ async _call(args) {
+ try {
+ const { action, city, lat, lon, exclude, units, lang, date, tz } = args;
+ const owmUnits = mapUnitsToOpenWeather(units);
+
+ if (action === 'help') {
+ return JSON.stringify(
+ {
+ title: 'OpenWeather One Call API 3.0 Help',
+ description: 'Guidance on using the OpenWeather One Call API 3.0.',
+ endpoints: {
+ current_and_forecast: {
+ endpoint: 'data/3.0/onecall',
+ data_provided: [
+ 'Current weather',
+ 'Minute forecast (1h)',
+ 'Hourly forecast (48h)',
+ 'Daily forecast (8 days)',
+ 'Government weather alerts',
+ ],
+ required_params: [['lat', 'lon'], ['city']],
+ optional_params: ['exclude', 'units (Celsius/Kelvin/Fahrenheit)', 'lang'],
+ usage_example: {
+ city: 'Knoxville, Tennessee',
+ units: 'Fahrenheit',
+ lang: 'en',
+ },
+ },
+ weather_for_timestamp: {
+ endpoint: 'data/3.0/onecall/timemachine',
+ data_provided: [
+ 'Historical weather (since 1979-01-01)',
+ 'Future forecast up to 4 days ahead',
+ ],
+ required_params: [
+ ['lat', 'lon', 'date (YYYY-MM-DD)'],
+ ['city', 'date (YYYY-MM-DD)'],
+ ],
+ optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang'],
+ usage_example: {
+ city: 'Knoxville, Tennessee',
+ date: '2020-03-04',
+ units: 'Fahrenheit',
+ lang: 'en',
+ },
+ },
+ daily_aggregation: {
+ endpoint: 'data/3.0/onecall/day_summary',
+ data_provided: [
+ 'Aggregated weather data for a specific date (1979-01-02 to 1.5 years ahead)',
+ ],
+ required_params: [
+ ['lat', 'lon', 'date (YYYY-MM-DD)'],
+ ['city', 'date (YYYY-MM-DD)'],
+ ],
+ optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang', 'tz'],
+ usage_example: {
+ city: 'Knoxville, Tennessee',
+ date: '2020-03-04',
+ units: 'Celsius',
+ lang: 'en',
+ },
+ },
+ weather_overview: {
+ endpoint: 'data/3.0/onecall/overview',
+ data_provided: ['Human-readable weather summary (today/tomorrow)'],
+ required_params: [['lat', 'lon'], ['city']],
+ optional_params: ['date (YYYY-MM-DD)', 'units (Celsius/Kelvin/Fahrenheit)'],
+ usage_example: {
+ city: 'Knoxville, Tennessee',
+ date: '2024-05-13',
+ units: 'Celsius',
+ },
+ },
+ },
+ notes: [
+ 'If lat/lon not provided, you can specify a city name and it will be geocoded.',
+ 'For the timestamp action, provide a date in YYYY-MM-DD format instead of a Unix timestamp.',
+ 'By default, temperatures are returned in Celsius.',
+ 'You can specify units as Celsius, Kelvin, or Fahrenheit.',
+ 'All temperatures are rounded to the nearest degree.',
+ ],
+ errors: [
+ '400: Bad Request (missing/invalid params)',
+ '401: Unauthorized (check API key)',
+ '404: Not Found (no data or city)',
+ '429: Too many requests',
+ '5xx: Internal error',
+ ],
+ },
+ null,
+ 2,
+ );
+ }
+
+ let finalLat = lat;
+ let finalLon = lon;
+
+ // If lat/lon not provided but city is given, geocode it
+ if ((finalLat == null || finalLon == null) && city) {
+ const coords = await this.geocodeCity(city);
+ finalLat = coords.lat;
+ finalLon = coords.lon;
+ }
+
+ if (['current_forecast', 'timestamp', 'daily_aggregation', 'overview'].includes(action)) {
+ if (typeof finalLat !== 'number' || typeof finalLon !== 'number') {
+ return "Error: lat and lon are required and must be numbers for this action (or specify 'city').";
+ }
+ }
+
+ const baseUrl = 'https://api.openweathermap.org/data/3.0';
+ let endpoint = '';
+ const params = new URLSearchParams({ appid: this.apiKey, units: owmUnits });
+
+ let dt;
+ if (action === 'timestamp') {
+ if (!date) {
+ return "Error: For timestamp action, a 'date' in YYYY-MM-DD format is required.";
+ }
+ dt = this.convertDateToUnix(date);
+ }
+
+ if (action === 'daily_aggregation' && !date) {
+ return 'Error: date (YYYY-MM-DD) is required for daily_aggregation action.';
+ }
+
+ switch (action) {
+ case 'current_forecast':
+ endpoint = '/onecall';
+ params.append('lat', String(finalLat));
+ params.append('lon', String(finalLon));
+ if (exclude) {
+ params.append('exclude', exclude);
+ }
+ if (lang) {
+ params.append('lang', lang);
+ }
+ break;
+ case 'timestamp':
+ endpoint = '/onecall/timemachine';
+ params.append('lat', String(finalLat));
+ params.append('lon', String(finalLon));
+ params.append('dt', String(dt));
+ if (lang) {
+ params.append('lang', lang);
+ }
+ break;
+ case 'daily_aggregation':
+ endpoint = '/onecall/day_summary';
+ params.append('lat', String(finalLat));
+ params.append('lon', String(finalLon));
+ params.append('date', date);
+ if (lang) {
+ params.append('lang', lang);
+ }
+ if (tz) {
+ params.append('tz', tz);
+ }
+ break;
+ case 'overview':
+ endpoint = '/onecall/overview';
+ params.append('lat', String(finalLat));
+ params.append('lon', String(finalLon));
+ if (date) {
+ params.append('date', date);
+ }
+ break;
+ default:
+ return `Error: Unknown action: ${action}`;
+ }
+
+ const url = `${baseUrl}${endpoint}?${params.toString()}`;
+ const response = await fetch(url);
+ const json = await response.json();
+ if (!response.ok) {
+ return `Error: OpenWeather API request failed with status ${response.status}: ${
+ json.message || JSON.stringify(json)
+ }`;
+ }
+
+ const roundedJson = roundTemperatures(json);
+ return JSON.stringify(roundedJson);
+ } catch (err) {
+ return `Error: ${err.message}`;
+ }
+ }
+}
+
+module.exports = OpenWeather;
diff --git a/api/app/clients/tools/structured/StableDiffusion.js b/api/app/clients/tools/structured/StableDiffusion.js
new file mode 100644
index 0000000..89792a8
--- /dev/null
+++ b/api/app/clients/tools/structured/StableDiffusion.js
@@ -0,0 +1,209 @@
+// Generates image using stable diffusion webui's api (automatic1111)
+const fs = require('fs');
+const path = require('path');
+const axios = require('axios');
+const sharp = require('sharp');
+const { v4: uuidv4 } = require('uuid');
+const { logger } = require('@librechat/data-schemas');
+const { Tool } = require('@librechat/agents/langchain/tools');
+const { FileContext, ContentTypes } = require('librechat-data-provider');
+const { getBasePath } = require('@librechat/api');
+const paths = require('~/config/paths');
+
+const stableDiffusionJsonSchema = {
+ type: 'object',
+ properties: {
+ prompt: {
+ type: 'string',
+ description:
+ 'Detailed keywords to describe the subject, using at least 7 keywords to accurately describe the image, separated by comma',
+ },
+ negative_prompt: {
+ type: 'string',
+ description:
+ 'Keywords we want to exclude from the final image, using at least 7 keywords to accurately describe the image, separated by comma',
+ },
+ },
+ required: ['prompt', 'negative_prompt'],
+};
+
+const displayMessage =
+ "Stable Diffusion displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.";
+
+class StableDiffusionAPI extends Tool {
+ constructor(fields) {
+ super();
+ /** @type {string} User ID */
+ this.userId = fields.userId;
+ /** @type {ServerRequest | undefined} Express Request object, only provided by ToolService */
+ this.req = fields.req;
+ /** @type {boolean} Used to initialize the Tool without necessary variables. */
+ this.override = fields.override ?? false;
+ /** @type {boolean} Necessary for output to contain all image metadata. */
+ 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);
+ }
+
+ this.name = 'stable-diffusion';
+ this.url = fields.SD_WEBUI_URL || this.getServerURL();
+ this.description_for_model = `// Generate images and visuals using text.
+// Guidelines:
+// - ALWAYS use {{"prompt": "7+ detailed keywords", "negative_prompt": "7+ detailed keywords"}} structure for queries.
+// - ALWAYS include the markdown url in your final response to show the user: }/images/id.png)
+// - Visually describe the moods, details, structures, styles, and/or proportions of the image. Remember, the focus is on visual attributes.
+// - Craft your input by "showing" and not "telling" the imagery. Think in terms of what you'd want to see in a photograph or a painting.
+// - Here's an example for generating a realistic portrait photo of a man:
+// "prompt":"photo of a man in black clothes, half body, high detailed skin, coastline, overcast weather, wind, waves, 8k uhd, dslr, soft lighting, high quality, film grain, Fujifilm XT3"
+// "negative_prompt":"semi-realistic, cgi, 3d, render, sketch, cartoon, drawing, anime, out of frame, low quality, ugly, mutation, deformed"
+// - Generate images only once per human query unless explicitly requested by the user`;
+ this.description =
+ "You can generate images using text with 'stable-diffusion'. This tool is exclusively for visual content.";
+ this.schema = stableDiffusionJsonSchema;
+ }
+
+ static get jsonSchema() {
+ return stableDiffusionJsonSchema;
+ }
+
+ replaceNewLinesWithSpaces(inputString) {
+ return inputString.replace(/\r\n|\r|\n/g, ' ');
+ }
+
+ getMarkdownImageUrl(imageName) {
+ const imageUrl = path
+ .join(this.relativePath, this.userId, imageName)
+ .replace(/\\/g, '/')
+ .replace('public/', '');
+ return ``;
+ }
+
+ returnValue(value) {
+ if (this.isAgent === true && typeof value === 'string') {
+ return [value, {}];
+ } else if (this.isAgent === true && typeof value === 'object') {
+ return [displayMessage, value];
+ }
+
+ return value;
+ }
+
+ getServerURL() {
+ const url = process.env.SD_WEBUI_URL || '';
+ if (!url && !this.override) {
+ throw new Error('Missing SD_WEBUI_URL environment variable.');
+ }
+ return url;
+ }
+
+ async _call(data) {
+ const url = this.url;
+ const { prompt, negative_prompt } = data;
+ const payload = {
+ prompt,
+ negative_prompt,
+ cfg_scale: 4.5,
+ steps: 22,
+ width: 1024,
+ height: 1024,
+ };
+ let generationResponse;
+ try {
+ generationResponse = await axios.post(`${url}/sdapi/v1/txt2img`, payload);
+ } catch (error) {
+ logger.error('[StableDiffusion] Error while generating image:', error);
+ return this.returnValue('Error making API request.');
+ }
+ const image = generationResponse.data.images[0];
+
+ /** @type {{ height: number, width: number, seed: number, infotexts: string[] }} */
+ let info = {};
+ try {
+ info = JSON.parse(generationResponse.data.info);
+ } catch (error) {
+ logger.error('[StableDiffusion] Error while getting image metadata:', error);
+ }
+
+ const file_id = uuidv4();
+ const imageName = `${file_id}.png`;
+ const { imageOutput: imageOutputPath, clientPath } = paths;
+ const filepath = path.join(imageOutputPath, this.userId, imageName);
+ this.relativePath = path.relative(clientPath, imageOutputPath);
+
+ if (!fs.existsSync(path.join(imageOutputPath, this.userId))) {
+ fs.mkdirSync(path.join(imageOutputPath, this.userId), { recursive: true });
+ }
+
+ try {
+ if (this.isAgent) {
+ const content = [
+ {
+ type: ContentTypes.IMAGE_URL,
+ image_url: {
+ url: `data:image/png;base64,${image}`,
+ },
+ },
+ ];
+
+ const response = [
+ {
+ type: ContentTypes.TEXT,
+ text: displayMessage,
+ },
+ ];
+ return [response, { content }];
+ }
+
+ const buffer = Buffer.from(image.split(',', 1)[0], 'base64');
+ if (this.returnMetadata && this.uploadImageBuffer && this.req) {
+ const file = await this.uploadImageBuffer({
+ req: this.req,
+ context: FileContext.image_generation,
+ resize: false,
+ metadata: {
+ buffer,
+ height: info.height,
+ width: info.width,
+ bytes: Buffer.byteLength(buffer),
+ filename: imageName,
+ type: 'image/png',
+ file_id,
+ },
+ });
+
+ const generationInfo = info.infotexts[0].split('\n').pop();
+ return {
+ ...file,
+ prompt,
+ metadata: {
+ negative_prompt,
+ seed: info.seed,
+ info: generationInfo,
+ },
+ };
+ }
+
+ await sharp(buffer)
+ .withMetadata({
+ iptcpng: {
+ parameters: info.infotexts[0],
+ },
+ })
+ .toFile(filepath);
+ this.result = this.getMarkdownImageUrl(imageName);
+ } catch (error) {
+ logger.error('[StableDiffusion] Error while saving the image:', error);
+ }
+
+ return this.returnValue(this.result);
+ }
+}
+
+module.exports = StableDiffusionAPI;
diff --git a/api/app/clients/tools/structured/TavilySearch.js b/api/app/clients/tools/structured/TavilySearch.js
new file mode 100644
index 0000000..a90b75b
--- /dev/null
+++ b/api/app/clients/tools/structured/TavilySearch.js
@@ -0,0 +1,79 @@
+const { z } = require('zod');
+const { fetch } = require('undici');
+const { tool } = require('@librechat/agents/langchain/tools');
+const { getEnvProxyDispatcher } = require('@librechat/api');
+const { getApiKey } = require('./credentials');
+
+function createTavilySearchTool(fields = {}) {
+ const envVar = 'TAVILY_API_KEY';
+ const override = fields.override ?? false;
+ const apiKey = fields.apiKey ?? getApiKey(envVar, override);
+ const kwargs = fields?.kwargs ?? {};
+
+ return tool(
+ async (input) => {
+ const { query, ...rest } = input;
+
+ const requestBody = {
+ api_key: apiKey,
+ query,
+ ...rest,
+ ...kwargs,
+ };
+
+ const fetchOptions = {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestBody),
+ };
+
+ const dispatcher = getEnvProxyDispatcher();
+ if (dispatcher) {
+ fetchOptions.dispatcher = dispatcher;
+ }
+
+ const response = await fetch('https://api.tavily.com/search', fetchOptions);
+
+ const json = await response.json();
+ if (!response.ok) {
+ throw new Error(`Request failed with status ${response.status}: ${json.error}`);
+ }
+
+ return JSON.stringify(json);
+ },
+ {
+ name: 'tavily_search_results_json',
+ description:
+ 'A search engine optimized for comprehensive, accurate, and trusted results. Useful for when you need to answer questions about current events.',
+ schema: z.object({
+ query: z.string().min(1).describe('The search query string.'),
+ max_results: z
+ .number()
+ .min(1)
+ .max(10)
+ .optional()
+ .describe('The maximum number of search results to return. Defaults to 5.'),
+ search_depth: z
+ .enum(['basic', 'advanced'])
+ .optional()
+ .describe(
+ 'The depth of the search, affecting result quality and response time (`basic` or `advanced`). Default is basic for quick results and advanced for indepth high quality results but longer response time. Advanced calls equals 2 requests.',
+ ),
+ include_images: z
+ .boolean()
+ .optional()
+ .describe(
+ 'Whether to include a list of query-related images in the response. Default is False.',
+ ),
+ include_answer: z
+ .boolean()
+ .optional()
+ .describe('Whether to include answers in the search results. Default is False.'),
+ }),
+ },
+ );
+}
+
+module.exports = createTavilySearchTool;
diff --git a/api/app/clients/tools/structured/TavilySearchResults.js b/api/app/clients/tools/structured/TavilySearchResults.js
new file mode 100644
index 0000000..9e9aa3d
--- /dev/null
+++ b/api/app/clients/tools/structured/TavilySearchResults.js
@@ -0,0 +1,142 @@
+const { fetch } = require('undici');
+const { Tool } = require('@librechat/agents/langchain/tools');
+const { getEnvironmentVariable } = require('@librechat/agents/langchain/utils/env');
+const { getEnvProxyDispatcher } = require('@librechat/api');
+
+const tavilySearchJsonSchema = {
+ type: 'object',
+ properties: {
+ query: {
+ type: 'string',
+ minLength: 1,
+ description: 'The search query string.',
+ },
+ max_results: {
+ type: 'number',
+ minimum: 1,
+ maximum: 10,
+ description: 'The maximum number of search results to return. Defaults to 5.',
+ },
+ search_depth: {
+ type: 'string',
+ enum: ['basic', 'advanced'],
+ description:
+ 'The depth of the search, affecting result quality and response time (`basic` or `advanced`). Default is basic for quick results and advanced for indepth high quality results but longer response time. Advanced calls equals 2 requests.',
+ },
+ include_images: {
+ type: 'boolean',
+ description:
+ 'Whether to include a list of query-related images in the response. Default is False.',
+ },
+ include_answer: {
+ type: 'boolean',
+ description: 'Whether to include answers in the search results. Default is False.',
+ },
+ include_raw_content: {
+ type: 'boolean',
+ description: 'Whether to include raw content in the search results. Default is False.',
+ },
+ include_domains: {
+ type: 'array',
+ items: { type: 'string' },
+ description: 'A list of domains to specifically include in the search results.',
+ },
+ exclude_domains: {
+ type: 'array',
+ items: { type: 'string' },
+ description: 'A list of domains to specifically exclude from the search results.',
+ },
+ topic: {
+ type: 'string',
+ enum: ['general', 'news', 'finance'],
+ description:
+ 'The category of the search. Use news ONLY if query SPECIFCALLY mentions the word "news".',
+ },
+ time_range: {
+ type: 'string',
+ enum: ['day', 'week', 'month', 'year', 'd', 'w', 'm', 'y'],
+ description: 'The time range back from the current date to filter results.',
+ },
+ days: {
+ type: 'number',
+ minimum: 1,
+ description: 'Number of days back from the current date to include. Only if topic is news.',
+ },
+ include_image_descriptions: {
+ type: 'boolean',
+ description:
+ 'When include_images is true, also add a descriptive text for each image. Default is false.',
+ },
+ },
+ required: ['query'],
+};
+
+class TavilySearchResults extends Tool {
+ static lc_name() {
+ return 'TavilySearchResults';
+ }
+
+ constructor(fields = {}) {
+ super(fields);
+ this.envVar = 'TAVILY_API_KEY';
+ /* Used to initialize the Tool without necessary variables. */
+ this.override = fields.override ?? false;
+ this.apiKey = fields[this.envVar] ?? this.getApiKey();
+
+ this.kwargs = fields?.kwargs ?? {};
+ this.name = 'tavily_search_results_json';
+ this.description =
+ 'A search engine optimized for comprehensive, accurate, and trusted results. Useful for when you need to answer questions about current events.';
+
+ this.schema = tavilySearchJsonSchema;
+ }
+
+ static get jsonSchema() {
+ return tavilySearchJsonSchema;
+ }
+
+ getApiKey() {
+ const apiKey = getEnvironmentVariable(this.envVar);
+ if (!apiKey && !this.override) {
+ throw new Error(`Missing ${this.envVar} environment variable.`);
+ }
+ return apiKey;
+ }
+
+ async _call(input) {
+ const { query, ...rest } = input;
+
+ const requestBody = {
+ api_key: this.apiKey,
+ query,
+ ...rest,
+ ...this.kwargs,
+ };
+
+ const fetchOptions = {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestBody),
+ };
+
+ const dispatcher = getEnvProxyDispatcher();
+ if (dispatcher) {
+ fetchOptions.dispatcher = dispatcher;
+ }
+
+ const response = await fetch('https://api.tavily.com/search', fetchOptions);
+
+ const json = await response.json();
+ if (!response.ok) {
+ throw new Error(
+ `Request failed with status ${response.status}: ${json?.detail?.error || json?.error}`,
+ );
+ }
+
+ return JSON.stringify(json);
+ }
+}
+
+module.exports = TavilySearchResults;
diff --git a/api/app/clients/tools/structured/TraversaalSearch.js b/api/app/clients/tools/structured/TraversaalSearch.js
new file mode 100644
index 0000000..9902756
--- /dev/null
+++ b/api/app/clients/tools/structured/TraversaalSearch.js
@@ -0,0 +1,97 @@
+const { logger } = require('@librechat/data-schemas');
+const { Tool } = require('@librechat/agents/langchain/tools');
+const { getEnvironmentVariable } = require('@librechat/agents/langchain/utils/env');
+
+const traversaalSearchJsonSchema = {
+ type: 'object',
+ properties: {
+ query: {
+ type: 'string',
+ description:
+ "A properly written sentence to be interpreted by an AI to search the web according to the user's request.",
+ },
+ },
+ required: ['query'],
+};
+
+/**
+ * Tool for the Traversaal AI search API, Ares.
+ */
+class TraversaalSearch extends Tool {
+ static lc_name() {
+ return 'TraversaalSearch';
+ }
+ constructor(fields) {
+ super(fields);
+ this.name = 'traversaal_search';
+ this.description = `An AI search engine optimized for comprehensive, accurate, and trusted results.
+ Useful for when you need to answer questions about current events. Input should be a search query.`;
+ this.description_for_model =
+ '\'Please create a specific sentence for the AI to understand and use as a query to search the web based on the user\'s request. For example, "Find information about the highest mountains in the world." or "Show me the latest news articles about climate change and its impact on polar ice caps."\'';
+ this.schema = traversaalSearchJsonSchema;
+
+ this.apiKey = fields?.TRAVERSAAL_API_KEY ?? this.getApiKey();
+ }
+
+ static get jsonSchema() {
+ return traversaalSearchJsonSchema;
+ }
+
+ getApiKey() {
+ const apiKey = getEnvironmentVariable('TRAVERSAAL_API_KEY');
+ if (!apiKey && this.override) {
+ throw new Error(
+ 'No Traversaal API key found. Either set an environment variable named "TRAVERSAAL_API_KEY" or pass an API key as "apiKey".',
+ );
+ }
+ return apiKey;
+ }
+
+ async _call({ query }, _runManager) {
+ const body = {
+ query: [query],
+ };
+ try {
+ const response = await fetch('https://api-ares.traversaal.ai/live/predict', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-api-key': this.apiKey,
+ },
+ body: JSON.stringify({ ...body }),
+ });
+ const json = await response.json();
+ if (!response.ok) {
+ throw new Error(
+ `Request failed with status code ${response.status}: ${json.error ?? json.message}`,
+ );
+ }
+ if (!json.data) {
+ throw new Error('Could not parse Traversaal API results. Please try again.');
+ }
+
+ const baseText = json.data?.response_text ?? '';
+ const sources = json.data?.web_url;
+ const noResponse = 'No response found in Traversaal API results';
+
+ if (!baseText && !sources) {
+ return noResponse;
+ }
+
+ const sourcesText = sources?.length ? '\n\nSources:\n - ' + sources.join('\n - ') : '';
+
+ const result = baseText + sourcesText;
+
+ if (!result) {
+ return noResponse;
+ }
+
+ return result;
+ } catch (error) {
+ logger.error('Traversaal API request failed', error);
+ return `Traversaal API request failed: ${error.message}`;
+ }
+ }
+}
+
+module.exports = TraversaalSearch;
diff --git a/api/app/clients/tools/structured/Wolfram.js b/api/app/clients/tools/structured/Wolfram.js
new file mode 100644
index 0000000..1163c0f
--- /dev/null
+++ b/api/app/clients/tools/structured/Wolfram.js
@@ -0,0 +1,107 @@
+/* eslint-disable no-useless-escape */
+const axios = require('axios');
+const { logger } = require('@librechat/data-schemas');
+const { Tool } = require('@librechat/agents/langchain/tools');
+
+const wolframJsonSchema = {
+ type: 'object',
+ properties: {
+ input: {
+ type: 'string',
+ description: 'Natural language query to WolframAlpha following the guidelines',
+ },
+ },
+ required: ['input'],
+};
+
+class WolframAlphaAPI extends Tool {
+ constructor(fields) {
+ super();
+ /* Used to initialize the Tool without necessary variables. */
+ this.override = fields.override ?? false;
+
+ this.name = 'wolfram';
+ this.apiKey = fields.WOLFRAM_APP_ID || this.getAppId();
+ this.description_for_model = `// Access dynamic computation and curated data from WolframAlpha and Wolfram Cloud.
+ // General guidelines:
+ // - Use only getWolframAlphaResults or getWolframCloudResults endpoints.
+ // - Prefer getWolframAlphaResults unless Wolfram Language code should be evaluated.
+ // - Use getWolframAlphaResults for natural-language queries in English; translate non-English queries before sending, then respond in the original language.
+ // - Use getWolframCloudResults for problems solvable with Wolfram Language code.
+ // - Suggest only Wolfram Language for external computation.
+ // - Inform users if information is not from Wolfram endpoints.
+ // - Display image URLs with Image Markdown syntax: . You must prefix the caption brackets with "!".
+ // - ALWAYS use this exponent notation: \`6*10^14\`, NEVER \`6e14\`.
+ // - ALWAYS use {{"input": query}} structure for queries to Wolfram endpoints; \`query\` must ONLY be a single-line string.
+ // - ALWAYS use proper Markdown formatting for all math, scientific, and chemical formulas, symbols, etc.: '$$\n[expression]\n$$' for standalone cases and '\( [expression] \)' when inline.
+ // - Format inline Wolfram Language code with Markdown code formatting.
+ // - Never mention your knowledge cutoff date; Wolfram may return more recent data. getWolframAlphaResults guidelines:
+ // - Understands natural language queries about entities in chemistry, physics, geography, history, art, astronomy, and more.
+ // - Performs mathematical calculations, date and unit conversions, formula solving, etc.
+ // - Convert inputs to simplified keyword queries whenever possible (e.g. convert "how many people live in France" to "France population").
+ // - Use ONLY single-letter variable names, with or without integer subscript (e.g., n, n1, n_1).
+ // - Use named physical constants (e.g., 'speed of light') without numerical substitution.
+ // - Include a space between compound units (e.g., "Ω m" for "ohm*meter").
+ // - To solve for a variable in an equation with units, consider solving a corresponding equation without units; exclude counting units (e.g., books), include genuine units (e.g., kg).
+ // - If data for multiple properties is needed, make separate calls for each property.
+ // - If a Wolfram Alpha result is not relevant to the query:
+ // -- If Wolfram provides multiple 'Assumptions' for a query, choose the more relevant one(s) without explaining the initial result. If you are unsure, ask the user to choose.
+ // -- Re-send the exact same 'input' with NO modifications, and add the 'assumption' parameter, formatted as a list, with the relevant values.
+ // -- ONLY simplify or rephrase the initial query if a more relevant 'Assumption' or other input suggestions are not provided.
+ // -- Do not explain each step unless user input is needed. Proceed directly to making a better API call based on the available assumptions.`;
+ this.description = `WolframAlpha offers computation, math, curated knowledge, and real-time data. It handles natural language queries and performs complex calculations.
+ Follow the guidelines to get the best results.`;
+ this.schema = wolframJsonSchema;
+ }
+
+ static get jsonSchema() {
+ return wolframJsonSchema;
+ }
+
+ async fetchRawText(url) {
+ try {
+ const response = await axios.get(url, { responseType: 'text' });
+ return response.data;
+ } catch (error) {
+ logger.error('[WolframAlphaAPI] Error fetching raw text:', error);
+ throw error;
+ }
+ }
+
+ getAppId() {
+ const appId = process.env.WOLFRAM_APP_ID || '';
+ if (!appId && !this.override) {
+ throw new Error('Missing WOLFRAM_APP_ID environment variable.');
+ }
+ return appId;
+ }
+
+ createWolframAlphaURL(query) {
+ // Clean up query
+ const formattedQuery = query.replaceAll(/`/g, '').replaceAll(/\n/g, ' ');
+ const baseURL = 'https://www.wolframalpha.com/api/v1/llm-api';
+ const encodedQuery = encodeURIComponent(formattedQuery);
+ const appId = this.apiKey || this.getAppId();
+ const url = `${baseURL}?input=${encodedQuery}&appid=${appId}`;
+ return url;
+ }
+
+ async _call(data) {
+ try {
+ const { input } = data;
+ const url = this.createWolframAlphaURL(input);
+ const response = await this.fetchRawText(url);
+ return response;
+ } catch (error) {
+ if (error.response && error.response.data) {
+ logger.error('[WolframAlphaAPI] Error data:', error);
+ return error.response.data;
+ } else {
+ logger.error('[WolframAlphaAPI] Error querying Wolfram Alpha', error);
+ return 'There was an error querying Wolfram Alpha.';
+ }
+ }
+ }
+}
+
+module.exports = WolframAlphaAPI;
diff --git a/api/app/clients/tools/structured/credentials.js b/api/app/clients/tools/structured/credentials.js
new file mode 100644
index 0000000..f9c4eae
--- /dev/null
+++ b/api/app/clients/tools/structured/credentials.js
@@ -0,0 +1,13 @@
+const { getEnvironmentVariable } = require('@librechat/agents/langchain/utils/env');
+
+function getApiKey(envVar, override) {
+ const key = getEnvironmentVariable(envVar);
+ if (!key && !override) {
+ throw new Error(`Missing ${envVar} environment variable.`);
+ }
+ return key;
+}
+
+module.exports = {
+ getApiKey,
+};
diff --git a/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js b/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js
new file mode 100644
index 0000000..b958ed7
--- /dev/null
+++ b/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js
@@ -0,0 +1,71 @@
+const DALLE3 = require('../DALLE3');
+
+const processFileURL = jest.fn();
+const proxyEnvKeys = [
+ 'PROXY',
+ 'proxy',
+ 'HTTP_PROXY',
+ 'HTTPS_PROXY',
+ 'NO_PROXY',
+ 'http_proxy',
+ 'https_proxy',
+ 'no_proxy',
+];
+
+function clearProxyEnv() {
+ proxyEnvKeys.forEach((key) => delete process.env[key]);
+}
+
+describe('DALLE3 Proxy Configuration', () => {
+ let originalEnv;
+
+ beforeAll(() => {
+ originalEnv = { ...process.env };
+ });
+
+ beforeEach(() => {
+ jest.resetModules();
+ process.env = { ...originalEnv };
+ clearProxyEnv();
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ it('should configure fetchOptions.dispatcher when proxy env is set', () => {
+ // Set proxy environment variable
+ process.env.PROXY = 'http://proxy.example.com:8080';
+ process.env.DALLE_API_KEY = 'test-api-key';
+
+ // Create instance
+ const dalleWithProxy = new DALLE3({ processFileURL });
+
+ // Check that the openai client exists
+ expect(dalleWithProxy.openai).toBeDefined();
+
+ // Check that _options exists and has fetchOptions with a dispatcher
+ expect(dalleWithProxy.openai._options).toBeDefined();
+ expect(dalleWithProxy.openai._options.fetchOptions).toBeDefined();
+ expect(dalleWithProxy.openai._options.fetchOptions.dispatcher).toBeDefined();
+ expect(dalleWithProxy.openai._options.fetchOptions.dispatcher).toBeDefined();
+ });
+
+ it('should not configure a dispatcher when proxy env is not set', () => {
+ process.env.DALLE_API_KEY = 'test-api-key';
+
+ // Create instance
+ const dalleWithoutProxy = new DALLE3({ processFileURL });
+
+ // Check that the openai client exists
+ expect(dalleWithoutProxy.openai).toBeDefined();
+
+ // Check that _options exists but fetchOptions either doesn't exist or doesn't have a dispatcher
+ expect(dalleWithoutProxy.openai._options).toBeDefined();
+
+ // fetchOptions should either not exist or not have a dispatcher
+ if (dalleWithoutProxy.openai._options.fetchOptions) {
+ expect(dalleWithoutProxy.openai._options.fetchOptions.dispatcher).toBeUndefined();
+ }
+ });
+});
diff --git a/api/app/clients/tools/structured/specs/DALLE3.spec.js b/api/app/clients/tools/structured/specs/DALLE3.spec.js
new file mode 100644
index 0000000..6071929
--- /dev/null
+++ b/api/app/clients/tools/structured/specs/DALLE3.spec.js
@@ -0,0 +1,207 @@
+const OpenAI = require('openai');
+const { logger } = require('@librechat/data-schemas');
+const DALLE3 = require('../DALLE3');
+
+jest.mock('openai');
+jest.mock('@librechat/data-schemas', () => {
+ return {
+ logger: {
+ info: jest.fn(),
+ warn: jest.fn(),
+ debug: jest.fn(),
+ error: jest.fn(),
+ },
+ };
+});
+
+const processFileURL = jest.fn();
+
+const generate = jest.fn();
+OpenAI.mockImplementation(() => ({
+ images: {
+ generate,
+ },
+}));
+
+jest.mock('fs', () => {
+ return {
+ existsSync: jest.fn(),
+ mkdirSync: jest.fn(),
+ promises: {
+ writeFile: jest.fn(),
+ readFile: jest.fn(),
+ unlink: jest.fn(),
+ },
+ };
+});
+
+jest.mock('path', () => {
+ return {
+ resolve: jest.fn(),
+ join: jest.fn(),
+ relative: jest.fn(),
+ extname: jest.fn().mockImplementation((filename) => {
+ return filename.slice(filename.lastIndexOf('.'));
+ }),
+ };
+});
+
+describe('DALLE3', () => {
+ let originalEnv;
+ let dalle; // Keep this declaration if you need to use dalle in other tests
+ const mockApiKey = 'mock_api_key';
+
+ beforeAll(() => {
+ // Save the original process.env
+ originalEnv = { ...process.env };
+ });
+
+ beforeEach(() => {
+ // Reset the process.env before each test
+ jest.resetModules();
+ process.env = { ...originalEnv, DALLE_API_KEY: mockApiKey };
+ // Instantiate DALLE3 for tests that do not depend on DALLE3_SYSTEM_PROMPT
+ dalle = new DALLE3({ processFileURL });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ // Restore the original process.env after each test
+ process.env = originalEnv;
+ });
+
+ it('should throw an error if all potential API keys are missing', () => {
+ delete process.env.DALLE3_API_KEY;
+ delete process.env.DALLE_API_KEY;
+ expect(() => new DALLE3()).toThrow('Missing DALLE_API_KEY environment variable.');
+ });
+
+ it('should replace unwanted characters in input string', () => {
+ const input = 'This is a test\nstring with "quotes" and new lines.';
+ const expectedOutput = 'This is a test string with quotes and new lines.';
+ expect(dalle.replaceUnwantedChars(input)).toBe(expectedOutput);
+ });
+
+ it('should generate markdown image URL correctly', () => {
+ const imageName = 'test.png';
+ const markdownImage = dalle.wrapInMarkdown(imageName);
+ expect(markdownImage).toBe('');
+ });
+
+ it('should call OpenAI API with correct parameters', async () => {
+ const mockData = {
+ prompt: 'A test prompt',
+ quality: 'standard',
+ size: '1024x1024',
+ style: 'vivid',
+ };
+
+ const mockResponse = {
+ data: [
+ {
+ url: 'http://example.com/img-test.png',
+ },
+ ],
+ };
+
+ generate.mockResolvedValue(mockResponse);
+ processFileURL.mockResolvedValue({
+ filepath: 'http://example.com/img-test.png',
+ });
+
+ const result = await dalle._call(mockData);
+
+ expect(generate).toHaveBeenCalledWith({
+ model: 'dall-e-3',
+ quality: mockData.quality,
+ style: mockData.style,
+ size: mockData.size,
+ prompt: mockData.prompt,
+ n: 1,
+ });
+
+ expect(result).toContain('![generated image]');
+ });
+
+ it('should use the system prompt if provided', () => {
+ process.env.DALLE3_SYSTEM_PROMPT = 'System prompt for testing';
+ jest.resetModules(); // This will ensure the module is fresh and will read the new env var
+ const DALLE3 = require('../DALLE3'); // Re-require after setting the env var
+ const dalleWithSystemPrompt = new DALLE3();
+ expect(dalleWithSystemPrompt.description_for_model).toBe('System prompt for testing');
+ });
+
+ it('should not use the system prompt if not provided', async () => {
+ delete process.env.DALLE3_SYSTEM_PROMPT;
+ const dalleWithoutSystemPrompt = new DALLE3();
+ expect(dalleWithoutSystemPrompt.description_for_model).not.toBe('System prompt for testing');
+ });
+
+ it('should throw an error if prompt is missing', async () => {
+ const mockData = {
+ quality: 'standard',
+ size: '1024x1024',
+ style: 'vivid',
+ };
+ await expect(dalle._call(mockData)).rejects.toThrow('Missing required field: prompt');
+ });
+
+ it('should log appropriate debug values', async () => {
+ const mockData = {
+ prompt: 'A test prompt',
+ };
+ const mockResponse = {
+ data: [
+ {
+ url: 'http://example.com/invalid-url',
+ },
+ ],
+ };
+
+ generate.mockResolvedValue(mockResponse);
+ await dalle._call(mockData);
+ expect(logger.debug).toHaveBeenCalledWith('[DALL-E-3]', {
+ data: { url: 'http://example.com/invalid-url' },
+ theImageUrl: 'http://example.com/invalid-url',
+ extension: expect.any(String),
+ imageBasename: expect.any(String),
+ imageExt: expect.any(String),
+ imageName: expect.any(String),
+ });
+ });
+
+ it('should log an error and return the image URL if there is an error saving the image', async () => {
+ const mockData = {
+ prompt: 'A test prompt',
+ };
+ const mockResponse = {
+ data: [
+ {
+ url: 'http://example.com/img-test.png',
+ },
+ ],
+ };
+ const error = new Error('Error while saving the image');
+ generate.mockResolvedValue(mockResponse);
+ processFileURL.mockRejectedValue(error);
+ const result = await dalle._call(mockData);
+ expect(logger.error).toHaveBeenCalledWith('Error while saving the image:', error);
+ expect(result).toBe('Failed to save the image locally. Error while saving the image');
+ });
+
+ it('should handle error when saving image to Firebase Storage fails', async () => {
+ const mockData = {
+ prompt: 'A test prompt',
+ };
+ const mockImageUrl = 'http://example.com/img-test.png';
+ const mockResponse = { data: [{ url: mockImageUrl }] };
+ const error = new Error('Error while saving to Firebase');
+ generate.mockResolvedValue(mockResponse);
+ processFileURL.mockRejectedValue(error);
+
+ const result = await dalle._call(mockData);
+
+ expect(logger.error).toHaveBeenCalledWith('Error while saving the image:', error);
+ expect(result).toContain('Failed to save the image');
+ });
+});
diff --git a/api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js b/api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js
new file mode 100644
index 0000000..dbdda6e
--- /dev/null
+++ b/api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js
@@ -0,0 +1,123 @@
+/**
+ * These tests verify the proxy wrapper behavior for GeminiImageGen.
+ * Instead of loading the full module (which has many dependencies),
+ * we directly test the wrapper logic that would be applied.
+ */
+describe('GeminiImageGen Proxy Configuration', () => {
+ let originalEnv;
+ let originalFetch;
+
+ beforeAll(() => {
+ originalEnv = { ...process.env };
+ originalFetch = globalThis.fetch;
+ });
+
+ beforeEach(() => {
+ process.env = { ...originalEnv };
+ globalThis.fetch = originalFetch;
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ globalThis.fetch = originalFetch;
+ });
+
+ /**
+ * Simulates the proxy wrapper that GeminiImageGen applies at module load.
+ * This is the same logic from GeminiImageGen.js lines 30-42.
+ */
+ function applyProxyWrapper() {
+ const proxyDispatcher = process.env.PROXY ? { type: 'proxy-dispatcher' } : undefined;
+ if (proxyDispatcher) {
+ const _originalFetch = globalThis.fetch;
+
+ globalThis.fetch = function (url, options = {}) {
+ const urlString = url.toString();
+ if (urlString.includes('googleapis.com')) {
+ options = { ...options, dispatcher: proxyDispatcher };
+ }
+ return _originalFetch.call(this, url, options);
+ };
+ }
+ }
+
+ it('should wrap globalThis.fetch when PROXY env is set', () => {
+ process.env.PROXY = 'http://proxy.example.com:8080';
+
+ const fetchBeforeWrap = globalThis.fetch;
+
+ applyProxyWrapper();
+
+ expect(globalThis.fetch).not.toBe(fetchBeforeWrap);
+ });
+
+ it('should not wrap globalThis.fetch when PROXY env is not set', () => {
+ delete process.env.PROXY;
+
+ const fetchBeforeWrap = globalThis.fetch;
+
+ applyProxyWrapper();
+
+ expect(globalThis.fetch).toBe(fetchBeforeWrap);
+ });
+
+ it('should add dispatcher to googleapis.com URLs', async () => {
+ process.env.PROXY = 'http://proxy.example.com:8080';
+
+ let capturedOptions = null;
+ const mockFetch = jest.fn((url, options) => {
+ capturedOptions = options;
+ return Promise.resolve({ ok: true });
+ });
+ globalThis.fetch = mockFetch;
+
+ applyProxyWrapper();
+
+ await globalThis.fetch('https://generativelanguage.googleapis.com/v1/models', {});
+
+ expect(capturedOptions).toBeDefined();
+ expect(capturedOptions.dispatcher).toEqual({ type: 'proxy-dispatcher' });
+ });
+
+ it('should not add dispatcher to non-googleapis.com URLs', async () => {
+ process.env.PROXY = 'http://proxy.example.com:8080';
+
+ let capturedOptions = null;
+ const mockFetch = jest.fn((url, options) => {
+ capturedOptions = options;
+ return Promise.resolve({ ok: true });
+ });
+ globalThis.fetch = mockFetch;
+
+ applyProxyWrapper();
+
+ await globalThis.fetch('https://api.openai.com/v1/images', {});
+
+ expect(capturedOptions).toBeDefined();
+ expect(capturedOptions.dispatcher).toBeUndefined();
+ });
+
+ it('should preserve existing options when adding dispatcher', async () => {
+ process.env.PROXY = 'http://proxy.example.com:8080';
+
+ let capturedOptions = null;
+ const mockFetch = jest.fn((url, options) => {
+ capturedOptions = options;
+ return Promise.resolve({ ok: true });
+ });
+ globalThis.fetch = mockFetch;
+
+ applyProxyWrapper();
+
+ const customHeaders = { 'X-Custom-Header': 'test' };
+ await globalThis.fetch('https://aiplatform.googleapis.com/v1/models', {
+ headers: customHeaders,
+ method: 'POST',
+ });
+
+ expect(capturedOptions).toBeDefined();
+ expect(capturedOptions.dispatcher).toEqual({ type: 'proxy-dispatcher' });
+ expect(capturedOptions.headers).toEqual(customHeaders);
+ expect(capturedOptions.method).toBe('POST');
+ });
+});
diff --git a/api/app/clients/tools/structured/specs/GoogleSearch.spec.js b/api/app/clients/tools/structured/specs/GoogleSearch.spec.js
new file mode 100644
index 0000000..781e9b7
--- /dev/null
+++ b/api/app/clients/tools/structured/specs/GoogleSearch.spec.js
@@ -0,0 +1,49 @@
+const GoogleSearch = require('../GoogleSearch');
+
+jest.mock('node-fetch');
+
+describe('GoogleSearch', () => {
+ let originalEnv;
+ const mockApiKey = 'mock_api';
+ const mockSearchEngineId = 'mock_search_engine_id';
+
+ beforeAll(() => {
+ originalEnv = { ...process.env };
+ });
+
+ beforeEach(() => {
+ jest.resetModules();
+ process.env = {
+ ...originalEnv,
+ GOOGLE_SEARCH_API_KEY: mockApiKey,
+ GOOGLE_CSE_ID: mockSearchEngineId,
+ };
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ process.env = originalEnv;
+ });
+
+ it('should use mockApiKey and mockSearchEngineId when environment variables are not set', () => {
+ const instance = new GoogleSearch({
+ GOOGLE_SEARCH_API_KEY: mockApiKey,
+ GOOGLE_CSE_ID: mockSearchEngineId,
+ });
+ expect(instance.apiKey).toBe(mockApiKey);
+ expect(instance.searchEngineId).toBe(mockSearchEngineId);
+ });
+
+ it('should throw an error if GOOGLE_SEARCH_API_KEY or GOOGLE_CSE_ID is missing', () => {
+ delete process.env.GOOGLE_SEARCH_API_KEY;
+ expect(() => new GoogleSearch()).toThrow(
+ 'Missing GOOGLE_SEARCH_API_KEY or GOOGLE_CSE_ID environment variable.',
+ );
+
+ process.env.GOOGLE_SEARCH_API_KEY = mockApiKey;
+ delete process.env.GOOGLE_CSE_ID;
+ expect(() => new GoogleSearch()).toThrow(
+ 'Missing GOOGLE_SEARCH_API_KEY or GOOGLE_CSE_ID environment variable.',
+ );
+ });
+});
diff --git a/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js b/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js
new file mode 100644
index 0000000..7184e08
--- /dev/null
+++ b/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js
@@ -0,0 +1,84 @@
+const { fetch } = require('undici');
+const TavilySearchResults = require('../TavilySearchResults');
+const { getEnvProxyDispatcher } = require('@librechat/api');
+
+jest.mock('undici');
+jest.mock('@librechat/api', () => ({
+ getEnvProxyDispatcher: jest.fn(),
+}));
+
+describe('TavilySearchResults', () => {
+ let originalEnv;
+ const mockApiKey = 'mock_api_key';
+
+ beforeAll(() => {
+ originalEnv = { ...process.env };
+ });
+
+ beforeEach(() => {
+ jest.resetModules();
+ jest.clearAllMocks();
+ process.env = {
+ ...originalEnv,
+ TAVILY_API_KEY: mockApiKey,
+ };
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ it('should throw an error if TAVILY_API_KEY is missing', () => {
+ delete process.env.TAVILY_API_KEY;
+ expect(() => new TavilySearchResults()).toThrow('Missing TAVILY_API_KEY environment variable.');
+ });
+
+ it('should use mockApiKey when TAVILY_API_KEY is not set in the environment', () => {
+ const instance = new TavilySearchResults({
+ TAVILY_API_KEY: mockApiKey,
+ });
+ expect(instance.apiKey).toBe(mockApiKey);
+ });
+
+ describe('proxy support', () => {
+ const mockResponse = {
+ ok: true,
+ json: jest.fn().mockResolvedValue({ results: [] }),
+ };
+
+ beforeEach(() => {
+ fetch.mockResolvedValue(mockResponse);
+ });
+
+ it('should use a shared proxy dispatcher when configured', async () => {
+ const mockProxyDispatcher = { type: 'proxy-dispatcher' };
+ getEnvProxyDispatcher.mockReturnValue(mockProxyDispatcher);
+
+ const instance = new TavilySearchResults({ TAVILY_API_KEY: mockApiKey });
+ await instance._call({ query: 'test query' });
+
+ expect(getEnvProxyDispatcher).toHaveBeenCalled();
+ expect(fetch).toHaveBeenCalledWith(
+ 'https://api.tavily.com/search',
+ expect.objectContaining({
+ dispatcher: mockProxyDispatcher,
+ }),
+ );
+ });
+
+ it('should not attach a dispatcher when no proxy is configured', async () => {
+ getEnvProxyDispatcher.mockReturnValue(undefined);
+
+ const instance = new TavilySearchResults({ TAVILY_API_KEY: mockApiKey });
+ await instance._call({ query: 'test query' });
+
+ expect(getEnvProxyDispatcher).toHaveBeenCalled();
+ expect(fetch).toHaveBeenCalledWith(
+ 'https://api.tavily.com/search',
+ expect.not.objectContaining({
+ dispatcher: expect.anything(),
+ }),
+ );
+ });
+ });
+});
diff --git a/api/app/clients/tools/structured/specs/imageTools-agent.spec.js b/api/app/clients/tools/structured/specs/imageTools-agent.spec.js
new file mode 100644
index 0000000..2d36ad4
--- /dev/null
+++ b/api/app/clients/tools/structured/specs/imageTools-agent.spec.js
@@ -0,0 +1,399 @@
+/**
+ * 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 { ContentTypes } = require('librechat-data-provider');
+const { ToolMessage } = require('@librechat/agents/langchain/messages');
+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('keeps tenant context without retaining the request object', () => {
+ const req = {
+ user: { id: 'user-1', tenantId: 'tenant-a' },
+ body: { conversationId: 'convo-1', isTemporary: 'true' },
+ config: { interfaceConfig: { retentionMode: 'all' } },
+ socket: {},
+ };
+ const dalle = new DALLE3({ isAgent: false, processFileURL: jest.fn(), req });
+
+ expect(dalle.tenantId).toBe('tenant-a');
+ expect(dalle.req).toBeUndefined();
+ expect(dalle.retentionRequest).toEqual({
+ user: { id: 'user-1', tenantId: 'tenant-a' },
+ body: { conversationId: 'convo-1', isTemporary: 'true' },
+ config: { interfaceConfig: { retentionMode: 'all' } },
+ });
+ });
+
+ 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('keeps tenant context without retaining the request object', () => {
+ const req = {
+ user: { id: 'user-1', tenantId: 'tenant-a' },
+ body: { conversationId: 'convo-1', isTemporary: 'true' },
+ config: { interfaceConfig: { retentionMode: 'all' } },
+ socket: {},
+ };
+ const flux = new FluxAPI({ isAgent: false, processFileURL: jest.fn(), req });
+
+ expect(flux.tenantId).toBe('tenant-a');
+ expect(flux.req).toBeUndefined();
+ expect(flux.retentionRequest).toEqual({
+ user: { id: 'user-1', tenantId: 'tenant-a' },
+ body: { conversationId: 'convo-1', isTemporary: 'true' },
+ config: { interfaceConfig: { retentionMode: 'all' } },
+ });
+ });
+
+ it('passes minimal retention context when saving generated images', async () => {
+ const processFileURL = jest.fn().mockResolvedValue({ filepath: '/images/generated.png' });
+ const req = {
+ user: { id: 'user-1', tenantId: 'tenant-a' },
+ body: { conversationId: 'convo-1', isTemporary: 'true' },
+ config: { interfaceConfig: { retentionMode: 'all' } },
+ socket: {},
+ };
+ const flux = new FluxAPI({
+ isAgent: false,
+ processFileURL,
+ req,
+ userId: 'user-1',
+ fileStrategy: 'local',
+ });
+ const invokePromise = flux.invoke(
+ makeToolCall('flux', { prompt: 'a box', endpoint: '/v1/flux-dev' }),
+ );
+ await jest.runAllTimersAsync();
+ await invokePromise;
+
+ expect(processFileURL).toHaveBeenCalledWith(
+ expect.objectContaining({
+ req: {
+ user: { id: 'user-1', tenantId: 'tenant-a' },
+ body: { conversationId: 'convo-1', isTemporary: 'true' },
+ config: { interfaceConfig: { retentionMode: 'all' } },
+ },
+ }),
+ );
+ });
+
+ it('passes minimal retention context when saving finetuned generated images', async () => {
+ const processFileURL = jest.fn().mockResolvedValue({ filepath: '/images/generated.png' });
+ const req = {
+ user: { id: 'user-1', tenantId: 'tenant-a' },
+ body: { conversationId: 'convo-1', isTemporary: 'true' },
+ config: { interfaceConfig: { retentionMode: 'all' } },
+ socket: {},
+ };
+ const flux = new FluxAPI({
+ isAgent: false,
+ processFileURL,
+ req,
+ userId: 'user-1',
+ fileStrategy: 'local',
+ });
+ const invokePromise = flux.invoke(
+ makeToolCall('flux', {
+ action: 'generate_finetuned',
+ prompt: 'a box',
+ finetune_id: 'ft-abc123',
+ endpoint: '/v1/flux-pro-finetuned',
+ }),
+ );
+ await jest.runAllTimersAsync();
+ await invokePromise;
+
+ expect(processFileURL).toHaveBeenCalledWith(
+ expect.objectContaining({
+ req: {
+ user: { id: 'user-1', tenantId: 'tenant-a' },
+ body: { conversationId: 'convo-1', isTemporary: 'true' },
+ config: { interfaceConfig: { retentionMode: 'all' } },
+ },
+ }),
+ );
+ });
+
+ 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');
+ });
+ });
+});
diff --git a/api/app/clients/tools/structured/specs/openWeather.integration.test.js b/api/app/clients/tools/structured/specs/openWeather.integration.test.js
new file mode 100644
index 0000000..07dd417
--- /dev/null
+++ b/api/app/clients/tools/structured/specs/openWeather.integration.test.js
@@ -0,0 +1,224 @@
+// __tests__/openWeather.integration.test.js
+const OpenWeather = require('../OpenWeather');
+
+describe('OpenWeather Tool (Integration Test)', () => {
+ let tool;
+
+ beforeAll(() => {
+ tool = new OpenWeather({ override: true });
+ console.log('API Key present:', !!process.env.OPENWEATHER_API_KEY);
+ });
+
+ test('current_forecast with a real API key returns current weather', async () => {
+ // Check if API key is available
+ if (!process.env.OPENWEATHER_API_KEY) {
+ console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
+ return;
+ }
+
+ try {
+ const result = await tool.call({
+ action: 'current_forecast',
+ city: 'London',
+ units: 'Celsius',
+ });
+
+ console.log('Raw API response:', result);
+
+ const parsed = JSON.parse(result);
+ expect(parsed).toHaveProperty('current');
+ expect(typeof parsed.current.temp).toBe('number');
+ } catch (error) {
+ console.error('Test failed with error:', error);
+ throw error;
+ }
+ });
+
+ test('timestamp action with real API key returns historical data', async () => {
+ if (!process.env.OPENWEATHER_API_KEY) {
+ console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
+ return;
+ }
+
+ try {
+ // Use a date from yesterday to ensure data availability
+ const yesterday = new Date();
+ yesterday.setDate(yesterday.getDate() - 1);
+ const dateStr = yesterday.toISOString().split('T')[0];
+
+ const result = await tool.call({
+ action: 'timestamp',
+ city: 'London',
+ date: dateStr,
+ units: 'Celsius',
+ });
+
+ console.log('Timestamp API response:', result);
+
+ const parsed = JSON.parse(result);
+ expect(parsed).toHaveProperty('data');
+ expect(Array.isArray(parsed.data)).toBe(true);
+ expect(parsed.data[0]).toHaveProperty('temp');
+ } catch (error) {
+ console.error('Timestamp test failed with error:', error);
+ throw error;
+ }
+ });
+
+ test('daily_aggregation action with real API key returns aggregated data', async () => {
+ if (!process.env.OPENWEATHER_API_KEY) {
+ console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
+ return;
+ }
+
+ try {
+ // Use yesterday's date for aggregation
+ const yesterday = new Date();
+ yesterday.setDate(yesterday.getDate() - 1);
+ const dateStr = yesterday.toISOString().split('T')[0];
+
+ const result = await tool.call({
+ action: 'daily_aggregation',
+ city: 'London',
+ date: dateStr,
+ units: 'Celsius',
+ });
+
+ console.log('Daily aggregation API response:', result);
+
+ const parsed = JSON.parse(result);
+ expect(parsed).toHaveProperty('temperature');
+ expect(parsed.temperature).toHaveProperty('morning');
+ expect(parsed.temperature).toHaveProperty('afternoon');
+ expect(parsed.temperature).toHaveProperty('evening');
+ } catch (error) {
+ console.error('Daily aggregation test failed with error:', error);
+ throw error;
+ }
+ });
+
+ test('overview action with real API key returns weather summary', async () => {
+ if (!process.env.OPENWEATHER_API_KEY) {
+ console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
+ return;
+ }
+
+ try {
+ const result = await tool.call({
+ action: 'overview',
+ city: 'London',
+ units: 'Celsius',
+ });
+
+ console.log('Overview API response:', result);
+
+ const parsed = JSON.parse(result);
+ expect(parsed).toHaveProperty('weather_overview');
+ expect(typeof parsed.weather_overview).toBe('string');
+ expect(parsed.weather_overview.length).toBeGreaterThan(0);
+ expect(parsed).toHaveProperty('date');
+ expect(parsed).toHaveProperty('units');
+ expect(parsed.units).toBe('metric');
+ } catch (error) {
+ console.error('Overview test failed with error:', error);
+ throw error;
+ }
+ });
+
+ test('different temperature units return correct values', async () => {
+ if (!process.env.OPENWEATHER_API_KEY) {
+ console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
+ return;
+ }
+
+ try {
+ // Test Celsius
+ let result = await tool.call({
+ action: 'current_forecast',
+ city: 'London',
+ units: 'Celsius',
+ });
+ let parsed = JSON.parse(result);
+ const celsiusTemp = parsed.current.temp;
+
+ // Test Kelvin
+ result = await tool.call({
+ action: 'current_forecast',
+ city: 'London',
+ units: 'Kelvin',
+ });
+ parsed = JSON.parse(result);
+ const kelvinTemp = parsed.current.temp;
+
+ // Test Fahrenheit
+ result = await tool.call({
+ action: 'current_forecast',
+ city: 'London',
+ units: 'Fahrenheit',
+ });
+ parsed = JSON.parse(result);
+ const fahrenheitTemp = parsed.current.temp;
+
+ // Verify temperature conversions are roughly correct
+ // K = C + 273.15
+ // F = (C * 9/5) + 32
+ const celsiusToKelvin = Math.round(celsiusTemp + 273.15);
+ const celsiusToFahrenheit = Math.round((celsiusTemp * 9) / 5 + 32);
+
+ console.log('Temperature comparisons:', {
+ celsius: celsiusTemp,
+ kelvin: kelvinTemp,
+ fahrenheit: fahrenheitTemp,
+ calculatedKelvin: celsiusToKelvin,
+ calculatedFahrenheit: celsiusToFahrenheit,
+ });
+
+ // Allow for some rounding differences
+ expect(Math.abs(kelvinTemp - celsiusToKelvin)).toBeLessThanOrEqual(1);
+ expect(Math.abs(fahrenheitTemp - celsiusToFahrenheit)).toBeLessThanOrEqual(1);
+ } catch (error) {
+ console.error('Temperature units test failed with error:', error);
+ throw error;
+ }
+ });
+
+ test('language parameter returns localized data', async () => {
+ if (!process.env.OPENWEATHER_API_KEY) {
+ console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
+ return;
+ }
+
+ try {
+ // Test with English
+ let result = await tool.call({
+ action: 'current_forecast',
+ city: 'Paris',
+ units: 'Celsius',
+ lang: 'en',
+ });
+ let parsed = JSON.parse(result);
+ const englishDescription = parsed.current.weather[0].description;
+
+ // Test with French
+ result = await tool.call({
+ action: 'current_forecast',
+ city: 'Paris',
+ units: 'Celsius',
+ lang: 'fr',
+ });
+ parsed = JSON.parse(result);
+ const frenchDescription = parsed.current.weather[0].description;
+
+ console.log('Language comparison:', {
+ english: englishDescription,
+ french: frenchDescription,
+ });
+
+ // Verify descriptions are different (indicating translation worked)
+ expect(englishDescription).not.toBe(frenchDescription);
+ } catch (error) {
+ console.error('Language test failed with error:', error);
+ throw error;
+ }
+ });
+});
diff --git a/api/app/clients/tools/structured/specs/openweather.test.js b/api/app/clients/tools/structured/specs/openweather.test.js
new file mode 100644
index 0000000..3340c80
--- /dev/null
+++ b/api/app/clients/tools/structured/specs/openweather.test.js
@@ -0,0 +1,358 @@
+// __tests__/openweather.test.js
+const OpenWeather = require('../OpenWeather');
+const fetch = require('node-fetch');
+
+// Mock environment variable
+process.env.OPENWEATHER_API_KEY = 'test-api-key';
+
+// Mock the fetch function globally
+jest.mock('node-fetch', () => jest.fn());
+
+describe('OpenWeather Tool', () => {
+ let tool;
+
+ beforeAll(() => {
+ tool = new OpenWeather();
+ });
+
+ beforeEach(() => {
+ fetch.mockReset();
+ });
+
+ test('action=help returns help instructions', async () => {
+ const result = await tool.call({
+ action: 'help',
+ });
+
+ expect(typeof result).toBe('string');
+ const parsed = JSON.parse(result);
+ expect(parsed.title).toBe('OpenWeather One Call API 3.0 Help');
+ });
+
+ test('current_forecast with a city and successful geocoding + forecast', async () => {
+ // Mock geocoding response
+ fetch.mockImplementationOnce((url) => {
+ if (url.includes('geo/1.0/direct')) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => [{ lat: 35.9606, lon: -83.9207 }],
+ });
+ }
+ return Promise.reject('Unexpected fetch call for geocoding');
+ });
+
+ // Mock forecast response
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ json: async () => ({
+ current: { temp: 293.15, feels_like: 295.15 },
+ daily: [{ temp: { day: 293.15, night: 283.15 } }],
+ }),
+ }),
+ );
+
+ const result = await tool.call({
+ action: 'current_forecast',
+ city: 'Knoxville, Tennessee',
+ units: 'Kelvin',
+ });
+
+ const parsed = JSON.parse(result);
+ expect(parsed.current.temp).toBe(293);
+ expect(parsed.current.feels_like).toBe(295);
+ expect(parsed.daily[0].temp.day).toBe(293);
+ expect(parsed.daily[0].temp.night).toBe(283);
+ });
+
+ test('timestamp action with valid date returns mocked historical data', async () => {
+ // Mock geocoding response
+ fetch.mockImplementationOnce((url) => {
+ if (url.includes('geo/1.0/direct')) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => [{ lat: 35.9606, lon: -83.9207 }],
+ });
+ }
+ return Promise.reject('Unexpected fetch call for geocoding');
+ });
+
+ // Mock historical weather response
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ json: async () => ({
+ data: [
+ {
+ dt: 1583280000,
+ temp: 283.15,
+ feels_like: 280.15,
+ humidity: 75,
+ weather: [{ description: 'clear sky' }],
+ },
+ ],
+ }),
+ }),
+ );
+
+ const result = await tool.call({
+ action: 'timestamp',
+ city: 'Knoxville, Tennessee',
+ date: '2020-03-04',
+ units: 'Kelvin',
+ });
+
+ const parsed = JSON.parse(result);
+ expect(parsed.data[0].temp).toBe(283);
+ expect(parsed.data[0].feels_like).toBe(280);
+ });
+
+ test('daily_aggregation action returns aggregated weather data', async () => {
+ // Mock geocoding response
+ fetch.mockImplementationOnce((url) => {
+ if (url.includes('geo/1.0/direct')) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => [{ lat: 35.9606, lon: -83.9207 }],
+ });
+ }
+ return Promise.reject('Unexpected fetch call for geocoding');
+ });
+
+ // Mock daily aggregation response
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ json: async () => ({
+ date: '2020-03-04',
+ temperature: {
+ morning: 283.15,
+ afternoon: 293.15,
+ evening: 288.15,
+ },
+ humidity: {
+ morning: 75,
+ afternoon: 60,
+ evening: 70,
+ },
+ }),
+ }),
+ );
+
+ const result = await tool.call({
+ action: 'daily_aggregation',
+ city: 'Knoxville, Tennessee',
+ date: '2020-03-04',
+ units: 'Kelvin',
+ });
+
+ const parsed = JSON.parse(result);
+ expect(parsed.temperature.morning).toBe(283);
+ expect(parsed.temperature.afternoon).toBe(293);
+ expect(parsed.temperature.evening).toBe(288);
+ });
+
+ test('overview action returns weather summary', async () => {
+ // Mock geocoding response
+ fetch.mockImplementationOnce((url) => {
+ if (url.includes('geo/1.0/direct')) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => [{ lat: 35.9606, lon: -83.9207 }],
+ });
+ }
+ return Promise.reject('Unexpected fetch call for geocoding');
+ });
+
+ // Mock overview response
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ json: async () => ({
+ date: '2024-01-07',
+ lat: 35.9606,
+ lon: -83.9207,
+ tz: '+00:00',
+ units: 'metric',
+ weather_overview:
+ 'Currently, the temperature is 2°C with a real feel of -2°C. The sky is clear with moderate wind.',
+ }),
+ }),
+ );
+
+ const result = await tool.call({
+ action: 'overview',
+ city: 'Knoxville, Tennessee',
+ units: 'Celsius',
+ });
+
+ const parsed = JSON.parse(result);
+ expect(parsed).toHaveProperty('weather_overview');
+ expect(typeof parsed.weather_overview).toBe('string');
+ expect(parsed.weather_overview.length).toBeGreaterThan(0);
+ expect(parsed).toHaveProperty('date');
+ expect(parsed).toHaveProperty('units');
+ expect(parsed.units).toBe('metric');
+ });
+
+ test('temperature units are correctly converted', async () => {
+ // Mock geocoding response for all three calls
+ const geocodingMock = Promise.resolve({
+ ok: true,
+ json: async () => [{ lat: 35.9606, lon: -83.9207 }],
+ });
+
+ // Mock weather response for Kelvin
+ const kelvinMock = Promise.resolve({
+ ok: true,
+ json: async () => ({
+ current: { temp: 293.15 },
+ }),
+ });
+
+ // Mock weather response for Celsius
+ const celsiusMock = Promise.resolve({
+ ok: true,
+ json: async () => ({
+ current: { temp: 20 },
+ }),
+ });
+
+ // Mock weather response for Fahrenheit
+ const fahrenheitMock = Promise.resolve({
+ ok: true,
+ json: async () => ({
+ current: { temp: 68 },
+ }),
+ });
+
+ // Test Kelvin
+ fetch.mockImplementationOnce(() => geocodingMock).mockImplementationOnce(() => kelvinMock);
+
+ let result = await tool.call({
+ action: 'current_forecast',
+ city: 'Knoxville, Tennessee',
+ units: 'Kelvin',
+ });
+ let parsed = JSON.parse(result);
+ expect(parsed.current.temp).toBe(293);
+
+ // Test Celsius
+ fetch.mockImplementationOnce(() => geocodingMock).mockImplementationOnce(() => celsiusMock);
+
+ result = await tool.call({
+ action: 'current_forecast',
+ city: 'Knoxville, Tennessee',
+ units: 'Celsius',
+ });
+ parsed = JSON.parse(result);
+ expect(parsed.current.temp).toBe(20);
+
+ // Test Fahrenheit
+ fetch.mockImplementationOnce(() => geocodingMock).mockImplementationOnce(() => fahrenheitMock);
+
+ result = await tool.call({
+ action: 'current_forecast',
+ city: 'Knoxville, Tennessee',
+ units: 'Fahrenheit',
+ });
+ parsed = JSON.parse(result);
+ expect(parsed.current.temp).toBe(68);
+ });
+
+ test('timestamp action without a date returns an error message', async () => {
+ const result = await tool.call({
+ action: 'timestamp',
+ lat: 35.9606,
+ lon: -83.9207,
+ });
+ expect(result).toMatch(
+ /Error: For timestamp action, a 'date' in YYYY-MM-DD format is required./,
+ );
+ });
+
+ test('daily_aggregation action without a date returns an error message', async () => {
+ const result = await tool.call({
+ action: 'daily_aggregation',
+ lat: 35.9606,
+ lon: -83.9207,
+ });
+ expect(result).toMatch(/Error: date \(YYYY-MM-DD\) is required for daily_aggregation action./);
+ });
+
+ test('unknown action returns an error due to schema validation', async () => {
+ await expect(
+ tool.call({
+ action: 'unknown_action',
+ }),
+ ).rejects.toThrow(/Received tool input did not match expected schema/);
+ });
+
+ test('geocoding failure returns a descriptive error', async () => {
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ json: async () => [],
+ }),
+ );
+
+ const result = await tool.call({
+ action: 'current_forecast',
+ city: 'NowhereCity',
+ });
+ expect(result).toMatch(/Error: Could not find coordinates for city: NowhereCity/);
+ });
+
+ test('API request failure returns an error', async () => {
+ // Mock geocoding success
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ json: async () => [{ lat: 35.9606, lon: -83.9207 }],
+ }),
+ );
+
+ // Mock weather request failure
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: false,
+ status: 404,
+ json: async () => ({ message: 'Not found' }),
+ }),
+ );
+
+ const result = await tool.call({
+ action: 'current_forecast',
+ city: 'Knoxville, Tennessee',
+ });
+ expect(result).toMatch(/Error: OpenWeather API request failed with status 404: Not found/);
+ });
+
+ test('invalid date format returns an error', async () => {
+ // Mock geocoding response first
+ fetch.mockImplementationOnce((url) => {
+ if (url.includes('geo/1.0/direct')) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => [{ lat: 35.9606, lon: -83.9207 }],
+ });
+ }
+ return Promise.reject('Unexpected fetch call for geocoding');
+ });
+
+ // Mock timestamp API response
+ fetch.mockImplementationOnce((url) => {
+ if (url.includes('onecall/timemachine')) {
+ throw new Error('Invalid date format. Expected YYYY-MM-DD.');
+ }
+ return Promise.reject('Unexpected fetch call');
+ });
+
+ const result = await tool.call({
+ action: 'timestamp',
+ city: 'Knoxville, Tennessee',
+ date: '03-04-2020', // Wrong format
+ });
+ expect(result).toMatch(/Error: Invalid date format. Expected YYYY-MM-DD./);
+ });
+});
diff --git a/api/app/clients/tools/util/fileSearch.js b/api/app/clients/tools/util/fileSearch.js
new file mode 100644
index 0000000..ceb87ee
--- /dev/null
+++ b/api/app/clients/tools/util/fileSearch.js
@@ -0,0 +1,212 @@
+const axios = require('axios');
+const { logger } = require('@librechat/data-schemas');
+const { tool } = require('@librechat/agents/langchain/tools');
+const { generateShortLivedToken, logAxiosError } = require('@librechat/api');
+const { Tools, EToolResources } = require('librechat-data-provider');
+const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
+const { getFiles } = require('~/models');
+
+const fileSearchJsonSchema = {
+ type: 'object',
+ properties: {
+ query: {
+ type: 'string',
+ description:
+ "A natural language query to search for relevant information in the files. Be specific and use keywords related to the information you're looking for. The query will be used for semantic similarity matching against the file contents.",
+ },
+ },
+ required: ['query'],
+};
+
+/**
+ *
+ * @param {Object} options
+ * @param {ServerRequest} options.req
+ * @param {Agent['tool_resources']} options.tool_resources
+ * @param {string} [options.agentId] - The agent ID for file access control
+ * @returns {Promise<{
+ * files: Array<{ file_id: string; filename: string; fromAgent: boolean }>,
+ * toolContext: string
+ * }>}
+ */
+const primeFiles = async (options) => {
+ const { tool_resources, req, agentId } = options;
+ const file_ids = tool_resources?.[EToolResources.file_search]?.file_ids ?? [];
+ const agentResourceIds = new Set(file_ids);
+ const resourceFiles = tool_resources?.[EToolResources.file_search]?.files ?? [];
+
+ // Get all files first
+ const allFiles = (await getFiles({ file_id: { $in: file_ids } }, null, { text: 0 })) ?? [];
+
+ // Filter by access if user and agent are provided
+ let dbFiles;
+ if (req?.user?.id && agentId) {
+ dbFiles = await filterFilesByAgentAccess({
+ files: allFiles,
+ userId: req.user.id,
+ role: req.user.role,
+ agentId,
+ });
+ } else {
+ dbFiles = allFiles;
+ }
+
+ dbFiles = dbFiles.concat(resourceFiles);
+
+ let toolContext = `- Note: Semantic search is available through the ${Tools.file_search} tool but no files are currently loaded. Request the user to upload documents to search through.`;
+
+ const files = [];
+ for (let i = 0; i < dbFiles.length; i++) {
+ const file = dbFiles[i];
+ if (!file) {
+ continue;
+ }
+ if (i === 0) {
+ toolContext = `- Note: Use the ${Tools.file_search} tool to find relevant information within:`;
+ }
+ toolContext += `\n\t- ${file.filename}${
+ agentResourceIds.has(file.file_id) ? '' : ' (just attached by user)'
+ }`;
+ files.push({
+ file_id: file.file_id,
+ filename: file.filename,
+ fromAgent: agentResourceIds.has(file.file_id),
+ });
+ }
+
+ return { files, toolContext };
+};
+
+/**
+ *
+ * @param {Object} options
+ * @param {string} options.userId
+ * @param {Array<{ file_id: string; filename: string; fromAgent?: boolean }>} options.files
+ * @param {string} [options.entity_id]
+ * @param {boolean} [options.fileCitations=false] - Whether to include citation instructions
+ * @returns
+ */
+const createFileSearchTool = async ({ userId, files, entity_id, fileCitations = false }) => {
+ return tool(
+ async ({ query }) => {
+ if (files.length === 0) {
+ return ['No files to search. Instruct the user to add files for the search.', undefined];
+ }
+ const jwtToken = generateShortLivedToken(userId);
+ if (!jwtToken) {
+ return ['There was an error authenticating the file search request.', undefined];
+ }
+
+ /**
+ * @param {import('librechat-data-provider').TFile & { fromAgent?: boolean }} file
+ * @returns {{ file_id: string, query: string, k: number, entity_id?: string }}
+ */
+ const createQueryBody = (file) => {
+ const body = {
+ file_id: file.file_id,
+ query,
+ k: 5,
+ };
+ // User-attached files are embedded under the user id (no entity);
+ // only agent knowledge-base files carry the agent's entity_id.
+ // Sending entity_id for user attachments makes the RAG API's entity
+ // filter return no results for them. When files are provided by
+ // primeFiles, fromAgent is always set; for callers that pass files
+ // directly without the flag, the safe default is unscoped (no
+ // entity_id).
+ if (!entity_id || file.fromAgent !== true) {
+ return body;
+ }
+ body.entity_id = entity_id;
+ logger.debug(`[${Tools.file_search}] RAG API /query body`, body);
+ return body;
+ };
+
+ const queryPromises = files.map((file) =>
+ axios
+ .post(`${process.env.RAG_API_URL}/query`, createQueryBody(file), {
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ 'Content-Type': 'application/json',
+ },
+ })
+ .catch((error) => {
+ logAxiosError({
+ message: 'Error encountered in `file_search` while querying file',
+ error,
+ });
+ return null;
+ }),
+ );
+
+ const results = await Promise.all(queryPromises);
+ const validResults = results.filter((result) => result !== null);
+
+ if (validResults.length === 0) {
+ return ['No results found or errors occurred while searching the files.', undefined];
+ }
+
+ const formattedResults = validResults
+ .flatMap((result, fileIndex) =>
+ result.data.map(([docInfo, distance]) => ({
+ filename: docInfo.metadata.source.split('/').pop(),
+ content: docInfo.page_content,
+ distance,
+ file_id: files[fileIndex]?.file_id,
+ page: docInfo.metadata.page || null,
+ })),
+ )
+ .sort((a, b) => a.distance - b.distance)
+ .slice(0, 10);
+
+ if (formattedResults.length === 0) {
+ return [
+ 'No content found in the files. The files may not have been processed correctly or you may need to refine your query.',
+ undefined,
+ ];
+ }
+
+ const formattedString = formattedResults
+ .map(
+ (result, index) =>
+ `File: ${result.filename}${
+ fileCitations ? `\nAnchor: \\ue202turn0file${index} (${result.filename})` : ''
+ }\nRelevance: ${(1.0 - result.distance).toFixed(4)}\nContent: ${result.content}\n`,
+ )
+ .join('\n---\n');
+
+ const sources = formattedResults.map((result) => ({
+ type: 'file',
+ fileId: result.file_id,
+ content: result.content,
+ fileName: result.filename,
+ relevance: 1.0 - result.distance,
+ pages: result.page ? [result.page] : [],
+ pageRelevance: result.page ? { [result.page]: 1.0 - result.distance } : {},
+ }));
+
+ return [formattedString, { [Tools.file_search]: { sources, fileCitations } }];
+ },
+ {
+ name: Tools.file_search,
+ responseFormat: 'content_and_artifact',
+ description: `Performs semantic search across attached "${Tools.file_search}" documents using natural language queries. This tool analyzes the content of uploaded files to find relevant information, quotes, and passages that best match your query. Use this to extract specific information or find relevant sections within the available documents.${
+ fileCitations
+ ? `
+
+**CITE FILE SEARCH RESULTS:**
+Use the EXACT anchor markers shown below (copy them verbatim) immediately after statements derived from file content. Reference the filename in your text:
+- File citation: "The document.pdf states that... \\ue202turn0file0"
+- Page reference: "According to report.docx... \\ue202turn0file1"
+- Multi-file: "Multiple sources confirm... \\ue200\\ue202turn0file0\\ue202turn0file1\\ue201"
+
+**CRITICAL:** Output these escape sequences EXACTLY as shown (e.g., \\ue202turn0file0). Do NOT substitute with other characters like † or similar symbols.
+**ALWAYS mention the filename in your text before the citation marker. NEVER use markdown links or footnotes.**`
+ : ''
+ }`,
+ schema: fileSearchJsonSchema,
+ },
+ );
+};
+
+module.exports = { createFileSearchTool, primeFiles, fileSearchJsonSchema };
diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js
new file mode 100644
index 0000000..a01d4fc
--- /dev/null
+++ b/api/app/clients/tools/util/handleTools.js
@@ -0,0 +1,557 @@
+const { logger } = require('@librechat/data-schemas');
+const { Calculator, createSearchTool, createCodeExecutionTool } = require('@librechat/agents');
+const {
+ checkAccess,
+ toolkitParent,
+ createSafeUser,
+ mcpToolPattern,
+ loadWebSearchAuth,
+ buildInlineMemoryTool,
+ getCodeApiAuthHeaders,
+ buildImageToolContext,
+ SET_MEMORY_TOOL_NAME,
+ buildWebSearchContext,
+ DELETE_MEMORY_TOOL_NAME,
+ createAskUserQuestionTool,
+ ASK_USER_QUESTION_TOOL_NAME,
+ buildWebSearchDynamicContext,
+} = require('@librechat/api');
+const {
+ Tools,
+ Constants,
+ Permissions,
+ EToolResources,
+ PermissionTypes,
+} = require('librechat-data-provider');
+const {
+ availableTools,
+ manifestToolMap,
+ // Basic Tools
+ GoogleSearchAPI,
+ // Structured Tools
+ DALLE3,
+ FluxAPI,
+ OpenWeather,
+ StructuredSD,
+ StructuredACS,
+ TraversaalSearch,
+ StructuredWolfram,
+ TavilySearchResults,
+ createGeminiImageTool,
+ createOpenAIImageTools,
+} = require('../');
+const {
+ createMCPTool,
+ createMCPTools,
+ createMCPPermissionContext,
+ resolveConfigServers,
+} = require('~/server/services/MCP');
+const { getMCPRequestContext } = require('~/server/services/MCPRequestContext');
+const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
+const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
+const { getUserPluginAuthValue } = require('~/server/services/PluginService');
+const { loadAuthValues } = require('~/server/services/Tools/credentials');
+const { getMCPServerTools } = require('~/server/services/Config');
+const { getMCPServersRegistry } = require('~/config');
+const { getRoleByName, setMemory, deleteMemory, getFormattedMemories } = require('~/models');
+
+/**
+ * Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
+ * Tools without required authentication or with valid authentication are considered valid.
+ *
+ * @param {Object} user The user object for whom to validate tool access.
+ * @param {Array} tools An array of tool identifiers to validate. Defaults to an empty array.
+ * @returns {Promise>} A promise that resolves to an array of valid tool identifiers.
+ */
+const validateTools = async (user, tools = []) => {
+ try {
+ const validToolsSet = new Set(tools);
+ const availableToolsToValidate = availableTools.filter((tool) =>
+ validToolsSet.has(tool.pluginKey),
+ );
+
+ /**
+ * Validates the credentials for a given auth field or set of alternate auth fields for a tool.
+ * If valid admin or user authentication is found, the function returns early. Otherwise, it removes the tool from the set of valid tools.
+ *
+ * @param {string} authField The authentication field or fields (separated by "||" for alternates) to validate.
+ * @param {string} toolName The identifier of the tool being validated.
+ */
+ const validateCredentials = async (authField, toolName) => {
+ const fields = authField.split('||');
+ for (const field of fields) {
+ const adminAuth = process.env[field];
+ if (adminAuth && adminAuth.length > 0) {
+ return;
+ }
+
+ let userAuth = null;
+ try {
+ userAuth = await getUserPluginAuthValue(user, field);
+ } catch (err) {
+ if (field === fields[fields.length - 1] && !userAuth) {
+ throw err;
+ }
+ }
+ if (userAuth && userAuth.length > 0) {
+ return;
+ }
+ }
+
+ validToolsSet.delete(toolName);
+ };
+
+ for (const tool of availableToolsToValidate) {
+ if (!tool.authConfig || tool.authConfig.length === 0) {
+ continue;
+ }
+
+ for (const auth of tool.authConfig) {
+ await validateCredentials(auth.authField, tool.pluginKey);
+ }
+ }
+
+ return Array.from(validToolsSet.values());
+ } catch (err) {
+ logger.error('[validateTools] There was a problem validating tools', err);
+ throw new Error(err);
+ }
+};
+
+/** @typedef {typeof import('@librechat/agents/langchain/tools').Tool} ToolConstructor */
+/** @typedef {import('@librechat/agents/langchain/tools').Tool} Tool */
+
+/**
+ * Initializes a tool with authentication values for the given user, supporting alternate authentication fields.
+ * Authentication fields can have alternates separated by "||", and the first defined variable will be used.
+ *
+ * @param {string} userId The user ID for which the tool is being loaded.
+ * @param {Array} authFields Array of strings representing the authentication fields. Supports alternate fields delimited by "||".
+ * @param {ToolConstructor} ToolConstructor The constructor function for the tool to be initialized.
+ * @param {Object} options Optional parameters to be passed to the tool constructor alongside authentication values.
+ * @returns {() => Promise} An Async function that, when called, asynchronously initializes and returns an instance of the tool with authentication.
+ */
+const loadToolWithAuth = (userId, authFields, ToolConstructor, options = {}) => {
+ return async function () {
+ const authValues = await loadAuthValues({ userId, authFields });
+ return new ToolConstructor({ ...options, ...authValues, userId });
+ };
+};
+
+/**
+ * @param {string} toolKey
+ * @returns {Array}
+ */
+const getAuthFields = (toolKey) => {
+ return manifestToolMap[toolKey]?.authConfig.map((auth) => auth.authField) ?? [];
+};
+
+/**
+ *
+ * @param {object} params
+ * @param {string} params.user
+ * @param {Record>} [object.userMCPAuthMap]
+ * @param {AbortSignal} [object.signal]
+ * @param {Pick} [params.agent]
+ * @param {string} [params.model]
+ * @param {EModelEndpoint} [params.endpoint]
+ * @param {LoadToolOptions} [params.options]
+ * @param {boolean} [params.useSpecs]
+ * @param {Array} params.tools
+ * @param {boolean} [params.functions]
+ * @param {boolean} [params.returnMap]
+ * @param {AppConfig['webSearch']} [params.webSearch]
+ * @param {AppConfig['fileStrategy']} [params.fileStrategy]
+ * @param {AppConfig['imageOutputType']} [params.imageOutputType]
+ * @returns {Promise<{ loadedTools: Tool[], toolContextMap: Object, dynamicToolContextMap?: Object } | Record>}
+ */
+const loadTools = async ({
+ user,
+ agent,
+ model,
+ signal,
+ endpoint,
+ userMCPAuthMap,
+ tools = [],
+ options = {},
+ functions = true,
+ returnMap = false,
+ webSearch,
+ fileStrategy,
+ imageOutputType,
+}) => {
+ const toolConstructors = {
+ flux: FluxAPI,
+ calculator: Calculator,
+ google: GoogleSearchAPI,
+ open_weather: OpenWeather,
+ wolfram: StructuredWolfram,
+ 'stable-diffusion': StructuredSD,
+ 'azure-ai-search': StructuredACS,
+ traversaal_search: TraversaalSearch,
+ tavily_search_results_json: TavilySearchResults,
+ };
+
+ const customConstructors = {
+ image_gen_oai: async (_toolContextMap, dynamicToolContextMap) => {
+ const authFields = getAuthFields('image_gen_oai');
+ const authValues = await loadAuthValues({ userId: user, authFields });
+ const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? [];
+ const toolContext = buildImageToolContext({
+ imageFiles,
+ toolName: `${EToolResources.image_edit}_oai`,
+ contextDescription: 'image editing',
+ });
+ if (toolContext) {
+ dynamicToolContextMap.image_edit_oai = toolContext;
+ }
+ return createOpenAIImageTools({
+ ...authValues,
+ isAgent: !!agent,
+ req: options.req,
+ imageOutputType,
+ fileStrategy,
+ imageFiles,
+ });
+ },
+ gemini_image_gen: async (_toolContextMap, dynamicToolContextMap) => {
+ const authFields = getAuthFields('gemini_image_gen');
+ const authValues = await loadAuthValues({ userId: user, authFields, throwError: false });
+ const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? [];
+ const toolContext = buildImageToolContext({
+ imageFiles,
+ toolName: 'gemini_image_gen',
+ contextDescription: 'image context',
+ });
+ if (toolContext) {
+ dynamicToolContextMap.gemini_image_gen = toolContext;
+ }
+ return createGeminiImageTool({
+ ...authValues,
+ isAgent: !!agent,
+ req: options.req,
+ imageFiles,
+ userId: user,
+ fileStrategy,
+ });
+ },
+ };
+
+ const requestedTools = {};
+ const hasMCPTools = tools.some((toolName) => toolName && mcpToolPattern.test(toolName));
+ const mcpPermissionContext =
+ options.mcpPermissionContext ?? createMCPPermissionContext(options.req);
+ const canUseMCP = hasMCPTools
+ ? await mcpPermissionContext.canUseServers(options.req?.user)
+ : true;
+ let loggedMCPDenied = false;
+
+ if (functions === true) {
+ toolConstructors.dalle = DALLE3;
+ }
+
+ /** @type {ImageGenOptions} */
+ const imageGenOptions = {
+ isAgent: !!agent,
+ req: options.req,
+ fileStrategy,
+ processFileURL: options.processFileURL,
+ returnMetadata: options.returnMetadata,
+ uploadImageBuffer: options.uploadImageBuffer,
+ };
+
+ const toolOptions = {
+ flux: imageGenOptions,
+ dalle: imageGenOptions,
+ 'stable-diffusion': imageGenOptions,
+ gemini_image_gen: imageGenOptions,
+ };
+
+ /** @type {Record} */
+ const toolContextMap = {};
+ /** @type {Record} */
+ const dynamicToolContextMap = {};
+ /**
+ * @type {import('@librechat/agents').CodeEnvFile[] | undefined}
+ * Captured by the `execute_code` factory when files are primed. Surfaced
+ * out of `loadTools` so client.js can seed `Graph.sessions[EXECUTE_CODE]`
+ * before run start — without that seed, the first `execute_code` /
+ * `bash_tool` call lands with empty `_injected_files` and the sandbox
+ * can't see the prior turn's generated artifacts.
+ */
+ let primedCodeFiles;
+ const requestedMCPTools = {};
+
+ /** Resolve config-source servers for the current user/tenant context */
+ let configServers;
+ if (hasMCPTools && canUseMCP) {
+ configServers = await resolveConfigServers(options.req);
+ }
+
+ for (const tool of tools) {
+ if (tool === Tools.execute_code) {
+ requestedTools[tool] = async () => {
+ const { files, toolContext } = await primeCodeFiles({
+ ...options,
+ agentId: agent?.id,
+ });
+ if (toolContext) {
+ dynamicToolContextMap[tool] = toolContext;
+ }
+ if (files?.length) {
+ primedCodeFiles = files;
+ }
+ return createCodeExecutionTool({
+ user_id: user,
+ files,
+ authHeaders: () => getCodeApiAuthHeaders(options.req),
+ });
+ };
+ continue;
+ } else if (tool === Tools.file_search) {
+ requestedTools[tool] = async () => {
+ const { files, toolContext } = await primeSearchFiles({
+ ...options,
+ agentId: agent?.id,
+ });
+ if (toolContext) {
+ dynamicToolContextMap[tool] = toolContext;
+ }
+
+ /** @type {boolean | undefined} Check if user has FILE_CITATIONS permission */
+ let fileCitations;
+ if (fileCitations == null && options.req?.user != null) {
+ try {
+ fileCitations = await checkAccess({
+ user: options.req.user,
+ permissionType: PermissionTypes.FILE_CITATIONS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ } catch (error) {
+ logger.error('[handleTools] FILE_CITATIONS permission check failed:', error);
+ fileCitations = false;
+ }
+ }
+
+ return createFileSearchTool({
+ userId: user,
+ files,
+ entity_id: agent?.id,
+ fileCitations,
+ });
+ };
+ continue;
+ } else if (tool === Tools.web_search) {
+ const result = await loadWebSearchAuth({
+ userId: user,
+ loadAuthValues,
+ webSearchConfig: webSearch,
+ });
+ const { onSearchResults, onGetHighlights } = options?.[Tools.web_search] ?? {};
+ requestedTools[tool] = async () => {
+ toolContextMap[tool] = buildWebSearchContext();
+ dynamicToolContextMap[tool] = buildWebSearchDynamicContext(
+ options.req?.conversationCreatedAt,
+ );
+ return createSearchTool({
+ ...result.authResult,
+ onSearchResults,
+ onGetHighlights,
+ logger,
+ });
+ };
+ continue;
+ } else if (tool === ASK_USER_QUESTION_TOOL_NAME) {
+ requestedTools[tool] = () => createAskUserQuestionTool();
+ continue;
+ } else if (tool === SET_MEMORY_TOOL_NAME || tool === DELETE_MEMORY_TOOL_NAME) {
+ requestedTools[tool] = () =>
+ buildInlineMemoryTool({
+ toolName: tool,
+ req: options.req,
+ agent,
+ userId: user,
+ memoryMethods: { setMemory, deleteMemory, getFormattedMemories },
+ getRoleByName,
+ });
+ continue;
+ } else if (tool && mcpToolPattern.test(tool)) {
+ if (!canUseMCP) {
+ if (!loggedMCPDenied) {
+ logger.warn(
+ `[handleTools] User ${options.req?.user?.id} lacks MCP server use permission`,
+ );
+ loggedMCPDenied = true;
+ }
+ continue;
+ }
+
+ const [toolName, serverName] = tool.split(Constants.mcp_delimiter);
+ if (toolName === Constants.mcp_server) {
+ /** Placeholder used for UI purposes */
+ continue;
+ }
+ const serverConfig = serverName
+ ? await getMCPServersRegistry().getServerConfig(serverName, user, configServers)
+ : null;
+ if (!serverConfig) {
+ logger.warn(
+ `MCP server "${serverName}" for "${toolName}" tool is not configured${agent?.id != null && agent.id ? ` but attached to "${agent.id}"` : ''}`,
+ );
+ continue;
+ }
+ if (toolName === Constants.mcp_all) {
+ requestedMCPTools[serverName] = [
+ {
+ type: 'all',
+ serverName,
+ config: serverConfig,
+ },
+ ];
+ continue;
+ }
+
+ requestedMCPTools[serverName] = requestedMCPTools[serverName] || [];
+ requestedMCPTools[serverName].push({
+ type: 'single',
+ toolKey: tool,
+ serverName,
+ config: serverConfig,
+ });
+ continue;
+ }
+
+ const toolKey = customConstructors[tool] ? tool : toolkitParent[tool];
+ if (toolKey && customConstructors[toolKey]) {
+ if (!requestedTools[toolKey]) {
+ let cached;
+ requestedTools[toolKey] = async () => {
+ cached ??= customConstructors[toolKey](toolContextMap, dynamicToolContextMap);
+ return cached;
+ };
+ }
+ requestedTools[tool] = requestedTools[toolKey];
+ continue;
+ }
+
+ if (toolConstructors[tool]) {
+ const options = toolOptions[tool] || {};
+ const toolInstance = loadToolWithAuth(
+ user,
+ getAuthFields(tool),
+ toolConstructors[tool],
+ options,
+ );
+ requestedTools[tool] = toolInstance;
+ continue;
+ }
+ }
+
+ if (returnMap) {
+ return requestedTools;
+ }
+
+ const toolPromises = [];
+ for (const tool of tools) {
+ const validTool = requestedTools[tool];
+ if (validTool) {
+ toolPromises.push(
+ validTool().catch((error) => {
+ logger.error(`Error loading tool ${tool}:`, error);
+ return null;
+ }),
+ );
+ }
+ }
+
+ const loadedTools = (await Promise.all(toolPromises)).flatMap((plugin) => plugin || []);
+ const mcpToolPromises = [];
+ /** MCP server tools are initialized sequentially by server */
+ let index = -1;
+ const failedMCPServers = new Set();
+ const safeUser = createSafeUser(options.req?.user);
+ const requestScopedConnections =
+ options.requestScopedConnections ?? getMCPRequestContext(options.req, options.res);
+
+ for (const [serverName, toolConfigs] of Object.entries(requestedMCPTools)) {
+ index++;
+ /** @type {LCAvailableTools} */
+ let availableTools = options.mcpAvailableTools?.[serverName];
+ for (const config of toolConfigs) {
+ try {
+ if (failedMCPServers.has(serverName)) {
+ continue;
+ }
+ const mcpParams = {
+ mcpPermissionContext,
+ index,
+ signal,
+ user: safeUser,
+ userMCPAuthMap,
+ configServers,
+ requestBody: options.req?.body,
+ requestScopedConnections,
+ res: options.res,
+ streamId: options.req?._resumableStreamId || null,
+ model: agent?.model ?? model,
+ serverName: config.serverName,
+ provider: agent?.provider ?? endpoint,
+ config: config.config,
+ };
+
+ if (config.type === 'all' && toolConfigs.length === 1) {
+ /** Handle async loading for single 'all' tool config */
+ mcpToolPromises.push(
+ createMCPTools(mcpParams).catch((error) => {
+ logger.error(`Error loading ${serverName} tools:`, error);
+ return null;
+ }),
+ );
+ continue;
+ }
+ if (!availableTools) {
+ try {
+ availableTools = await getMCPServerTools(safeUser.id, serverName, config.config);
+ } catch (error) {
+ logger.error(`Error fetching available tools for MCP server ${serverName}:`, error);
+ }
+ }
+
+ /** Handle synchronous loading */
+ const mcpTool =
+ config.type === 'all'
+ ? await createMCPTools(mcpParams)
+ : await createMCPTool({
+ ...mcpParams,
+ availableTools,
+ toolKey: config.toolKey,
+ onAvailableTools: (tools) => {
+ availableTools = tools;
+ },
+ });
+
+ if (Array.isArray(mcpTool)) {
+ loadedTools.push(...mcpTool);
+ } else if (mcpTool) {
+ loadedTools.push(mcpTool);
+ } else {
+ failedMCPServers.add(serverName);
+ logger.warn(
+ `MCP tool creation failed for "${config.toolKey}", server may be unavailable or unauthenticated.`,
+ );
+ }
+ } catch (error) {
+ logger.error(`Error loading MCP tool for server ${serverName}:`, error);
+ }
+ }
+ }
+ loadedTools.push(...(await Promise.all(mcpToolPromises)).flatMap((plugin) => plugin || []));
+ return { loadedTools, toolContextMap, dynamicToolContextMap, primedCodeFiles };
+};
+
+module.exports = {
+ loadToolWithAuth,
+ validateTools,
+ loadTools,
+};
diff --git a/api/app/clients/tools/util/handleTools.test.js b/api/app/clients/tools/util/handleTools.test.js
new file mode 100644
index 0000000..697649e
--- /dev/null
+++ b/api/app/clients/tools/util/handleTools.test.js
@@ -0,0 +1,454 @@
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+
+const mockPluginService = {
+ updateUserPluginAuth: jest.fn(),
+ deleteUserPluginAuth: jest.fn(),
+ getUserPluginAuthValue: jest.fn(),
+};
+const mockGetMCPServerTools = jest.fn();
+const mockCreateMCPTool = jest.fn();
+const mockCreateMCPTools = jest.fn();
+const mockGetServerConfig = jest.fn();
+
+jest.mock('~/server/services/PluginService', () => mockPluginService);
+
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn().mockResolvedValue({
+ // Default app config for tool tests
+ paths: { uploads: '/tmp' },
+ fileStrategy: 'local',
+ filteredTools: [],
+ includedTools: [],
+ }),
+ getCachedTools: jest.fn().mockResolvedValue({
+ // Default cached tools for tests
+ dalle: {
+ type: 'function',
+ function: {
+ name: 'dalle',
+ description: 'DALL-E image generation',
+ parameters: {},
+ },
+ },
+ }),
+ getMCPServerTools: (...args) => mockGetMCPServerTools(...args),
+}));
+
+jest.mock('~/server/services/MCP', () => ({
+ createMCPTool: (...args) => mockCreateMCPTool(...args),
+ createMCPTools: (...args) => mockCreateMCPTools(...args),
+ createMCPPermissionContext: jest.fn(() => ({
+ canUseServers: jest.fn().mockResolvedValue(true),
+ })),
+ resolveConfigServers: jest.fn().mockResolvedValue({}),
+}));
+
+jest.mock('~/config', () => ({
+ getMCPServersRegistry: jest.fn(() => ({
+ getServerConfig: (...args) => mockGetServerConfig(...args),
+ })),
+}));
+
+const { Calculator } = require('@librechat/agents');
+const { Constants } = require('librechat-data-provider');
+
+const { User } = require('~/db/models');
+const PluginService = require('~/server/services/PluginService');
+const { validateTools, loadTools, loadToolWithAuth } = require('./handleTools');
+const { StructuredSD, availableTools, DALLE3 } = require('../');
+
+describe('Tool Handlers', () => {
+ let mongoServer;
+ let fakeUser;
+ const pluginKey = 'dalle';
+ const pluginKey2 = 'wolfram';
+ const ToolClass = DALLE3;
+ const initialTools = [pluginKey, pluginKey2];
+ const mockCredential = 'mock-credential';
+ const mainPlugin = availableTools.find((tool) => tool.pluginKey === pluginKey);
+ const authConfigs = mainPlugin.authConfig;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+
+ const userAuthValues = {};
+ mockPluginService.getUserPluginAuthValue.mockImplementation((userId, authField) => {
+ return userAuthValues[`${userId}-${authField}`];
+ });
+ mockPluginService.updateUserPluginAuth.mockImplementation(
+ (userId, authField, _pluginKey, credential) => {
+ const fields = authField.split('||');
+ fields.forEach((field) => {
+ userAuthValues[`${userId}-${field}`] = credential;
+ });
+ },
+ );
+
+ fakeUser = new User({
+ name: 'Fake User',
+ username: 'fakeuser',
+ email: 'fakeuser@example.com',
+ emailVerified: false,
+ // file deepcode ignore NoHardcodedPasswords/test: fake value
+ password: 'fakepassword123',
+ avatar: '',
+ provider: 'local',
+ role: 'USER',
+ googleId: null,
+ plugins: [],
+ refreshToken: [],
+ });
+ await fakeUser.save();
+ for (const authConfig of authConfigs) {
+ await PluginService.updateUserPluginAuth(
+ fakeUser._id,
+ authConfig.authField,
+ pluginKey,
+ mockCredential,
+ );
+ }
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ // Clear mocks but not the database since we need the user to persist
+ jest.clearAllMocks();
+
+ // Reset the mock implementations
+ const userAuthValues = {};
+ mockPluginService.getUserPluginAuthValue.mockImplementation((userId, authField) => {
+ return userAuthValues[`${userId}-${authField}`];
+ });
+ mockPluginService.updateUserPluginAuth.mockImplementation(
+ (userId, authField, _pluginKey, credential) => {
+ const fields = authField.split('||');
+ fields.forEach((field) => {
+ userAuthValues[`${userId}-${field}`] = credential;
+ });
+ },
+ );
+
+ // Re-add the auth configs for the user
+ for (const authConfig of authConfigs) {
+ await PluginService.updateUserPluginAuth(
+ fakeUser._id,
+ authConfig.authField,
+ pluginKey,
+ mockCredential,
+ );
+ }
+ });
+
+ describe('validateTools', () => {
+ it('returns valid tools given input tools and user authentication', async () => {
+ const validTools = await validateTools(fakeUser._id, initialTools);
+ expect(validTools).toBeDefined();
+ expect(validTools.some((tool) => tool === pluginKey)).toBeTruthy();
+ expect(validTools.length).toBeGreaterThan(0);
+ });
+
+ it('removes tools without valid credentials from the validTools array', async () => {
+ const validTools = await validateTools(fakeUser._id, initialTools);
+ expect(validTools.some((tool) => tool.pluginKey === pluginKey2)).toBeFalsy();
+ });
+
+ it('returns an empty array when no authenticated tools are provided', async () => {
+ const validTools = await validateTools(fakeUser._id, []);
+ expect(validTools).toEqual([]);
+ });
+
+ it('should validate a tool from an Environment Variable', async () => {
+ const plugin = availableTools.find((tool) => tool.pluginKey === pluginKey2);
+ const authConfigs = plugin.authConfig;
+ for (const authConfig of authConfigs) {
+ process.env[authConfig.authField] = mockCredential;
+ }
+ const validTools = await validateTools(fakeUser._id, [pluginKey2]);
+ expect(validTools.length).toEqual(1);
+ for (const authConfig of authConfigs) {
+ delete process.env[authConfig.authField];
+ }
+ });
+ });
+
+ describe('loadTools', () => {
+ let toolFunctions;
+ let loadTool1;
+ let loadTool2;
+ let loadTool3;
+ const sampleTools = [...initialTools, 'calculator'];
+ let ToolClass2 = Calculator;
+ let remainingTools = availableTools.filter(
+ (tool) => sampleTools.indexOf(tool.pluginKey) === -1,
+ );
+
+ beforeAll(async () => {
+ const toolMap = await loadTools({
+ user: fakeUser._id,
+ tools: sampleTools,
+ returnMap: true,
+ useSpecs: true,
+ });
+ toolFunctions = toolMap;
+ loadTool1 = toolFunctions[sampleTools[0]];
+ loadTool2 = toolFunctions[sampleTools[1]];
+ loadTool3 = toolFunctions[sampleTools[2]];
+ });
+
+ let originalEnv;
+
+ beforeEach(() => {
+ originalEnv = process.env;
+ process.env = { ...originalEnv };
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ it('returns the expected load functions for requested tools', async () => {
+ expect(loadTool1).toBeDefined();
+ expect(loadTool2).toBeDefined();
+ expect(loadTool3).toBeDefined();
+
+ for (const tool of remainingTools) {
+ expect(toolFunctions[tool.pluginKey]).toBeUndefined();
+ }
+ });
+
+ it('should initialize an authenticated tool or one without authentication', async () => {
+ const authTool = await loadTool1();
+ const tool = await loadTool3();
+ expect(authTool).toBeInstanceOf(ToolClass);
+ expect(tool).toBeInstanceOf(ToolClass2);
+ });
+
+ it('should initialize an authenticated tool with primary auth field', async () => {
+ process.env.DALLE3_API_KEY = 'mocked_api_key';
+ const initToolFunction = loadToolWithAuth(
+ 'userId',
+ ['DALLE3_API_KEY||DALLE_API_KEY'],
+ ToolClass,
+ );
+ const authTool = await initToolFunction();
+
+ expect(authTool).toBeInstanceOf(ToolClass);
+ expect(mockPluginService.getUserPluginAuthValue).not.toHaveBeenCalled();
+ });
+
+ it('should initialize an authenticated tool with alternate auth field when primary is missing', async () => {
+ delete process.env.DALLE3_API_KEY; // Ensure the primary key is not set
+ process.env.DALLE_API_KEY = 'mocked_alternate_api_key';
+ const initToolFunction = loadToolWithAuth(
+ 'userId',
+ ['DALLE3_API_KEY||DALLE_API_KEY'],
+ ToolClass,
+ );
+ const authTool = await initToolFunction();
+
+ expect(authTool).toBeInstanceOf(ToolClass);
+ expect(mockPluginService.getUserPluginAuthValue).toHaveBeenCalledTimes(1);
+ expect(mockPluginService.getUserPluginAuthValue).toHaveBeenCalledWith(
+ 'userId',
+ 'DALLE3_API_KEY',
+ true,
+ );
+ });
+
+ it('should fallback to getUserPluginAuthValue when env vars are missing', async () => {
+ mockPluginService.updateUserPluginAuth('userId', 'DALLE_API_KEY', 'dalle', 'mocked_api_key');
+ const initToolFunction = loadToolWithAuth(
+ 'userId',
+ ['DALLE3_API_KEY||DALLE_API_KEY'],
+ ToolClass,
+ );
+ const authTool = await initToolFunction();
+
+ expect(authTool).toBeInstanceOf(ToolClass);
+ expect(mockPluginService.getUserPluginAuthValue).toHaveBeenCalledTimes(2);
+ });
+
+ it('should throw an error for an unauthenticated tool', async () => {
+ try {
+ await loadTool2();
+ } catch (error) {
+ expect(error).toBeDefined();
+ }
+ });
+ it('returns an empty object when no tools are requested', async () => {
+ toolFunctions = await loadTools({
+ user: fakeUser._id,
+ returnMap: true,
+ useSpecs: true,
+ });
+ expect(toolFunctions).toEqual({});
+ });
+ it('should return the StructuredTool version when using functions', async () => {
+ process.env.SD_WEBUI_URL = mockCredential;
+ toolFunctions = await loadTools({
+ user: fakeUser._id,
+ tools: ['stable-diffusion'],
+ functions: true,
+ returnMap: true,
+ useSpecs: true,
+ });
+ const structuredTool = await toolFunctions['stable-diffusion']();
+ expect(structuredTool).toBeInstanceOf(StructuredSD);
+ delete process.env.SD_WEBUI_URL;
+ });
+
+ it('passes request body to chat MCP tool creation and skips stale cache for BODY-scoped servers', async () => {
+ const serverName = 'body-scoped';
+ const toolKey = `search${Constants.mcp_delimiter}${serverName}`;
+ const requestBody = { conversationId: 'conv-123', messageId: 'msg-123' };
+ const serverConfig = {
+ type: 'streamable-http',
+ url: 'https://api.example.com/messages/{{LIBRECHAT_BODY_MESSAGEID}}/mcp',
+ source: 'yaml',
+ };
+
+ mockGetServerConfig.mockResolvedValue(serverConfig);
+ mockCreateMCPTool.mockResolvedValue({ name: 'loaded-mcp-tool' });
+
+ const result = await loadTools({
+ user: fakeUser._id.toString(),
+ tools: [toolKey],
+ options: {
+ req: {
+ user: { id: fakeUser._id.toString(), role: 'USER' },
+ body: requestBody,
+ },
+ },
+ });
+
+ expect(result.loadedTools).toEqual([{ name: 'loaded-mcp-tool' }]);
+ expect(mockGetMCPServerTools).toHaveBeenCalledWith(
+ fakeUser._id.toString(),
+ serverName,
+ serverConfig,
+ );
+ expect(mockCreateMCPTool).toHaveBeenCalledWith(
+ expect.objectContaining({
+ requestBody,
+ toolKey,
+ config: serverConfig,
+ }),
+ );
+ });
+
+ it('uses run-scoped MCP tool definitions before cache lookup', async () => {
+ const serverName = 'body-scoped';
+ const toolKey = `search${Constants.mcp_delimiter}${serverName}`;
+ const requestBody = { conversationId: 'conv-123', messageId: 'msg-123' };
+ const serverConfig = {
+ type: 'streamable-http',
+ url: 'https://api.example.com/messages/{{LIBRECHAT_BODY_MESSAGEID}}/mcp',
+ source: 'yaml',
+ };
+ const runScopedTools = {
+ [toolKey]: {
+ function: {
+ name: toolKey,
+ description: 'Run-scoped search',
+ parameters: { type: 'object', properties: {} },
+ },
+ },
+ };
+
+ mockGetServerConfig.mockResolvedValue(serverConfig);
+ mockCreateMCPTool.mockResolvedValue({ name: 'loaded-mcp-tool' });
+
+ const result = await loadTools({
+ user: fakeUser._id.toString(),
+ tools: [toolKey],
+ options: {
+ mcpAvailableTools: {
+ [serverName]: runScopedTools,
+ },
+ req: {
+ user: { id: fakeUser._id.toString(), role: 'USER' },
+ body: requestBody,
+ },
+ },
+ });
+
+ expect(result.loadedTools).toEqual([{ name: 'loaded-mcp-tool' }]);
+ expect(mockGetMCPServerTools).not.toHaveBeenCalled();
+ expect(mockCreateMCPTool).toHaveBeenCalledWith(
+ expect.objectContaining({
+ availableTools: runScopedTools,
+ requestBody,
+ toolKey,
+ config: serverConfig,
+ }),
+ );
+ });
+
+ it('reuses discovered request-scoped MCP tool definitions within a server loop', async () => {
+ const serverName = 'body-scoped';
+ const firstToolKey = `search${Constants.mcp_delimiter}${serverName}`;
+ const secondToolKey = `lookup${Constants.mcp_delimiter}${serverName}`;
+ const requestBody = { conversationId: 'conv-123', messageId: 'msg-123' };
+ const serverConfig = {
+ type: 'streamable-http',
+ url: 'https://api.example.com/messages/{{LIBRECHAT_BODY_MESSAGEID}}/mcp',
+ source: 'yaml',
+ };
+ const discoveredTools = {
+ [firstToolKey]: {
+ function: {
+ description: 'Search',
+ parameters: { type: 'object', properties: {} },
+ },
+ },
+ [secondToolKey]: {
+ function: {
+ description: 'Lookup',
+ parameters: { type: 'object', properties: {} },
+ },
+ },
+ };
+
+ mockGetServerConfig.mockResolvedValue(serverConfig);
+ mockCreateMCPTool
+ .mockImplementationOnce(async ({ onAvailableTools }) => {
+ onAvailableTools(discoveredTools);
+ return { name: 'search-tool' };
+ })
+ .mockImplementationOnce(async ({ availableTools }) => {
+ expect(availableTools).toBe(discoveredTools);
+ return { name: 'lookup-tool' };
+ });
+
+ const result = await loadTools({
+ user: fakeUser._id.toString(),
+ tools: [firstToolKey, secondToolKey],
+ options: {
+ req: {
+ user: { id: fakeUser._id.toString(), role: 'USER' },
+ body: requestBody,
+ },
+ },
+ });
+
+ expect(result.loadedTools).toEqual([{ name: 'search-tool' }, { name: 'lookup-tool' }]);
+ expect(mockGetMCPServerTools).toHaveBeenCalledTimes(1);
+ expect(mockCreateMCPTool).toHaveBeenCalledTimes(2);
+ expect(mockCreateMCPTool).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ availableTools: discoveredTools,
+ requestBody,
+ toolKey: secondToolKey,
+ }),
+ );
+ });
+ });
+});
diff --git a/api/app/clients/tools/util/index.js b/api/app/clients/tools/util/index.js
new file mode 100644
index 0000000..9c96fb5
--- /dev/null
+++ b/api/app/clients/tools/util/index.js
@@ -0,0 +1,6 @@
+const { validateTools, loadTools } = require('./handleTools');
+
+module.exports = {
+ validateTools,
+ loadTools,
+};
diff --git a/api/app/index.js b/api/app/index.js
new file mode 100644
index 0000000..bb11fb0
--- /dev/null
+++ b/api/app/index.js
@@ -0,0 +1,5 @@
+const clients = require('./clients');
+
+module.exports = {
+ ...clients,
+};
diff --git a/api/cache/banViolation.js b/api/cache/banViolation.js
new file mode 100644
index 0000000..36945ca
--- /dev/null
+++ b/api/cache/banViolation.js
@@ -0,0 +1,86 @@
+const { logger } = require('@librechat/data-schemas');
+const { ViolationTypes } = require('librechat-data-provider');
+const { isEnabled, math, removePorts } = require('@librechat/api');
+const { deleteAllUserSessions } = require('~/models');
+const getLogStores = require('./getLogStores');
+
+const { BAN_VIOLATIONS, BAN_INTERVAL } = process.env ?? {};
+const interval = math(BAN_INTERVAL, 20);
+
+/**
+ * Bans a user based on violation criteria.
+ *
+ * If the user's violation count is a multiple of the BAN_INTERVAL, the user will be banned.
+ * The duration of the ban is determined by the BAN_DURATION environment variable.
+ * If BAN_DURATION is not set or invalid, the user will not be banned.
+ * Sessions will be deleted and the refreshToken cookie will be cleared even with
+ * an invalid or nill duration, which is a "soft" ban; the user can remain active until
+ * access token expiry.
+ *
+ * @async
+ * @param {Object} req - Express request object containing user information.
+ * @param {Object} res - Express response object.
+ * @param {Object} errorMessage - Object containing user violation details.
+ * @param {string} errorMessage.type - Type of the violation.
+ * @param {string} errorMessage.user_id - ID of the user who committed the violation.
+ * @param {number} errorMessage.violation_count - Number of violations committed by the user.
+ *
+ * @returns {Promise}
+ *
+ */
+const banViolation = async (req, res, errorMessage) => {
+ if (!isEnabled(BAN_VIOLATIONS)) {
+ return;
+ }
+ if (!errorMessage) {
+ return;
+ }
+
+ const { type, user_id, prev_count, violation_count } = errorMessage;
+
+ const prevThreshold = Math.floor(prev_count / interval);
+ const currentThreshold = Math.floor(violation_count / interval);
+
+ if (prevThreshold >= currentThreshold) {
+ return;
+ }
+
+ await deleteAllUserSessions({ userId: user_id });
+
+ /** Clear OpenID session tokens if present */
+ if (req.session?.openidTokens) {
+ delete req.session.openidTokens;
+ }
+
+ res.clearCookie('refreshToken');
+ res.clearCookie('openid_access_token');
+ res.clearCookie('openid_id_token');
+ res.clearCookie('openid_user_id');
+ res.clearCookie('token_provider');
+
+ const banLogs = getLogStores(ViolationTypes.BAN);
+ const duration = errorMessage.duration || banLogs.opts.ttl;
+ if (duration <= 0) {
+ return;
+ }
+
+ req.ip = removePorts(req);
+ logger.info(
+ `[BAN] Banning user ${user_id} ${req.ip ? `@ ${req.ip} ` : ''}for ${
+ duration / 1000 / 60
+ } minutes`,
+ );
+
+ const expiresAt = Date.now() + duration;
+ await banLogs.set(user_id, { type, violation_count, duration, expiresAt });
+ if (req.ip) {
+ await banLogs.set(req.ip, { type, user_id, violation_count, duration, expiresAt });
+ }
+
+ errorMessage.ban = true;
+ errorMessage.ban_duration = duration;
+
+ return;
+};
+
+module.exports = banViolation;
diff --git a/api/cache/banViolation.spec.js b/api/cache/banViolation.spec.js
new file mode 100644
index 0000000..df98753
--- /dev/null
+++ b/api/cache/banViolation.spec.js
@@ -0,0 +1,136 @@
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const banViolation = require('./banViolation');
+
+// Mock deleteAllUserSessions since we're testing ban logic, not session deletion
+jest.mock('~/models', () => ({
+ ...jest.requireActual('~/models'),
+ deleteAllUserSessions: jest.fn().mockResolvedValue(true),
+}));
+
+describe('banViolation', () => {
+ let mongoServer;
+ let req, res, errorMessage;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(() => {
+ req = {
+ ip: '127.0.0.1',
+ cookies: {
+ refreshToken: 'someToken',
+ },
+ };
+ res = {
+ clearCookie: jest.fn(),
+ };
+ errorMessage = {
+ type: 'someViolation',
+ user_id: new mongoose.Types.ObjectId().toString(), // Use valid ObjectId
+ prev_count: 0,
+ violation_count: 0,
+ };
+ process.env.BAN_VIOLATIONS = 'true';
+ process.env.BAN_DURATION = '7200000'; // 2 hours in ms
+ process.env.BAN_INTERVAL = '20';
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should not ban if BAN_VIOLATIONS are not enabled', async () => {
+ process.env.BAN_VIOLATIONS = 'false';
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeFalsy();
+ });
+
+ it('should not ban if errorMessage is not provided', async () => {
+ await banViolation(req, res, null);
+ expect(errorMessage.ban).toBeFalsy();
+ });
+
+ it('[1/3] should ban if violation_count crosses the interval threshold: 19 -> 39', async () => {
+ errorMessage.prev_count = 19;
+ errorMessage.violation_count = 39;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeTruthy();
+ });
+
+ it('[2/3] should ban if violation_count crosses the interval threshold: 19 -> 20', async () => {
+ errorMessage.prev_count = 19;
+ errorMessage.violation_count = 20;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeTruthy();
+ });
+
+ const randomValueAbove = Math.floor(20 + Math.random() * 100);
+ it(`[3/3] should ban if violation_count crosses the interval threshold: 19 -> ${randomValueAbove}`, async () => {
+ errorMessage.prev_count = 19;
+ errorMessage.violation_count = randomValueAbove;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeTruthy();
+ });
+
+ it('should handle invalid BAN_INTERVAL and default to 20', async () => {
+ process.env.BAN_INTERVAL = 'invalid';
+ errorMessage.prev_count = 19;
+ errorMessage.violation_count = 39;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeTruthy();
+ });
+
+ it('should ban if BAN_DURATION is invalid as default is 2 hours', async () => {
+ process.env.BAN_DURATION = 'invalid';
+ errorMessage.prev_count = 19;
+ errorMessage.violation_count = 39;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeTruthy();
+ });
+
+ it('should not ban if BAN_DURATION is 0 but should clear cookies', async () => {
+ process.env.BAN_DURATION = '0';
+ errorMessage.prev_count = 19;
+ errorMessage.violation_count = 39;
+ await banViolation(req, res, errorMessage);
+ expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
+ });
+
+ it('should not ban if violation_count does not change', async () => {
+ errorMessage.prev_count = 0;
+ errorMessage.violation_count = 0;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeFalsy();
+ });
+
+ it('[1/2] should not ban if violation_count does not cross the interval threshold: 0 -> 19', async () => {
+ errorMessage.prev_count = 0;
+ errorMessage.violation_count = 19;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeFalsy();
+ });
+
+ const randomValueUnder = Math.floor(1 + Math.random() * 19);
+ it(`[2/2] should not ban if violation_count does not cross the interval threshold: 0 -> ${randomValueUnder}`, async () => {
+ errorMessage.prev_count = 0;
+ errorMessage.violation_count = randomValueUnder;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeFalsy();
+ });
+
+ it('[EDGE CASE] should not ban if violation_count is lower', async () => {
+ errorMessage.prev_count = 0;
+ errorMessage.violation_count = -10;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeFalsy();
+ });
+});
diff --git a/api/cache/clearPendingReq.js b/api/cache/clearPendingReq.js
new file mode 100644
index 0000000..b4329b1
--- /dev/null
+++ b/api/cache/clearPendingReq.js
@@ -0,0 +1,49 @@
+const { isEnabled } = require('@librechat/api');
+const { Time, CacheKeys } = require('librechat-data-provider');
+const getLogStores = require('./getLogStores');
+
+const { USE_REDIS, LIMIT_CONCURRENT_MESSAGES } = process.env ?? {};
+
+/**
+ * Clear or decrement pending requests from the cache.
+ * Checks the environmental variable LIMIT_CONCURRENT_MESSAGES;
+ * if the rule is enabled ('true'), it either decrements the count of pending requests
+ * or deletes the key if the count is less than or equal to 1.
+ *
+ * @module clearPendingReq
+ * @requires ./getLogStores
+ * @requires ../server/utils
+ * @requires process
+ *
+ * @async
+ * @function
+ * @param {Object} params - The parameters object.
+ * @param {string} params.userId - The user ID for which the pending requests are to be cleared or decremented.
+ * @param {Object} [params.cache] - An optional cache object to use. If not provided, a default cache will be fetched using getLogStores.
+ * @returns {Promise} A promise that either decrements the 'pendingRequests' count, deletes the key from the store, or resolves with no value.
+ */
+const clearPendingReq = async ({ userId, cache: _cache }) => {
+ if (!userId) {
+ return;
+ } else if (!isEnabled(LIMIT_CONCURRENT_MESSAGES)) {
+ return;
+ }
+
+ const namespace = CacheKeys.PENDING_REQ;
+ const cache = _cache ?? getLogStores(namespace);
+
+ if (!cache) {
+ return;
+ }
+
+ const key = `${isEnabled(USE_REDIS) ? namespace : ''}:${userId ?? ''}`;
+ const currentReq = +((await cache.get(key)) ?? 0);
+
+ if (currentReq && currentReq >= 1) {
+ await cache.set(key, currentReq - 1, Time.ONE_MINUTE);
+ } else {
+ await cache.delete(key);
+ }
+};
+
+module.exports = clearPendingReq;
diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js
new file mode 100644
index 0000000..848d222
--- /dev/null
+++ b/api/cache/getLogStores.js
@@ -0,0 +1,236 @@
+const { Keyv } = require('keyv');
+const { Time, CacheKeys, ViolationTypes } = require('librechat-data-provider');
+const {
+ logFile,
+ keyvMongo,
+ cacheConfig,
+ sessionCache,
+ standardCache,
+ violationCache,
+ userPrincipalsCache,
+ registerShutdownTask,
+} = require('@librechat/api');
+
+/** No-op store registered when the user principals cache is disabled (TTL of 0). */
+const disabledCache = {
+ get: async () => undefined,
+ set: async () => undefined,
+ delete: async () => undefined,
+ clear: async () => undefined,
+};
+
+const namespaces = {
+ [ViolationTypes.GENERAL]: new Keyv({ store: logFile, namespace: 'violations' }),
+ [ViolationTypes.LOGINS]: violationCache(ViolationTypes.LOGINS),
+ [ViolationTypes.CONCURRENT]: violationCache(ViolationTypes.CONCURRENT),
+ [ViolationTypes.NON_BROWSER]: violationCache(ViolationTypes.NON_BROWSER),
+ [ViolationTypes.MESSAGE_LIMIT]: violationCache(ViolationTypes.MESSAGE_LIMIT),
+ [ViolationTypes.REGISTRATIONS]: violationCache(ViolationTypes.REGISTRATIONS),
+ [ViolationTypes.TOKEN_BALANCE]: violationCache(ViolationTypes.TOKEN_BALANCE),
+ [ViolationTypes.TTS_LIMIT]: violationCache(ViolationTypes.TTS_LIMIT),
+ [ViolationTypes.STT_LIMIT]: violationCache(ViolationTypes.STT_LIMIT),
+ [ViolationTypes.CONVO_ACCESS]: violationCache(ViolationTypes.CONVO_ACCESS),
+ [ViolationTypes.TOOL_CALL_LIMIT]: violationCache(ViolationTypes.TOOL_CALL_LIMIT),
+ [ViolationTypes.FILE_UPLOAD_LIMIT]: violationCache(ViolationTypes.FILE_UPLOAD_LIMIT),
+ [ViolationTypes.VERIFY_EMAIL_LIMIT]: violationCache(ViolationTypes.VERIFY_EMAIL_LIMIT),
+ [ViolationTypes.RESET_PASSWORD_LIMIT]: violationCache(ViolationTypes.RESET_PASSWORD_LIMIT),
+ [ViolationTypes.ILLEGAL_MODEL_REQUEST]: violationCache(ViolationTypes.ILLEGAL_MODEL_REQUEST),
+ [ViolationTypes.BAN]: new Keyv({
+ store: keyvMongo,
+ namespace: CacheKeys.BANS,
+ ttl: cacheConfig.BAN_DURATION,
+ }),
+
+ [CacheKeys.OPENID_SESSION]: sessionCache(CacheKeys.OPENID_SESSION),
+ [CacheKeys.SAML_SESSION]: sessionCache(CacheKeys.SAML_SESSION),
+
+ [CacheKeys.ROLES]: standardCache(CacheKeys.ROLES),
+ [CacheKeys.USER_PRINCIPALS]: userPrincipalsCache() ?? disabledCache,
+ [CacheKeys.APP_CONFIG]: standardCache(CacheKeys.APP_CONFIG),
+ [CacheKeys.CONFIG_STORE]: standardCache(CacheKeys.CONFIG_STORE),
+ [CacheKeys.TOOL_CACHE]: standardCache(CacheKeys.TOOL_CACHE),
+ [CacheKeys.PENDING_REQ]: standardCache(CacheKeys.PENDING_REQ),
+ [CacheKeys.ENCODED_DOMAINS]: new Keyv({ store: keyvMongo, namespace: CacheKeys.ENCODED_DOMAINS }),
+ [CacheKeys.ABORT_KEYS]: standardCache(CacheKeys.ABORT_KEYS, Time.TEN_MINUTES),
+ [CacheKeys.TOKEN_CONFIG]: standardCache(CacheKeys.TOKEN_CONFIG, Time.THIRTY_MINUTES),
+ [CacheKeys.GEN_TITLE]: standardCache(CacheKeys.GEN_TITLE, Time.TWO_MINUTES),
+ [CacheKeys.S3_EXPIRY_INTERVAL]: standardCache(CacheKeys.S3_EXPIRY_INTERVAL, Time.THIRTY_MINUTES),
+ [CacheKeys.MODEL_QUERIES]: standardCache(CacheKeys.MODEL_QUERIES),
+ [CacheKeys.AUDIO_RUNS]: standardCache(CacheKeys.AUDIO_RUNS, Time.TEN_MINUTES),
+ [CacheKeys.MESSAGES]: standardCache(CacheKeys.MESSAGES, Time.ONE_MINUTE),
+ [CacheKeys.FLOWS]: standardCache(CacheKeys.FLOWS, Time.ONE_MINUTE * 10),
+ [CacheKeys.OPENID_EXCHANGED_TOKENS]: standardCache(
+ CacheKeys.OPENID_EXCHANGED_TOKENS,
+ Time.TEN_MINUTES,
+ ),
+ [CacheKeys.ADMIN_OAUTH_EXCHANGE]: standardCache(
+ CacheKeys.ADMIN_OAUTH_EXCHANGE,
+ Time.THIRTY_SECONDS,
+ ),
+};
+
+/**
+ * Gets all cache stores that have TTL configured
+ * @returns {Keyv[]}
+ */
+function getTTLStores() {
+ return Object.values(namespaces).filter(
+ (store) =>
+ store instanceof Keyv &&
+ parseInt(store.opts?.ttl ?? '0') > 0 &&
+ !store.opts?.store?.constructor?.name?.includes('Redis'), // Only include non-Redis stores
+ );
+}
+
+/**
+ * Clears entries older than the cache's TTL
+ * @param {Keyv} cache
+ */
+async function clearExpiredFromCache(cache) {
+ if (!cache?.opts?.store?.entries) {
+ return;
+ }
+
+ const ttl = cache.opts.ttl;
+ if (!ttl) {
+ return;
+ }
+
+ const expiryTime = Date.now() - ttl;
+ let cleared = 0;
+
+ // Get all keys first to avoid modification during iteration
+ const keys = Array.from(cache.opts.store.keys());
+
+ for (const key of keys) {
+ try {
+ const raw = cache.opts.store.get(key);
+ if (!raw) {
+ continue;
+ }
+
+ const data = cache.opts.deserialize(raw);
+ // Check if the entry is older than TTL
+ if (data?.expires && data.expires <= expiryTime) {
+ const deleted = await cache.opts.store.delete(key);
+ if (!deleted) {
+ cacheConfig.DEBUG_MEMORY_CACHE &&
+ console.warn(`[Cache] Error deleting entry: ${key} from ${cache.opts.namespace}`);
+ continue;
+ }
+ cleared++;
+ }
+ } catch (error) {
+ cacheConfig.DEBUG_MEMORY_CACHE &&
+ console.log(`[Cache] Error processing entry from ${cache.opts.namespace}:`, error);
+ const deleted = await cache.opts.store.delete(key);
+ if (!deleted) {
+ cacheConfig.DEBUG_MEMORY_CACHE &&
+ console.warn(`[Cache] Error deleting entry: ${key} from ${cache.opts.namespace}`);
+ continue;
+ }
+ cleared++;
+ }
+ }
+
+ if (cleared > 0) {
+ cacheConfig.DEBUG_MEMORY_CACHE &&
+ console.log(
+ `[Cache] Cleared ${cleared} entries older than ${ttl}ms from ${cache.opts.namespace}`,
+ );
+ }
+}
+
+const auditCache = () => {
+ const ttlStores = getTTLStores();
+ console.log('[Cache] Starting audit');
+
+ ttlStores.forEach((store) => {
+ if (!store?.opts?.store?.entries) {
+ return;
+ }
+
+ console.log(`[Cache] ${store.opts.namespace} entries:`, {
+ count: store.opts.store.size,
+ ttl: store.opts.ttl,
+ keys: Array.from(store.opts.store.keys()),
+ entriesWithTimestamps: Array.from(store.opts.store.entries()).map(([key, value]) => ({
+ key,
+ value,
+ })),
+ });
+ });
+};
+
+/**
+ * Clears expired entries from all TTL-enabled stores
+ */
+async function clearAllExpiredFromCache() {
+ const ttlStores = getTTLStores();
+ await Promise.all(ttlStores.map((store) => clearExpiredFromCache(store)));
+
+ // Force garbage collection if available (Node.js with --expose-gc flag)
+ if (global.gc) {
+ global.gc();
+ }
+}
+
+if (!cacheConfig.USE_REDIS && !cacheConfig.CI) {
+ /** @type {Set} */
+ const cleanupIntervals = new Set();
+
+ // Clear expired entries every 30 seconds
+ const cleanup = setInterval(() => {
+ clearAllExpiredFromCache();
+ }, Time.THIRTY_SECONDS);
+
+ cleanupIntervals.add(cleanup);
+
+ if (cacheConfig.DEBUG_MEMORY_CACHE) {
+ const monitor = setInterval(() => {
+ const ttlStores = getTTLStores();
+ const memory = process.memoryUsage();
+ const totalSize = ttlStores.reduce((sum, store) => sum + (store.opts?.store?.size ?? 0), 0);
+
+ console.log('[Cache] Memory usage:', {
+ heapUsed: `${(memory.heapUsed / 1024 / 1024).toFixed(2)} MB`,
+ heapTotal: `${(memory.heapTotal / 1024 / 1024).toFixed(2)} MB`,
+ rss: `${(memory.rss / 1024 / 1024).toFixed(2)} MB`,
+ external: `${(memory.external / 1024 / 1024).toFixed(2)} MB`,
+ totalCacheEntries: totalSize,
+ });
+
+ auditCache();
+ }, Time.ONE_MINUTE);
+
+ cleanupIntervals.add(monitor);
+ }
+
+ // Register cleanup with the centralized graceful-shutdown coordinator
+ // (see packages/api/src/app/shutdown.ts) rather than attaching a direct
+ // signal handler — multiple competing handlers race the HTTP drain.
+ registerShutdownTask('cache cleanup', async () => {
+ cacheConfig.DEBUG_MEMORY_CACHE && console.log('[Cache] Cleaning up and shutting down...');
+ cleanupIntervals.forEach((interval) => clearInterval(interval));
+ cleanupIntervals.clear();
+ await clearAllExpiredFromCache();
+ cacheConfig.DEBUG_MEMORY_CACHE && console.log('[Cache] Final cleanup completed');
+ });
+}
+
+/**
+ * Returns the keyv cache specified by type.
+ * If an invalid type is passed, an error will be thrown.
+ *
+ * @param {string} key - The key for the namespace to access
+ * @returns {Keyv} - If a valid key is passed, returns an object containing the cache store of the specified key.
+ * @throws Will throw an error if an invalid key is passed.
+ */
+const getLogStores = (key) => {
+ if (!key || !namespaces[key]) {
+ throw new Error(`Invalid store key: ${key}`);
+ }
+ return namespaces[key];
+};
+
+module.exports = getLogStores;
diff --git a/api/cache/index.js b/api/cache/index.js
new file mode 100644
index 0000000..1bad242
--- /dev/null
+++ b/api/cache/index.js
@@ -0,0 +1,4 @@
+const getLogStores = require('./getLogStores');
+const logViolation = require('./logViolation');
+
+module.exports = { getLogStores, logViolation };
diff --git a/api/cache/logViolation.js b/api/cache/logViolation.js
new file mode 100644
index 0000000..1ff65c6
--- /dev/null
+++ b/api/cache/logViolation.js
@@ -0,0 +1,40 @@
+const { isEnabled } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const getLogStores = require('./getLogStores');
+const banViolation = require('./banViolation');
+
+/**
+ * Logs the violation.
+ *
+ * @param {Object} req - Express request object containing user information.
+ * @param {Object} res - Express response object.
+ * @param {string} type - The type of violation.
+ * @param {Object} errorMessage - The error message to log.
+ * @param {number | string} [score=1] - The severity of the violation. Defaults to 1
+ */
+const logViolation = async (req, res, type, errorMessage, score = 1) => {
+ const userId = req.user?.id ?? req.user?._id;
+ if (!userId) {
+ return;
+ }
+ const logs = getLogStores(ViolationTypes.GENERAL);
+ const violationLogs = getLogStores(type);
+ const key = isEnabled(process.env.USE_REDIS) ? `${type}:${userId}` : userId;
+
+ const userViolations = (await violationLogs.get(key)) ?? 0;
+ const violationCount = +userViolations + +score;
+ await violationLogs.set(key, violationCount);
+
+ errorMessage.user_id = userId;
+ errorMessage.prev_count = userViolations;
+ errorMessage.violation_count = violationCount;
+ errorMessage.date = new Date().toISOString();
+
+ await banViolation(req, res, errorMessage);
+ const userLogs = (await logs.get(key)) ?? [];
+ userLogs.push(errorMessage);
+ delete errorMessage.user_id;
+ await logs.set(key, userLogs);
+};
+
+module.exports = logViolation;
diff --git a/api/config/index.js b/api/config/index.js
new file mode 100644
index 0000000..6d9f70e
--- /dev/null
+++ b/api/config/index.js
@@ -0,0 +1,57 @@
+const { EventSource } = require('eventsource');
+const { Time } = require('librechat-data-provider');
+const {
+ mcpConfig,
+ MCPManager,
+ FlowStateManager,
+ MCPServersRegistry,
+ OAuthReconnectionManager,
+} = require('@librechat/api');
+
+global.EventSource = EventSource;
+
+/** @type {FlowStateManager} */
+let flowManager = null;
+/** @type {FlowStateManager} */
+let actionFlowManager = null;
+
+/**
+ * Flow manager for MCP OAuth flows. Uses the longer MCP OAuth TTL so the auth
+ * button and flow state outlive the user-completion window.
+ * @param {Keyv} flowsCache
+ * @returns {FlowStateManager}
+ */
+function getFlowStateManager(flowsCache) {
+ if (!flowManager) {
+ flowManager = new FlowStateManager(flowsCache, {
+ ttl: mcpConfig.OAUTH_FLOW_TTL,
+ });
+ }
+ return flowManager;
+}
+
+/**
+ * Flow manager for Action (custom tool) OAuth flows. Kept on the shorter TTL so an
+ * unclicked action login does not leave the tool call waiting for the MCP OAuth window.
+ * @param {Keyv} flowsCache
+ * @returns {FlowStateManager}
+ */
+function getActionFlowStateManager(flowsCache) {
+ if (!actionFlowManager) {
+ actionFlowManager = new FlowStateManager(flowsCache, {
+ ttl: Time.ONE_MINUTE * 3,
+ });
+ }
+ return actionFlowManager;
+}
+
+module.exports = {
+ createMCPServersRegistry: MCPServersRegistry.createInstance,
+ getMCPServersRegistry: MCPServersRegistry.getInstance,
+ createMCPManager: MCPManager.createInstance,
+ getMCPManager: MCPManager.getInstance,
+ getFlowStateManager,
+ getActionFlowStateManager,
+ createOAuthReconnectionManager: OAuthReconnectionManager.createInstance,
+ getOAuthReconnectionManager: OAuthReconnectionManager.getInstance,
+};
diff --git a/api/config/meiliLogger.js b/api/config/meiliLogger.js
new file mode 100644
index 0000000..7eb6e3b
--- /dev/null
+++ b/api/config/meiliLogger.js
@@ -0,0 +1,110 @@
+const path = require('path');
+const fs = require('fs');
+const winston = require('winston');
+require('winston-daily-rotate-file');
+
+/**
+ * Determine the log directory.
+ * Priority:
+ * 1. LIBRECHAT_LOG_DIR environment variable (allows user override)
+ * 2. /app/logs if running in Docker (bind-mounted with correct permissions)
+ * 3. api/logs relative to this file (local development)
+ */
+const getLogDir = () => {
+ if (process.env.LIBRECHAT_LOG_DIR) {
+ return process.env.LIBRECHAT_LOG_DIR;
+ }
+
+ // Check if running in Docker container (cwd is /app)
+ if (process.cwd() === '/app') {
+ const dockerLogDir = '/app/logs';
+ // Ensure the directory exists
+ if (!fs.existsSync(dockerLogDir)) {
+ fs.mkdirSync(dockerLogDir, { recursive: true });
+ }
+ return dockerLogDir;
+ }
+
+ // Local development: use api/logs relative to this file
+ return path.join(__dirname, '..', 'logs');
+};
+
+const { NODE_ENV, DEBUG_LOGGING = false, LOG_TO_FILE = true } = process.env;
+
+const useDebugLogging =
+ (typeof DEBUG_LOGGING === 'string' && DEBUG_LOGGING?.toLowerCase() === 'true') ||
+ DEBUG_LOGGING === true;
+
+const useFileLogging =
+ (typeof LOG_TO_FILE === 'string' && LOG_TO_FILE?.toLowerCase() !== 'false') ||
+ LOG_TO_FILE === true;
+
+const levels = {
+ error: 0,
+ warn: 1,
+ info: 2,
+ http: 3,
+ verbose: 4,
+ debug: 5,
+ activity: 6,
+ silly: 7,
+};
+
+winston.addColors({
+ info: 'green', // fontStyle color
+ warn: 'italic yellow',
+ error: 'red',
+ debug: 'blue',
+});
+
+const level = () => {
+ const env = NODE_ENV || 'development';
+ const isDevelopment = env === 'development';
+ return isDevelopment ? 'debug' : 'warn';
+};
+
+const fileFormat = winston.format.combine(
+ winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
+ winston.format.errors({ stack: true }),
+ winston.format.splat(),
+);
+
+const logLevel = useDebugLogging ? 'debug' : 'error';
+const transports = [];
+
+if (useFileLogging) {
+ const logDir = getLogDir();
+
+ transports.push(
+ new winston.transports.DailyRotateFile({
+ level: logLevel,
+ filename: `${logDir}/meiliSync-%DATE%.log`,
+ datePattern: 'YYYY-MM-DD',
+ zippedArchive: true,
+ maxSize: '20m',
+ maxFiles: '14d',
+ format: fileFormat,
+ }),
+ );
+}
+
+const consoleFormat = winston.format.combine(
+ winston.format.colorize({ all: true }),
+ winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
+ winston.format.printf((info) => `${info.timestamp} ${info.level}: ${info.message}`),
+);
+
+transports.push(
+ new winston.transports.Console({
+ level: 'info',
+ format: consoleFormat,
+ }),
+);
+
+const logger = winston.createLogger({
+ level: level(),
+ levels,
+ transports,
+});
+
+module.exports = logger;
diff --git a/api/config/paths.js b/api/config/paths.js
new file mode 100644
index 0000000..165e9e6
--- /dev/null
+++ b/api/config/paths.js
@@ -0,0 +1,14 @@
+const path = require('path');
+
+module.exports = {
+ root: path.resolve(__dirname, '..', '..'),
+ uploads: path.resolve(__dirname, '..', '..', 'uploads'),
+ clientPath: path.resolve(__dirname, '..', '..', 'client'),
+ dist: path.resolve(__dirname, '..', '..', 'client', 'dist'),
+ publicPath: path.resolve(__dirname, '..', '..', 'client', 'public'),
+ fonts: path.resolve(__dirname, '..', '..', 'client', 'public', 'fonts'),
+ assets: path.resolve(__dirname, '..', '..', 'client', 'public', 'assets'),
+ imageOutput: path.resolve(__dirname, '..', '..', 'client', 'public', 'images'),
+ structuredTools: path.resolve(__dirname, '..', 'app', 'clients', 'tools', 'structured'),
+ pluginManifest: path.resolve(__dirname, '..', 'app', 'clients', 'tools', 'manifest.json'),
+};
diff --git a/api/db/connect.js b/api/db/connect.js
new file mode 100644
index 0000000..a63d330
--- /dev/null
+++ b/api/db/connect.js
@@ -0,0 +1,85 @@
+require('dotenv').config();
+const { isEnabled, instrumentMongooseQueryMetrics } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+
+const mongoose = require('mongoose');
+const MONGO_URI = process.env.MONGO_URI;
+
+instrumentMongooseQueryMetrics(mongoose);
+
+if (!MONGO_URI) {
+ throw new Error('Please define the MONGO_URI environment variable');
+}
+/** The maximum number of connections in the connection pool. */
+const maxPoolSize = parseInt(process.env.MONGO_MAX_POOL_SIZE) || undefined;
+/** The minimum number of connections in the connection pool. */
+const minPoolSize = parseInt(process.env.MONGO_MIN_POOL_SIZE) || undefined;
+/** The maximum number of connections that may be in the process of being established concurrently by the connection pool. */
+const maxConnecting = parseInt(process.env.MONGO_MAX_CONNECTING) || undefined;
+/** The maximum number of milliseconds that a connection can remain idle in the pool before being removed and closed. */
+const maxIdleTimeMS = parseInt(process.env.MONGO_MAX_IDLE_TIME_MS) || undefined;
+/** The maximum time in milliseconds that a thread can wait for a connection to become available. */
+const waitQueueTimeoutMS = parseInt(process.env.MONGO_WAIT_QUEUE_TIMEOUT_MS) || undefined;
+/** Set to false to disable automatic index creation for all models associated with this connection. */
+const autoIndex =
+ process.env.MONGO_AUTO_INDEX != undefined
+ ? isEnabled(process.env.MONGO_AUTO_INDEX) || false
+ : undefined;
+
+/** Set to `false` to disable Mongoose automatically calling `createCollection()` on every model created on this connection. */
+const autoCreate =
+ process.env.MONGO_AUTO_CREATE != undefined
+ ? isEnabled(process.env.MONGO_AUTO_CREATE) || false
+ : undefined;
+/**
+ * Global is used here to maintain a cached connection across hot reloads
+ * in development. This prevents connections growing exponentially
+ * during API Route usage.
+ */
+let cached = global.mongoose;
+
+if (!cached) {
+ cached = global.mongoose = { conn: null, promise: null };
+}
+
+mongoose.connection.on('error', (err) => {
+ logger.error('[connectDb] MongoDB connection error:', err);
+});
+
+async function connectDb() {
+ if (cached.conn && cached.conn?._readyState === 1) {
+ return cached.conn;
+ }
+
+ const disconnected = cached.conn && cached.conn?._readyState !== 1;
+ if (!cached.promise || disconnected) {
+ const opts = {
+ bufferCommands: false,
+ ...(maxPoolSize ? { maxPoolSize } : {}),
+ ...(minPoolSize ? { minPoolSize } : {}),
+ ...(maxConnecting ? { maxConnecting } : {}),
+ ...(maxIdleTimeMS ? { maxIdleTimeMS } : {}),
+ ...(waitQueueTimeoutMS ? { waitQueueTimeoutMS } : {}),
+ ...(autoIndex != undefined ? { autoIndex } : {}),
+ ...(autoCreate != undefined ? { autoCreate } : {}),
+ // useNewUrlParser: true,
+ // useUnifiedTopology: true,
+ // bufferMaxEntries: 0,
+ // useFindAndModify: true,
+ // useCreateIndex: true
+ };
+ logger.info('Mongo Connection options');
+ logger.info(JSON.stringify(opts, null, 2));
+ mongoose.set('strictQuery', true);
+ cached.promise = mongoose.connect(MONGO_URI, opts).then((mongoose) => {
+ return mongoose;
+ });
+ }
+ cached.conn = await cached.promise;
+
+ return cached.conn;
+}
+
+module.exports = {
+ connectDb,
+};
diff --git a/api/db/index.js b/api/db/index.js
new file mode 100644
index 0000000..f4359c8
--- /dev/null
+++ b/api/db/index.js
@@ -0,0 +1,13 @@
+const mongoose = require('mongoose');
+const { createModels } = require('@librechat/data-schemas');
+const { connectDb } = require('./connect');
+
+// 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 };
diff --git a/api/db/index.spec.js b/api/db/index.spec.js
new file mode 100644
index 0000000..e1ebe17
--- /dev/null
+++ b/api/db/index.spec.js
@@ -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']);
+ });
+});
diff --git a/api/db/indexSync.js b/api/db/indexSync.js
new file mode 100644
index 0000000..1305903
--- /dev/null
+++ b/api/db/indexSync.js
@@ -0,0 +1,383 @@
+const mongoose = require('mongoose');
+const { MeiliSearch } = require('meilisearch');
+const { logger } = require('@librechat/data-schemas');
+const { CacheKeys } = require('librechat-data-provider');
+const { isEnabled, FlowStateManager } = require('@librechat/api');
+const { getLogStores } = require('~/cache');
+const { batchResetMeiliFlags } = require('./utils');
+
+const searchEnabled = isEnabled(process.env.SEARCH);
+const indexingDisabled = isEnabled(process.env.MEILI_NO_SYNC);
+let currentTimeout = null;
+
+const defaultSyncThreshold = 1000;
+const syncThreshold = process.env.MEILI_SYNC_THRESHOLD
+ ? parseInt(process.env.MEILI_SYNC_THRESHOLD, 10)
+ : defaultSyncThreshold;
+
+class MeiliSearchClient {
+ static instance = null;
+
+ static getInstance() {
+ if (!MeiliSearchClient.instance) {
+ if (!process.env.MEILI_HOST || !process.env.MEILI_MASTER_KEY) {
+ throw new Error('Meilisearch configuration is missing.');
+ }
+ MeiliSearchClient.instance = new MeiliSearch({
+ host: process.env.MEILI_HOST,
+ apiKey: process.env.MEILI_MASTER_KEY,
+ });
+ }
+ return MeiliSearchClient.instance;
+ }
+}
+
+/**
+ * Deletes documents from MeiliSearch index that are missing the user field
+ * @param {import('meilisearch').Index} index - MeiliSearch index instance
+ * @param {string} indexName - Name of the index for logging
+ * @returns {Promise} - Number of documents deleted
+ */
+async function deleteDocumentsWithoutUserField(index, indexName) {
+ let deletedCount = 0;
+ let offset = 0;
+ const batchSize = 1000;
+
+ try {
+ while (true) {
+ const searchResult = await index.search('', {
+ limit: batchSize,
+ offset: offset,
+ });
+
+ if (searchResult.hits.length === 0) {
+ break;
+ }
+
+ const idsToDelete = searchResult.hits.filter((hit) => !hit.user).map((hit) => hit.id);
+
+ if (idsToDelete.length > 0) {
+ logger.info(
+ `[indexSync] Deleting ${idsToDelete.length} documents without user field from ${indexName} index`,
+ );
+ await index.deleteDocuments(idsToDelete);
+ deletedCount += idsToDelete.length;
+ }
+
+ if (searchResult.hits.length < batchSize) {
+ break;
+ }
+
+ offset += batchSize;
+ }
+
+ if (deletedCount > 0) {
+ logger.info(`[indexSync] Deleted ${deletedCount} orphaned documents from ${indexName} index`);
+ }
+ } catch (error) {
+ logger.error(`[indexSync] Error deleting documents from ${indexName}:`, error);
+ }
+
+ return deletedCount;
+}
+
+/**
+ * Ensures indexes have proper filterable attributes configured and checks if documents have user field
+ * @param {MeiliSearch} client - MeiliSearch client instance
+ * @returns {Promise<{settingsUpdated: boolean, orphanedDocsFound: boolean}>} - Status of what was done
+ */
+async function ensureFilterableAttributes(client) {
+ let settingsUpdated = false;
+ let hasOrphanedDocs = false;
+
+ try {
+ // Check and update messages index
+ try {
+ const messagesIndex = client.index('messages');
+ const settings = await messagesIndex.getSettings();
+
+ if (!settings.filterableAttributes || !settings.filterableAttributes.includes('user')) {
+ logger.info('[indexSync] Configuring messages index to filter by user...');
+ await messagesIndex.updateSettings({
+ filterableAttributes: ['user'],
+ });
+ logger.info('[indexSync] Messages index configured for user filtering');
+ settingsUpdated = true;
+ }
+
+ // Check if existing documents have user field indexed
+ try {
+ const searchResult = await messagesIndex.search('', { limit: 1 });
+ if (searchResult.hits.length > 0 && !searchResult.hits[0].user) {
+ logger.info(
+ '[indexSync] Existing messages missing user field, will clean up orphaned documents...',
+ );
+ hasOrphanedDocs = true;
+ }
+ } catch (searchError) {
+ logger.debug('[indexSync] Could not check message documents:', searchError.message);
+ }
+ } catch (error) {
+ if (error.code !== 'index_not_found') {
+ logger.warn('[indexSync] Could not check/update messages index settings:', error.message);
+ }
+ }
+
+ // Check and update conversations index
+ try {
+ const convosIndex = client.index('convos');
+ const settings = await convosIndex.getSettings();
+
+ if (!settings.filterableAttributes || !settings.filterableAttributes.includes('user')) {
+ logger.info('[indexSync] Configuring convos index to filter by user...');
+ await convosIndex.updateSettings({
+ filterableAttributes: ['user'],
+ });
+ logger.info('[indexSync] Convos index configured for user filtering');
+ settingsUpdated = true;
+ }
+
+ // Check if existing documents have user field indexed
+ try {
+ const searchResult = await convosIndex.search('', { limit: 1 });
+ if (searchResult.hits.length > 0 && !searchResult.hits[0].user) {
+ logger.info(
+ '[indexSync] Existing conversations missing user field, will clean up orphaned documents...',
+ );
+ hasOrphanedDocs = true;
+ }
+ } catch (searchError) {
+ logger.debug('[indexSync] Could not check conversation documents:', searchError.message);
+ }
+ } catch (error) {
+ if (error.code !== 'index_not_found') {
+ logger.warn('[indexSync] Could not check/update convos index settings:', error.message);
+ }
+ }
+
+ // If either index has orphaned documents, clean them up (but don't force resync)
+ if (hasOrphanedDocs) {
+ try {
+ const messagesIndex = client.index('messages');
+ await deleteDocumentsWithoutUserField(messagesIndex, 'messages');
+ } catch (error) {
+ logger.debug('[indexSync] Could not clean up messages:', error.message);
+ }
+
+ try {
+ const convosIndex = client.index('convos');
+ await deleteDocumentsWithoutUserField(convosIndex, 'convos');
+ } catch (error) {
+ logger.debug('[indexSync] Could not clean up convos:', error.message);
+ }
+
+ logger.info('[indexSync] Orphaned documents cleaned up without forcing resync.');
+ }
+
+ if (settingsUpdated) {
+ logger.info('[indexSync] Index settings updated. Full re-sync will be triggered.');
+ }
+ } catch (error) {
+ logger.error('[indexSync] Error ensuring filterable attributes:', error);
+ }
+
+ return { settingsUpdated, orphanedDocsFound: hasOrphanedDocs };
+}
+
+/**
+ * Performs the actual sync operations for messages and conversations
+ * @param {FlowStateManager} flowManager - Flow state manager instance
+ * @param {string} flowId - Flow identifier
+ * @param {string} flowType - Flow type
+ */
+async function performSync(flowManager, flowId, flowType) {
+ try {
+ if (indexingDisabled === true) {
+ logger.info('[indexSync] Indexing is disabled, skipping...');
+ 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();
+ if (status !== 'available') {
+ throw new Error('Meilisearch not available');
+ }
+
+ /** Ensures indexes have proper filterable attributes configured */
+ const { settingsUpdated, orphanedDocsFound: _orphanedDocsFound } =
+ await ensureFilterableAttributes(client);
+
+ let messagesSync = false;
+ let convosSync = false;
+
+ // Only reset flags if settings were actually updated (not just for orphaned doc cleanup)
+ if (settingsUpdated) {
+ logger.info(
+ '[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...',
+ );
+
+ // Reset sync flags to force full re-sync
+ await batchResetMeiliFlags(Message.collection);
+ await batchResetMeiliFlags(Conversation.collection);
+ }
+
+ // Check if we need to sync messages
+ logger.info('[indexSync] Requesting message sync progress...');
+ const messageProgress = await Message.getSyncProgress();
+ if (!messageProgress.isComplete || settingsUpdated) {
+ logger.info(
+ `[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`,
+ );
+
+ const messageCount = messageProgress.totalDocuments;
+ const messagesIndexed = messageProgress.totalProcessed;
+ const unindexedMessages = messageCount - messagesIndexed;
+ const noneIndexed = messagesIndexed === 0 && unindexedMessages > 0;
+
+ if (settingsUpdated || noneIndexed || unindexedMessages > syncThreshold) {
+ if (noneIndexed && !settingsUpdated) {
+ logger.info('[indexSync] No messages marked as indexed, forcing full sync');
+ }
+ logger.info(`[indexSync] Starting message sync (${unindexedMessages} unindexed)`);
+ await Message.syncWithMeili();
+ messagesSync = true;
+ } else if (unindexedMessages > 0) {
+ logger.info(
+ `[indexSync] ${unindexedMessages} messages unindexed (below threshold: ${syncThreshold}, skipping)`,
+ );
+ }
+ } else {
+ logger.info(
+ `[indexSync] Messages are fully synced: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments}`,
+ );
+ }
+
+ // Check if we need to sync conversations
+ const convoProgress = await Conversation.getSyncProgress();
+ if (!convoProgress.isComplete || settingsUpdated) {
+ logger.info(
+ `[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`,
+ );
+
+ const convoCount = convoProgress.totalDocuments;
+ const convosIndexed = convoProgress.totalProcessed;
+ const unindexedConvos = convoCount - convosIndexed;
+ const noneConvosIndexed = convosIndexed === 0 && unindexedConvos > 0;
+
+ if (settingsUpdated || noneConvosIndexed || unindexedConvos > syncThreshold) {
+ if (noneConvosIndexed && !settingsUpdated) {
+ logger.info('[indexSync] No conversations marked as indexed, forcing full sync');
+ }
+ logger.info(`[indexSync] Starting convos sync (${unindexedConvos} unindexed)`);
+ await Conversation.syncWithMeili();
+ convosSync = true;
+ } else if (unindexedConvos > 0) {
+ logger.info(
+ `[indexSync] ${unindexedConvos} convos unindexed (below threshold: ${syncThreshold}, skipping)`,
+ );
+ }
+ } else {
+ logger.info(
+ `[indexSync] Conversations are fully synced: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments}`,
+ );
+ }
+
+ return { messagesSync, convosSync };
+ } finally {
+ if (indexingDisabled === true) {
+ logger.info('[indexSync] Indexing is disabled, skipping cleanup...');
+ } else if (flowManager && flowId && flowType) {
+ try {
+ await flowManager.deleteFlow(flowId, flowType);
+ logger.debug('[indexSync] Flow state cleaned up');
+ } catch (cleanupErr) {
+ logger.debug('[indexSync] Could not clean up flow state:', cleanupErr.message);
+ }
+ }
+ }
+}
+
+/**
+ * Main index sync function that uses FlowStateManager to prevent concurrent execution
+ */
+async function indexSync() {
+ if (!searchEnabled) {
+ return;
+ }
+
+ logger.info('[indexSync] Starting index synchronization check...');
+
+ // Get or create FlowStateManager instance
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ if (!flowsCache) {
+ logger.warn('[indexSync] Flows cache not available, falling back to direct sync');
+ return await performSync(null, null, null);
+ }
+
+ const flowManager = new FlowStateManager(flowsCache, {
+ ttl: 60000 * 10, // 10 minutes TTL for sync operations
+ });
+
+ // Use a unique flow ID for the sync operation
+ const flowId = 'meili-index-sync';
+ const flowType = 'MEILI_SYNC';
+
+ try {
+ // This will only execute the handler if no other instance is running the sync
+ const result = await flowManager.createFlowWithHandler(flowId, flowType, () =>
+ performSync(flowManager, flowId, flowType),
+ );
+
+ if (result.messagesSync || result.convosSync) {
+ logger.info('[indexSync] Sync completed successfully');
+ } else {
+ logger.debug('[indexSync] No sync was needed');
+ }
+
+ return result;
+ } catch (err) {
+ if (err.message.includes('flow already exists')) {
+ logger.info('[indexSync] Sync already running on another instance');
+ return;
+ }
+
+ if (err.message.includes('not found')) {
+ 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) {
+ logger.error('[indexSync] Trouble creating indices, try restarting the server.', err);
+ }
+ }, 750);
+ } else if (err.message.includes('Meilisearch not configured')) {
+ logger.info('[indexSync] Meilisearch not configured, search will be disabled.');
+ } else {
+ logger.error('[indexSync] error', err);
+ }
+ }
+}
+
+process.on('exit', () => {
+ logger.debug('[indexSync] Clearing sync timeouts before exiting...');
+ clearTimeout(currentTimeout);
+});
+
+module.exports = indexSync;
diff --git a/api/db/indexSync.spec.js b/api/db/indexSync.spec.js
new file mode 100644
index 0000000..dbe07c7
--- /dev/null
+++ b/api/db/indexSync.spec.js
@@ -0,0 +1,530 @@
+/**
+ * Unit tests for performSync() function in indexSync.js
+ *
+ * Tests use real mongoose with mocked model methods, only mocking external calls.
+ */
+
+const mongoose = require('mongoose');
+
+// Mock only external dependencies (not internal classes/models)
+const mockLogger = {
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ debug: jest.fn(),
+};
+
+const mockMeiliHealth = jest.fn();
+const mockMeiliIndex = jest.fn();
+const mockBatchResetMeiliFlags = jest.fn();
+const mockIsEnabled = jest.fn();
+const mockGetLogStores = jest.fn();
+
+// Create mock models that will be reused
+const createMockModel = (collectionName) => ({
+ collection: { name: collectionName },
+ getSyncProgress: jest.fn(),
+ syncWithMeili: jest.fn(),
+ countDocuments: jest.fn(),
+});
+
+const originalMessageModel = mongoose.models.Message;
+const originalConversationModel = mongoose.models.Conversation;
+
+// Mock external modules
+jest.mock('@librechat/data-schemas', () => ({
+ logger: mockLogger,
+}));
+
+jest.mock('meilisearch', () => ({
+ MeiliSearch: jest.fn(() => ({
+ health: mockMeiliHealth,
+ index: mockMeiliIndex,
+ })),
+}));
+
+jest.mock('./utils', () => ({
+ batchResetMeiliFlags: mockBatchResetMeiliFlags,
+}));
+
+jest.mock('@librechat/api', () => ({
+ isEnabled: mockIsEnabled,
+ FlowStateManager: jest.fn(),
+}));
+
+jest.mock('~/cache', () => ({
+ getLogStores: mockGetLogStores,
+}));
+
+// Set environment before module load
+process.env.MEILI_HOST = 'http://localhost:7700';
+process.env.MEILI_MASTER_KEY = 'test-key';
+process.env.SEARCH = 'true';
+process.env.MEILI_SYNC_THRESHOLD = '1000'; // Set threshold before module loads
+
+describe('performSync() - syncThreshold logic', () => {
+ const ORIGINAL_ENV = process.env;
+ let Message;
+ let Conversation;
+
+ beforeAll(() => {
+ Message = createMockModel('messages');
+ Conversation = createMockModel('conversations');
+
+ mongoose.models.Message = Message;
+ mongoose.models.Conversation = Conversation;
+ });
+
+ beforeEach(() => {
+ // Reset all mocks
+ jest.clearAllMocks();
+ // Reset modules to ensure fresh load of indexSync.js and its top-level consts (like syncThreshold)
+ jest.resetModules();
+
+ // Set up environment
+ process.env = { ...ORIGINAL_ENV };
+ process.env.MEILI_HOST = 'http://localhost:7700';
+ process.env.MEILI_MASTER_KEY = 'test-key';
+ process.env.SEARCH = 'true';
+ delete process.env.MEILI_NO_SYNC;
+
+ // Re-ensure models are available in mongoose after resetModules
+ // We must require mongoose again to get the fresh instance that indexSync will use
+ const mongoose = require('mongoose');
+ mongoose.models.Message = Message;
+ mongoose.models.Conversation = Conversation;
+
+ // Mock isEnabled
+ mockIsEnabled.mockImplementation((val) => val === 'true' || val === true);
+
+ // Mock MeiliSearch client responses
+ mockMeiliHealth.mockResolvedValue({ status: 'available' });
+ mockMeiliIndex.mockReturnValue({
+ getSettings: jest.fn().mockResolvedValue({ filterableAttributes: ['user'] }),
+ updateSettings: jest.fn().mockResolvedValue({}),
+ search: jest.fn().mockResolvedValue({ hits: [] }),
+ });
+
+ mockBatchResetMeiliFlags.mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ process.env = ORIGINAL_ENV;
+ });
+
+ afterAll(() => {
+ mongoose.models.Message = originalMessageModel;
+ mongoose.models.Conversation = originalConversationModel;
+ });
+
+ test('triggers sync when unindexed messages exceed syncThreshold', async () => {
+ // Arrange: Set threshold before module load
+ process.env.MEILI_SYNC_THRESHOLD = '1000';
+
+ // Arrange: 1050 unindexed messages > 1000 threshold
+ Message.getSyncProgress.mockResolvedValue({
+ totalProcessed: 100,
+ totalDocuments: 1150, // 1050 unindexed
+ isComplete: false,
+ });
+
+ Conversation.getSyncProgress.mockResolvedValue({
+ totalProcessed: 50,
+ totalDocuments: 50,
+ isComplete: true,
+ });
+
+ Message.syncWithMeili.mockResolvedValue(undefined);
+
+ // Act
+ const indexSync = require('./indexSync');
+ await indexSync();
+
+ // Assert: No countDocuments calls
+ expect(Message.countDocuments).not.toHaveBeenCalled();
+ expect(Conversation.countDocuments).not.toHaveBeenCalled();
+
+ // Assert: Message sync triggered because 1050 > 1000
+ expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] Messages need syncing: 100/1150 indexed',
+ );
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] Starting message sync (1050 unindexed)',
+ );
+
+ // Assert: Conversation sync NOT triggered (already complete)
+ expect(Conversation.syncWithMeili).not.toHaveBeenCalled();
+ });
+
+ test('skips sync when unindexed messages are below syncThreshold', async () => {
+ // Arrange: 50 unindexed messages < 1000 threshold
+ Message.getSyncProgress.mockResolvedValue({
+ totalProcessed: 100,
+ totalDocuments: 150, // 50 unindexed
+ isComplete: false,
+ });
+
+ Conversation.getSyncProgress.mockResolvedValue({
+ totalProcessed: 50,
+ totalDocuments: 50,
+ isComplete: true,
+ });
+
+ process.env.MEILI_SYNC_THRESHOLD = '1000';
+
+ // Act
+ const indexSync = require('./indexSync');
+ await indexSync();
+
+ // Assert: No countDocuments calls
+ expect(Message.countDocuments).not.toHaveBeenCalled();
+ expect(Conversation.countDocuments).not.toHaveBeenCalled();
+
+ // Assert: Message sync NOT triggered because 50 < 1000
+ expect(Message.syncWithMeili).not.toHaveBeenCalled();
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] Messages need syncing: 100/150 indexed',
+ );
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] 50 messages unindexed (below threshold: 1000, skipping)',
+ );
+
+ // Assert: Conversation sync NOT triggered (already complete)
+ expect(Conversation.syncWithMeili).not.toHaveBeenCalled();
+ });
+
+ test('respects syncThreshold at boundary (exactly at threshold)', async () => {
+ // Arrange: 1000 unindexed messages = 1000 threshold (NOT greater than)
+ Message.getSyncProgress.mockResolvedValue({
+ totalProcessed: 100,
+ totalDocuments: 1100, // 1000 unindexed
+ isComplete: false,
+ });
+
+ Conversation.getSyncProgress.mockResolvedValue({
+ totalProcessed: 0,
+ totalDocuments: 0,
+ isComplete: true,
+ });
+
+ process.env.MEILI_SYNC_THRESHOLD = '1000';
+
+ // Act
+ const indexSync = require('./indexSync');
+ await indexSync();
+
+ // Assert: No countDocuments calls
+ expect(Message.countDocuments).not.toHaveBeenCalled();
+
+ // Assert: Message sync NOT triggered because 1000 is NOT > 1000
+ expect(Message.syncWithMeili).not.toHaveBeenCalled();
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] Messages need syncing: 100/1100 indexed',
+ );
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] 1000 messages unindexed (below threshold: 1000, skipping)',
+ );
+ });
+
+ test('triggers sync when unindexed is threshold + 1', async () => {
+ // Arrange: 1001 unindexed messages > 1000 threshold
+ Message.getSyncProgress.mockResolvedValue({
+ totalProcessed: 100,
+ totalDocuments: 1101, // 1001 unindexed
+ isComplete: false,
+ });
+
+ Conversation.getSyncProgress.mockResolvedValue({
+ totalProcessed: 0,
+ totalDocuments: 0,
+ isComplete: true,
+ });
+
+ Message.syncWithMeili.mockResolvedValue(undefined);
+
+ process.env.MEILI_SYNC_THRESHOLD = '1000';
+
+ // Act
+ const indexSync = require('./indexSync');
+ await indexSync();
+
+ // Assert: No countDocuments calls
+ expect(Message.countDocuments).not.toHaveBeenCalled();
+
+ // Assert: Message sync triggered because 1001 > 1000
+ expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] Messages need syncing: 100/1101 indexed',
+ );
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] Starting message sync (1001 unindexed)',
+ );
+ });
+
+ test('uses totalDocuments from convoProgress for conversation sync decisions', async () => {
+ // Arrange: Messages complete, conversations need sync
+ Message.getSyncProgress.mockResolvedValue({
+ totalProcessed: 100,
+ totalDocuments: 100,
+ isComplete: true,
+ });
+
+ Conversation.getSyncProgress.mockResolvedValue({
+ totalProcessed: 50,
+ totalDocuments: 1100, // 1050 unindexed > 1000 threshold
+ isComplete: false,
+ });
+
+ Conversation.syncWithMeili.mockResolvedValue(undefined);
+
+ process.env.MEILI_SYNC_THRESHOLD = '1000';
+
+ // Act
+ const indexSync = require('./indexSync');
+ await indexSync();
+
+ // Assert: No countDocuments calls (the optimization)
+ expect(Message.countDocuments).not.toHaveBeenCalled();
+ expect(Conversation.countDocuments).not.toHaveBeenCalled();
+
+ // Assert: Only conversation sync triggered
+ expect(Message.syncWithMeili).not.toHaveBeenCalled();
+ expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1);
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] Conversations need syncing: 50/1100 indexed',
+ );
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] Starting convos sync (1050 unindexed)',
+ );
+ });
+
+ test('skips sync when collections are fully synced', async () => {
+ // Arrange: Everything already synced
+ Message.getSyncProgress.mockResolvedValue({
+ totalProcessed: 100,
+ totalDocuments: 100,
+ isComplete: true,
+ });
+
+ Conversation.getSyncProgress.mockResolvedValue({
+ totalProcessed: 50,
+ totalDocuments: 50,
+ isComplete: true,
+ });
+
+ // Act
+ const indexSync = require('./indexSync');
+ await indexSync();
+
+ // Assert: No countDocuments calls
+ expect(Message.countDocuments).not.toHaveBeenCalled();
+ expect(Conversation.countDocuments).not.toHaveBeenCalled();
+
+ // Assert: No sync triggered
+ expect(Message.syncWithMeili).not.toHaveBeenCalled();
+ expect(Conversation.syncWithMeili).not.toHaveBeenCalled();
+
+ // Assert: Correct logs
+ expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Messages are fully synced: 100/100');
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] Conversations are fully synced: 50/50',
+ );
+ });
+
+ test('triggers message sync when settingsUpdated even if below syncThreshold', async () => {
+ // Arrange: Only 50 unindexed messages (< 1000 threshold), but settings were updated
+ Message.getSyncProgress.mockResolvedValue({
+ totalProcessed: 100,
+ totalDocuments: 150, // 50 unindexed
+ isComplete: false,
+ });
+
+ Conversation.getSyncProgress.mockResolvedValue({
+ totalProcessed: 50,
+ totalDocuments: 50,
+ isComplete: true,
+ });
+
+ Message.syncWithMeili.mockResolvedValue(undefined);
+
+ // Mock settings update scenario
+ mockMeiliIndex.mockReturnValue({
+ getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field
+ updateSettings: jest.fn().mockResolvedValue({}),
+ search: jest.fn().mockResolvedValue({ hits: [] }),
+ });
+
+ process.env.MEILI_SYNC_THRESHOLD = '1000';
+
+ // Act
+ const indexSync = require('./indexSync');
+ await indexSync();
+
+ // Assert: Flags were reset due to settings update
+ expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection);
+ expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection);
+
+ // Assert: Message sync triggered despite being below threshold (50 < 1000)
+ expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...',
+ );
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] Starting message sync (50 unindexed)',
+ );
+ });
+
+ test('triggers conversation sync when settingsUpdated even if below syncThreshold', async () => {
+ // Arrange: Messages complete, conversations have 50 unindexed (< 1000 threshold), but settings were updated
+ Message.getSyncProgress.mockResolvedValue({
+ totalProcessed: 100,
+ totalDocuments: 100,
+ isComplete: true,
+ });
+
+ Conversation.getSyncProgress.mockResolvedValue({
+ totalProcessed: 50,
+ totalDocuments: 100, // 50 unindexed
+ isComplete: false,
+ });
+
+ Conversation.syncWithMeili.mockResolvedValue(undefined);
+
+ // Mock settings update scenario
+ mockMeiliIndex.mockReturnValue({
+ getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field
+ updateSettings: jest.fn().mockResolvedValue({}),
+ search: jest.fn().mockResolvedValue({ hits: [] }),
+ });
+
+ process.env.MEILI_SYNC_THRESHOLD = '1000';
+
+ // Act
+ const indexSync = require('./indexSync');
+ await indexSync();
+
+ // Assert: Flags were reset due to settings update
+ expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection);
+ expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection);
+
+ // Assert: Conversation sync triggered despite being below threshold (50 < 1000)
+ expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1);
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...',
+ );
+ expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (50 unindexed)');
+ });
+
+ test('triggers both message and conversation sync when settingsUpdated even if both below syncThreshold', async () => {
+ // Arrange: Set threshold before module load
+ process.env.MEILI_SYNC_THRESHOLD = '1000';
+
+ // Arrange: Both have documents below threshold (50 each), but settings were updated
+ Message.getSyncProgress.mockResolvedValue({
+ totalProcessed: 100,
+ totalDocuments: 150, // 50 unindexed
+ isComplete: false,
+ });
+
+ Conversation.getSyncProgress.mockResolvedValue({
+ totalProcessed: 50,
+ totalDocuments: 100, // 50 unindexed
+ isComplete: false,
+ });
+
+ Message.syncWithMeili.mockResolvedValue(undefined);
+ Conversation.syncWithMeili.mockResolvedValue(undefined);
+
+ // Mock settings update scenario
+ mockMeiliIndex.mockReturnValue({
+ getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field
+ updateSettings: jest.fn().mockResolvedValue({}),
+ search: jest.fn().mockResolvedValue({ hits: [] }),
+ });
+
+ // Act
+ const indexSync = require('./indexSync');
+ await indexSync();
+
+ // Assert: Flags were reset due to settings update
+ expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection);
+ expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection);
+
+ // Assert: Both syncs triggered despite both being below threshold
+ expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
+ expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1);
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...',
+ );
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] Starting message sync (50 unindexed)',
+ );
+ expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (50 unindexed)');
+ });
+
+ test('forces sync when zero documents indexed (reset scenario) even if below threshold', async () => {
+ Message.getSyncProgress.mockResolvedValue({
+ totalProcessed: 0,
+ totalDocuments: 680,
+ isComplete: false,
+ });
+
+ Conversation.getSyncProgress.mockResolvedValue({
+ totalProcessed: 0,
+ totalDocuments: 76,
+ isComplete: false,
+ });
+
+ Message.syncWithMeili.mockResolvedValue(undefined);
+ Conversation.syncWithMeili.mockResolvedValue(undefined);
+
+ const indexSync = require('./indexSync');
+ await indexSync();
+
+ expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
+ expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1);
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] No messages marked as indexed, forcing full sync',
+ );
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] Starting message sync (680 unindexed)',
+ );
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] No conversations marked as indexed, forcing full sync',
+ );
+ expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (76 unindexed)');
+ });
+
+ test('does NOT force sync when some documents already indexed and below threshold', async () => {
+ Message.getSyncProgress.mockResolvedValue({
+ totalProcessed: 630,
+ totalDocuments: 680,
+ isComplete: false,
+ });
+
+ Conversation.getSyncProgress.mockResolvedValue({
+ totalProcessed: 70,
+ totalDocuments: 76,
+ isComplete: false,
+ });
+
+ const indexSync = require('./indexSync');
+ await indexSync();
+
+ expect(Message.syncWithMeili).not.toHaveBeenCalled();
+ expect(Conversation.syncWithMeili).not.toHaveBeenCalled();
+ expect(mockLogger.info).not.toHaveBeenCalledWith(
+ '[indexSync] No messages marked as indexed, forcing full sync',
+ );
+ expect(mockLogger.info).not.toHaveBeenCalledWith(
+ '[indexSync] No conversations marked as indexed, forcing full sync',
+ );
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] 50 messages unindexed (below threshold: 1000, skipping)',
+ );
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ '[indexSync] 6 convos unindexed (below threshold: 1000, skipping)',
+ );
+ });
+});
diff --git a/api/db/models.js b/api/db/models.js
new file mode 100644
index 0000000..fca1327
--- /dev/null
+++ b/api/db/models.js
@@ -0,0 +1,5 @@
+const mongoose = require('mongoose');
+const { createModels } = require('@librechat/data-schemas');
+const models = createModels(mongoose);
+
+module.exports = { ...models };
diff --git a/api/db/utils.js b/api/db/utils.js
new file mode 100644
index 0000000..f3302c9
--- /dev/null
+++ b/api/db/utils.js
@@ -0,0 +1,93 @@
+const { logger, buildRetentionVisibilityFilter } = require('@librechat/data-schemas');
+
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+/**
+ * Batch update documents in chunks to avoid timeouts on weak instances
+ * @param {mongoose.Collection} collection - MongoDB collection
+ * @returns {Promise} - Total modified count
+ * @throws {Error} - Throws if database operations fail (e.g., network issues, connection loss, permission problems)
+ */
+async function batchResetMeiliFlags(collection) {
+ const DEFAULT_BATCH_SIZE = 1000;
+
+ let BATCH_SIZE = parseEnvInt('MEILI_SYNC_BATCH_SIZE', DEFAULT_BATCH_SIZE);
+ if (BATCH_SIZE === 0) {
+ logger.warn(
+ `[batchResetMeiliFlags] MEILI_SYNC_BATCH_SIZE cannot be 0. Using default: ${DEFAULT_BATCH_SIZE}`,
+ );
+ BATCH_SIZE = DEFAULT_BATCH_SIZE;
+ }
+
+ const BATCH_DELAY_MS = parseEnvInt('MEILI_SYNC_DELAY_MS', 100);
+ let totalModified = 0;
+ let hasMore = true;
+
+ try {
+ while (hasMore) {
+ const docs = await collection
+ .find(
+ { ...buildRetentionVisibilityFilter(), _meiliIndex: { $ne: false } },
+ { projection: { _id: 1 } },
+ )
+ .limit(BATCH_SIZE)
+ .toArray();
+
+ if (docs.length === 0) {
+ break;
+ }
+
+ const ids = docs.map((doc) => doc._id);
+ const result = await collection.updateMany(
+ { _id: { $in: ids } },
+ { $set: { _meiliIndex: false } },
+ );
+
+ totalModified += result.modifiedCount;
+ process.stdout.write(
+ `\r Updating ${collection.collectionName}: ${totalModified} documents...`,
+ );
+
+ if (docs.length < BATCH_SIZE) {
+ hasMore = false;
+ }
+
+ if (hasMore && BATCH_DELAY_MS > 0) {
+ await sleep(BATCH_DELAY_MS);
+ }
+ }
+
+ return totalModified;
+ } catch (error) {
+ throw new Error(
+ `Failed to batch reset Meili flags for collection '${collection.collectionName}' after processing ${totalModified} documents: ${error.message}`,
+ );
+ }
+}
+
+/**
+ * Parse and validate an environment variable as a positive integer
+ * @param {string} varName - Environment variable name
+ * @param {number} defaultValue - Default value to use if invalid or missing
+ * @returns {number} - Parsed value or default
+ */
+function parseEnvInt(varName, defaultValue) {
+ const value = process.env[varName];
+ if (!value) {
+ return defaultValue;
+ }
+
+ const parsed = parseInt(value, 10);
+ if (isNaN(parsed) || parsed < 0) {
+ logger.warn(
+ `[batchResetMeiliFlags] Invalid value for ${varName}="${value}". Expected a positive integer. Using default: ${defaultValue}`,
+ );
+ return defaultValue;
+ }
+
+ return parsed;
+}
+
+module.exports = {
+ batchResetMeiliFlags,
+};
diff --git a/api/db/utils.spec.js b/api/db/utils.spec.js
new file mode 100644
index 0000000..477bd70
--- /dev/null
+++ b/api/db/utils.spec.js
@@ -0,0 +1,577 @@
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { batchResetMeiliFlags } = require('./utils');
+
+describe('batchResetMeiliFlags', () => {
+ let mongoServer;
+ let testCollection;
+ const ORIGINAL_BATCH_SIZE = process.env.MEILI_SYNC_BATCH_SIZE;
+ const ORIGINAL_BATCH_DELAY = process.env.MEILI_SYNC_DELAY_MS;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+
+ // Restore original env variables
+ if (ORIGINAL_BATCH_SIZE !== undefined) {
+ process.env.MEILI_SYNC_BATCH_SIZE = ORIGINAL_BATCH_SIZE;
+ } else {
+ delete process.env.MEILI_SYNC_BATCH_SIZE;
+ }
+
+ if (ORIGINAL_BATCH_DELAY !== undefined) {
+ process.env.MEILI_SYNC_DELAY_MS = ORIGINAL_BATCH_DELAY;
+ } else {
+ delete process.env.MEILI_SYNC_DELAY_MS;
+ }
+ });
+
+ beforeEach(async () => {
+ // Create a fresh collection for each test
+ testCollection = mongoose.connection.db.collection('test_meili_batch');
+ await testCollection.deleteMany({});
+
+ // Reset env variables to defaults
+ delete process.env.MEILI_SYNC_BATCH_SIZE;
+ delete process.env.MEILI_SYNC_DELAY_MS;
+ });
+
+ afterEach(async () => {
+ if (testCollection) {
+ await testCollection.deleteMany({});
+ }
+ });
+
+ describe('basic functionality', () => {
+ it('should reset _meiliIndex flag for documents with expiredAt: null and _meiliIndex: true', async () => {
+ // Insert test documents
+ await testCollection.insertMany([
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true, name: 'doc1' },
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true, name: 'doc2' },
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true, name: 'doc3' },
+ ]);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(3);
+
+ const updatedDocs = await testCollection.find({ _meiliIndex: false }).toArray();
+ expect(updatedDocs).toHaveLength(3);
+
+ const notUpdatedDocs = await testCollection.find({ _meiliIndex: true }).toArray();
+ expect(notUpdatedDocs).toHaveLength(0);
+ });
+
+ it('should not modify documents with expiredAt set', async () => {
+ const expiredDate = new Date();
+ await testCollection.insertMany([
+ { _id: new mongoose.Types.ObjectId(), expiredAt: expiredDate, _meiliIndex: true },
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
+ ]);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(1);
+
+ const expiredDoc = await testCollection.findOne({ expiredAt: expiredDate });
+ expect(expiredDoc._meiliIndex).toBe(true);
+ });
+
+ it('should reset active non-temporary documents with expiredAt set for all-data retention', async () => {
+ const retentionDate = new Date(Date.now() + 60 * 60 * 1000);
+ await testCollection.insertMany([
+ {
+ _id: new mongoose.Types.ObjectId(),
+ isTemporary: false,
+ expiredAt: retentionDate,
+ _meiliIndex: true,
+ },
+ {
+ _id: new mongoose.Types.ObjectId(),
+ isTemporary: true,
+ expiredAt: retentionDate,
+ _meiliIndex: true,
+ },
+ ]);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(1);
+
+ const retainedDoc = await testCollection.findOne({ isTemporary: false });
+ const temporaryDoc = await testCollection.findOne({ isTemporary: true });
+ expect(retainedDoc._meiliIndex).toBe(false);
+ expect(temporaryDoc._meiliIndex).toBe(true);
+ });
+
+ it('should not reset expired non-temporary documents with expiredAt set for all-data retention', async () => {
+ const retentionDate = new Date(Date.now() - 60 * 60 * 1000);
+ await testCollection.insertMany([
+ {
+ _id: new mongoose.Types.ObjectId(),
+ isTemporary: false,
+ expiredAt: retentionDate,
+ _meiliIndex: true,
+ },
+ {
+ _id: new mongoose.Types.ObjectId(),
+ isTemporary: false,
+ expiredAt: null,
+ _meiliIndex: true,
+ },
+ ]);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(1);
+
+ const expiredDoc = await testCollection.findOne({ expiredAt: retentionDate });
+ const permanentDoc = await testCollection.findOne({ expiredAt: null });
+ expect(expiredDoc._meiliIndex).toBe(true);
+ expect(permanentDoc._meiliIndex).toBe(false);
+ });
+
+ it('should not modify documents with _meiliIndex: false', async () => {
+ await testCollection.insertMany([
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: false },
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
+ ]);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(1);
+ });
+
+ it('should return 0 when no documents match the criteria', async () => {
+ await testCollection.insertMany([
+ { _id: new mongoose.Types.ObjectId(), expiredAt: new Date(), _meiliIndex: true },
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: false },
+ ]);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(0);
+ });
+
+ it('should return 0 when collection is empty', async () => {
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(0);
+ });
+ });
+
+ describe('batch processing', () => {
+ it('should process documents in batches according to MEILI_SYNC_BATCH_SIZE', async () => {
+ process.env.MEILI_SYNC_BATCH_SIZE = '2';
+
+ const docs = [];
+ for (let i = 0; i < 5; i++) {
+ docs.push({
+ _id: new mongoose.Types.ObjectId(),
+ expiredAt: null,
+ _meiliIndex: true,
+ name: `doc${i}`,
+ });
+ }
+ await testCollection.insertMany(docs);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(5);
+
+ const updatedDocs = await testCollection.find({ _meiliIndex: false }).toArray();
+ expect(updatedDocs).toHaveLength(5);
+ });
+
+ it('should handle large datasets with small batch sizes', async () => {
+ process.env.MEILI_SYNC_BATCH_SIZE = '10';
+
+ const docs = [];
+ for (let i = 0; i < 25; i++) {
+ docs.push({
+ _id: new mongoose.Types.ObjectId(),
+ expiredAt: null,
+ _meiliIndex: true,
+ });
+ }
+ await testCollection.insertMany(docs);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(25);
+ });
+
+ it('should use default batch size of 1000 when env variable is not set', async () => {
+ // Create exactly 1000 documents to verify default batch behavior
+ const docs = [];
+ for (let i = 0; i < 1000; i++) {
+ docs.push({
+ _id: new mongoose.Types.ObjectId(),
+ expiredAt: null,
+ _meiliIndex: true,
+ });
+ }
+ await testCollection.insertMany(docs);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(1000);
+ });
+ });
+
+ describe('return value', () => {
+ it('should return correct modified count', async () => {
+ await testCollection.insertMany([
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
+ ]);
+
+ await expect(batchResetMeiliFlags(testCollection)).resolves.toBe(1);
+ });
+ });
+
+ describe('batch delay', () => {
+ it('should respect MEILI_SYNC_DELAY_MS between batches', async () => {
+ process.env.MEILI_SYNC_BATCH_SIZE = '2';
+ process.env.MEILI_SYNC_DELAY_MS = '50';
+
+ const docs = [];
+ for (let i = 0; i < 5; i++) {
+ docs.push({
+ _id: new mongoose.Types.ObjectId(),
+ expiredAt: null,
+ _meiliIndex: true,
+ });
+ }
+ await testCollection.insertMany(docs);
+
+ const startTime = Date.now();
+ await batchResetMeiliFlags(testCollection);
+ const endTime = Date.now();
+
+ // With 5 documents and batch size 2, we need 3 batches
+ // That means 2 delays between batches (not after the last one)
+ // So minimum time should be around 100ms (2 * 50ms)
+ // Using a slightly lower threshold to account for timing variations
+ const elapsed = endTime - startTime;
+ expect(elapsed).toBeGreaterThanOrEqual(80);
+ });
+
+ it('should not delay when MEILI_SYNC_DELAY_MS is 0', async () => {
+ process.env.MEILI_SYNC_BATCH_SIZE = '2';
+ process.env.MEILI_SYNC_DELAY_MS = '0';
+
+ const docs = [];
+ for (let i = 0; i < 5; i++) {
+ docs.push({
+ _id: new mongoose.Types.ObjectId(),
+ expiredAt: null,
+ _meiliIndex: true,
+ });
+ }
+ await testCollection.insertMany(docs);
+
+ const startTime = Date.now();
+ await batchResetMeiliFlags(testCollection);
+ const endTime = Date.now();
+
+ const elapsed = endTime - startTime;
+ // Should complete without intentional delays, but database operations still take time
+ // Just verify it completes and returns the correct count
+ expect(elapsed).toBeLessThan(1000); // More reasonable upper bound
+
+ const result = await testCollection.countDocuments({ _meiliIndex: false });
+ expect(result).toBe(5);
+ });
+
+ it('should not delay after the last batch', async () => {
+ process.env.MEILI_SYNC_BATCH_SIZE = '3';
+ process.env.MEILI_SYNC_DELAY_MS = '100';
+
+ // Exactly 3 documents - should fit in one batch, no delay
+ await testCollection.insertMany([
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
+ ]);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ // Verify all 3 documents were processed in a single batch
+ expect(result).toBe(3);
+
+ const updatedDocs = await testCollection.countDocuments({ _meiliIndex: false });
+ expect(updatedDocs).toBe(3);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle documents without _meiliIndex field', async () => {
+ await testCollection.insertMany([
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null },
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
+ ]);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ // both documents should be updated
+ expect(result).toBe(2);
+ });
+
+ it('should handle mixed document states correctly', async () => {
+ await testCollection.insertMany([
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: false },
+ { _id: new mongoose.Types.ObjectId(), expiredAt: new Date(), _meiliIndex: true },
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: null },
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null },
+ ]);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(4);
+
+ const flaggedDocs = await testCollection
+ .find({ expiredAt: null, _meiliIndex: false })
+ .toArray();
+ expect(flaggedDocs).toHaveLength(5); // 4 were updated, 1 was already false
+ });
+ });
+
+ describe('error handling', () => {
+ it('should throw error with context when find operation fails', async () => {
+ const mockCollection = {
+ collectionName: 'test_meili_batch',
+ find: jest.fn().mockReturnValue({
+ limit: jest.fn().mockReturnValue({
+ toArray: jest.fn().mockRejectedValue(new Error('Network error')),
+ }),
+ }),
+ };
+
+ await expect(batchResetMeiliFlags(mockCollection)).rejects.toThrow(
+ "Failed to batch reset Meili flags for collection 'test_meili_batch' after processing 0 documents: Network error",
+ );
+ });
+
+ it('should throw error with context when updateMany operation fails', async () => {
+ const mockCollection = {
+ collectionName: 'test_meili_batch',
+ find: jest.fn().mockReturnValue({
+ limit: jest.fn().mockReturnValue({
+ toArray: jest
+ .fn()
+ .mockResolvedValue([
+ { _id: new mongoose.Types.ObjectId() },
+ { _id: new mongoose.Types.ObjectId() },
+ ]),
+ }),
+ }),
+ updateMany: jest.fn().mockRejectedValue(new Error('Connection lost')),
+ };
+
+ await expect(batchResetMeiliFlags(mockCollection)).rejects.toThrow(
+ "Failed to batch reset Meili flags for collection 'test_meili_batch' after processing 0 documents: Connection lost",
+ );
+ });
+
+ it('should include documents processed count in error when failure occurs mid-batch', async () => {
+ // Set batch size to 2 to force multiple batches
+ process.env.MEILI_SYNC_BATCH_SIZE = '2';
+ process.env.MEILI_SYNC_DELAY_MS = '0'; // No delay for faster test
+
+ let findCallCount = 0;
+ let updateCallCount = 0;
+
+ const mockCollection = {
+ collectionName: 'test_meili_batch',
+ find: jest.fn().mockReturnValue({
+ limit: jest.fn().mockReturnValue({
+ toArray: jest.fn().mockImplementation(() => {
+ findCallCount++;
+ // Return 2 documents for first two calls (to keep loop going)
+ // Return 2 documents for third call (to trigger third update which will fail)
+ if (findCallCount <= 3) {
+ return Promise.resolve([
+ { _id: new mongoose.Types.ObjectId() },
+ { _id: new mongoose.Types.ObjectId() },
+ ]);
+ }
+ // Should not reach here due to error
+ return Promise.resolve([]);
+ }),
+ }),
+ }),
+ updateMany: jest.fn().mockImplementation(() => {
+ updateCallCount++;
+ if (updateCallCount === 1) {
+ return Promise.resolve({ modifiedCount: 2 });
+ } else if (updateCallCount === 2) {
+ return Promise.resolve({ modifiedCount: 2 });
+ } else {
+ return Promise.reject(new Error('Database timeout'));
+ }
+ }),
+ };
+
+ await expect(batchResetMeiliFlags(mockCollection)).rejects.toThrow(
+ "Failed to batch reset Meili flags for collection 'test_meili_batch' after processing 4 documents: Database timeout",
+ );
+ });
+
+ it('should use collection.collectionName in error messages', async () => {
+ const mockCollection = {
+ collectionName: 'messages',
+ find: jest.fn().mockReturnValue({
+ limit: jest.fn().mockReturnValue({
+ toArray: jest.fn().mockRejectedValue(new Error('Permission denied')),
+ }),
+ }),
+ };
+
+ await expect(batchResetMeiliFlags(mockCollection)).rejects.toThrow(
+ "Failed to batch reset Meili flags for collection 'messages' after processing 0 documents: Permission denied",
+ );
+ });
+ });
+
+ describe('environment variable validation', () => {
+ let warnSpy;
+
+ beforeEach(() => {
+ // Mock logger.warn to track warning calls
+ const { logger } = require('@librechat/data-schemas');
+ warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ if (warnSpy) {
+ warnSpy.mockRestore();
+ }
+ });
+
+ it('should log warning and use default when MEILI_SYNC_BATCH_SIZE is not a number', async () => {
+ process.env.MEILI_SYNC_BATCH_SIZE = 'abc';
+
+ await testCollection.insertMany([
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
+ ]);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(1);
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Invalid value for MEILI_SYNC_BATCH_SIZE="abc"'),
+ );
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Using default: 1000'));
+ });
+
+ it('should log warning and use default when MEILI_SYNC_DELAY_MS is not a number', async () => {
+ process.env.MEILI_SYNC_DELAY_MS = 'xyz';
+
+ await testCollection.insertMany([
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
+ ]);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(1);
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Invalid value for MEILI_SYNC_DELAY_MS="xyz"'),
+ );
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Using default: 100'));
+ });
+
+ it('should log warning and use default when MEILI_SYNC_BATCH_SIZE is negative', async () => {
+ process.env.MEILI_SYNC_BATCH_SIZE = '-50';
+
+ await testCollection.insertMany([
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
+ ]);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(1);
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Invalid value for MEILI_SYNC_BATCH_SIZE="-50"'),
+ );
+ });
+
+ it('should log warning and use default when MEILI_SYNC_DELAY_MS is negative', async () => {
+ process.env.MEILI_SYNC_DELAY_MS = '-100';
+
+ await testCollection.insertMany([
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
+ ]);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(1);
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Invalid value for MEILI_SYNC_DELAY_MS="-100"'),
+ );
+ });
+
+ it('should accept valid positive integer values without warnings', async () => {
+ process.env.MEILI_SYNC_BATCH_SIZE = '500';
+ process.env.MEILI_SYNC_DELAY_MS = '50';
+
+ await testCollection.insertMany([
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
+ ]);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(1);
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it('should log warning and use default when MEILI_SYNC_BATCH_SIZE is zero', async () => {
+ process.env.MEILI_SYNC_BATCH_SIZE = '0';
+
+ await testCollection.insertMany([
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
+ ]);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(1);
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('MEILI_SYNC_BATCH_SIZE cannot be 0. Using default: 1000'),
+ );
+ });
+
+ it('should accept zero as a valid value for MEILI_SYNC_DELAY_MS without warnings', async () => {
+ process.env.MEILI_SYNC_DELAY_MS = '0';
+
+ await testCollection.insertMany([
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
+ ]);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(1);
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it('should not log warnings when environment variables are not set', async () => {
+ delete process.env.MEILI_SYNC_BATCH_SIZE;
+ delete process.env.MEILI_SYNC_DELAY_MS;
+
+ await testCollection.insertMany([
+ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
+ ]);
+
+ const result = await batchResetMeiliFlags(testCollection);
+
+ expect(result).toBe(1);
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/api/jest.config.js b/api/jest.config.js
new file mode 100644
index 0000000..daa1200
--- /dev/null
+++ b/api/jest.config.js
@@ -0,0 +1,35 @@
+const esModules = [
+ 'openid-client',
+ 'oauth4webapi',
+ 'jose',
+ '@langchain/langgraph',
+ '@langchain/langgraph-checkpoint',
+ '@langchain/langgraph-sdk',
+ '@mistralai/mistralai',
+ 'uuid',
+].join('|');
+
+module.exports = {
+ testEnvironment: 'node',
+ clearMocks: true,
+ roots: [''],
+ coverageDirectory: 'coverage',
+ maxWorkers: '50%',
+ testTimeout: 30000, // 30 seconds timeout for all tests
+ setupFiles: ['./test/jestSetup.js', './test/__mocks__/logger.js'],
+ moduleNameMapper: {
+ '~/(.*)': '/$1',
+ '~/data/auth.json': '/__mocks__/auth.mock.json',
+ '^openid-client/passport$': '/test/__mocks__/openid-client-passport.js',
+ '^openid-client$': '/test/__mocks__/openid-client.js',
+ },
+ transform: {
+ '\\.[jt]sx?$': [
+ 'babel-jest',
+ {
+ presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
+ },
+ ],
+ },
+ transformIgnorePatterns: [`/node_modules/(?!(${esModules})/).*/`],
+};
diff --git a/api/jsconfig.json b/api/jsconfig.json
new file mode 100644
index 0000000..756746f
--- /dev/null
+++ b/api/jsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES6",
+ "module": "CommonJS",
+ // "checkJs": true, // Report errors in JavaScript files
+ "baseUrl": "./",
+ "paths": {
+ "*": ["*", "node_modules/*"],
+ "~/*": ["./*"]
+ }
+ },
+ "exclude": ["node_modules"]
+}
diff --git a/api/models/index.js b/api/models/index.js
new file mode 100644
index 0000000..2a1cb22
--- /dev/null
+++ b/api/models/index.js
@@ -0,0 +1,22 @@
+const mongoose = require('mongoose');
+const { createMethods } = require('@librechat/data-schemas');
+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,
+};
diff --git a/api/package.json b/api/package.json
new file mode 100644
index 0000000..cc0e316
--- /dev/null
+++ b/api/package.json
@@ -0,0 +1,143 @@
+{
+ "name": "@librechat/backend",
+ "version": "v0.8.7",
+ "description": "",
+ "scripts": {
+ "start": "echo 'please run this from the root directory'",
+ "server-dev": "echo 'please run this from the root directory'",
+ "test": "cross-env NODE_ENV=test jest",
+ "b:test": "NODE_ENV=test bun jest",
+ "test:ci": "jest --ci --logHeapUsage",
+ "add-balance": "node ./add-balance.js",
+ "list-balances": "node ./list-balances.js",
+ "user-stats": "node ./user-stats.js",
+ "create-user": "node ./create-user.js",
+ "invite-user": "node ./invite-user.js",
+ "ban-user": "node ./ban-user.js",
+ "delete-user": "node ./delete-user.js"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/danny-avila/LibreChat.git"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "_moduleAliases": {
+ "~": "."
+ },
+ "imports": {
+ "~/*": "./*"
+ },
+ "bugs": {
+ "url": "https://github.com/danny-avila/LibreChat/issues"
+ },
+ "homepage": "https://librechat.ai",
+ "dependencies": {
+ "@anthropic-ai/vertex-sdk": "^0.16.0",
+ "@aws-sdk/client-bedrock-runtime": "^3.1013.0",
+ "@aws-sdk/client-cloudfront": "^3.1042.0",
+ "@aws-sdk/client-s3": "^3.980.0",
+ "@aws-sdk/cloudfront-signer": "^3.1036.0",
+ "@aws-sdk/credential-providers": "^3.1045.0",
+ "@aws-sdk/s3-request-presigner": "^3.758.0",
+ "@azure/identity": "^4.13.1",
+ "@azure/search-documents": "^12.0.0",
+ "@azure/storage-blob": "^12.30.0",
+ "@google/genai": "^2.8.0",
+ "@keyv/redis": "^4.3.3",
+ "@librechat/agents": "^3.2.61",
+ "@librechat/api": "*",
+ "@librechat/data-schemas": "*",
+ "@microsoft/microsoft-graph-client": "^3.0.7",
+ "@modelcontextprotocol/sdk": "^1.29.0",
+ "@node-saml/passport-saml": "^5.1.0",
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/instrumentation-express": "^0.56.0",
+ "@opentelemetry/instrumentation-http": "^0.207.0",
+ "@opentelemetry/instrumentation-ioredis": "^0.55.0",
+ "@opentelemetry/instrumentation-mongodb": "^0.60.0",
+ "@opentelemetry/instrumentation-mongoose": "^0.54.0",
+ "@opentelemetry/instrumentation-undici": "^0.18.0",
+ "@opentelemetry/resources": "^2.6.1",
+ "@opentelemetry/sdk-node": "^0.218.0",
+ "@opentelemetry/semantic-conventions": "^1.39.0",
+ "@smithy/node-http-handler": "^4.4.5",
+ "ai-tokenizer": "^1.0.6",
+ "axios": "^1.16.0",
+ "bcryptjs": "^2.4.3",
+ "compression": "^1.8.1",
+ "connect-redis": "^8.1.0",
+ "cookie": "^0.7.2",
+ "cookie-parser": "^1.4.7",
+ "cors": "^2.8.5",
+ "dedent": "^1.5.3",
+ "dotenv": "^16.0.3",
+ "eventsource": "^3.0.2",
+ "express": "^5.2.1",
+ "express-mongo-sanitize": "^2.2.0",
+ "express-rate-limit": "^8.5.1",
+ "express-session": "^1.18.2",
+ "express-static-gzip": "^2.2.0",
+ "file-type": "^21.3.2",
+ "firebase": "^11.0.2",
+ "form-data": "^4.0.4",
+ "get-stream": "^6.0.1",
+ "handlebars": "^4.7.9",
+ "https-proxy-agent": "^7.0.6",
+ "ioredis": "^5.3.2",
+ "js-yaml": "^4.2.0",
+ "jsonwebtoken": "^9.0.0",
+ "jszip": "^3.10.1",
+ "jwks-rsa": "^3.2.0",
+ "keyv": "^5.3.2",
+ "keyv-file": "^5.1.2",
+ "klona": "^2.0.6",
+ "librechat-data-provider": "*",
+ "lodash": "^4.17.23",
+ "mammoth": "^1.11.0",
+ "mathjs": "^15.2.0",
+ "meilisearch": "^0.38.0",
+ "memorystore": "^1.6.7",
+ "mime": "^3.0.0",
+ "module-alias": "^2.2.3",
+ "mongodb": "^6.14.2",
+ "mongoose": "^8.23.1",
+ "multer": "^2.2.0",
+ "nanoid": "^3.3.7",
+ "node-fetch": "^2.7.0",
+ "nodemailer": "^9.0.1",
+ "ollama": "^0.5.0",
+ "openai": "5.8.2",
+ "openid-client": "^6.5.0",
+ "passport": "^0.6.0",
+ "passport-apple": "^2.0.2",
+ "passport-discord": "^0.1.4",
+ "passport-facebook": "^3.0.0",
+ "passport-github2": "^0.1.12",
+ "passport-google-oauth20": "^2.0.0",
+ "passport-jwt": "^4.0.1",
+ "passport-ldapauth": "^3.0.1",
+ "passport-local": "^1.0.0",
+ "pdfjs-dist": "^5.4.624",
+ "prom-client": "^15.1.3",
+ "rate-limit-redis": "^4.2.0",
+ "sanitize-html": "^2.13.0",
+ "sharp": "^0.33.5",
+ "ua-parser-js": "^1.0.36",
+ "undici": "^7.24.1",
+ "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": {
+ "@babel/preset-env": "^7.29.5",
+ "@types/sanitize-html": "^2.13.0",
+ "jest": "^30.2.0",
+ "mongodb-memory-server": "^11.0.1",
+ "nodemon": "^3.0.3",
+ "supertest": "^7.1.0"
+ }
+}
diff --git a/api/server/cleanup.js b/api/server/cleanup.js
new file mode 100644
index 0000000..c278142
--- /dev/null
+++ b/api/server/cleanup.js
@@ -0,0 +1,431 @@
+const { logger } = require('@librechat/data-schemas');
+
+/** WeakMap to hold temporary data associated with requests */
+const requestDataMap = new WeakMap();
+
+const FinalizationRegistry = global.FinalizationRegistry || null;
+
+/**
+ * FinalizationRegistry to clean up client objects when they are garbage collected.
+ * This is used to prevent memory leaks and ensure that client objects are
+ * properly disposed of when they are no longer needed.
+ * The registry holds a weak reference to the client object and a cleanup
+ * callback that is called when the client object is garbage collected.
+ * The callback can be used to perform any necessary cleanup operations,
+ * such as removing event listeners or freeing up resources.
+ */
+const clientRegistry = FinalizationRegistry
+ ? new FinalizationRegistry((heldValue) => {
+ try {
+ // This will run when the client is garbage collected
+ if (heldValue && heldValue.userId) {
+ logger.debug(`[FinalizationRegistry] Cleaning up client for user ${heldValue.userId}`);
+ } else {
+ logger.debug('[FinalizationRegistry] Cleaning up client');
+ }
+ } catch {
+ // Ignore errors
+ }
+ })
+ : null;
+
+const graphPropsToClean = [
+ 'handlerRegistry',
+ 'runId',
+ 'tools',
+ 'signal',
+ 'config',
+ 'messages',
+ 'contentData',
+ 'stepKeyIds',
+ 'contentIndexMap',
+ 'toolCallStepIds',
+ 'messageIdsByStepKey',
+ 'messageStepHasToolCalls',
+ 'prelimMessageIdsByStepKey',
+ 'startIndex',
+ 'defaultAgentId',
+ 'dispatchReasoningDelta',
+ 'compileOptions',
+ 'invokedToolIds',
+ 'overrideModel',
+];
+
+const graphRunnablePropsToClean = [
+ 'lc_serializable',
+ 'lc_kwargs',
+ 'lc_runnable',
+ 'name',
+ 'lc_namespace',
+ 'lg_is_pregel',
+ 'nodes',
+ 'channels',
+ 'inputChannels',
+ 'outputChannels',
+ 'autoValidate',
+ 'streamMode',
+ 'streamChannels',
+ 'interruptAfter',
+ 'interruptBefore',
+ 'stepTimeout',
+ 'debug',
+ 'checkpointer',
+ 'retryPolicy',
+ 'config',
+ 'store',
+ 'triggerToNodes',
+ 'cache',
+ 'description',
+ 'metaRegistry',
+];
+
+/**
+ * Cleans up the client object by removing potential circular references to its properties.
+ * This is useful for preventing memory leaks and ensuring that the client
+ * and its properties can be garbage collected when it is no longer needed.
+ */
+function disposeClient(client) {
+ if (!client) {
+ return;
+ }
+
+ try {
+ if (client.user) {
+ client.user = null;
+ }
+ if (client.apiKey) {
+ client.apiKey = null;
+ }
+ if (client.azure) {
+ client.azure = null;
+ }
+ if (client.conversationId) {
+ client.conversationId = null;
+ }
+ if (client.responseMessageId) {
+ client.responseMessageId = null;
+ }
+ if (client.parentMessageId) {
+ client.parentMessageId = null;
+ }
+ if (client.message_file_map) {
+ client.message_file_map = null;
+ }
+ if (client.clientName) {
+ client.clientName = null;
+ }
+ if (client.sender) {
+ client.sender = null;
+ }
+ if (client.model) {
+ client.model = null;
+ }
+ if (client.maxContextTokens) {
+ client.maxContextTokens = null;
+ }
+ if (client.currentDateString) {
+ client.currentDateString = null;
+ }
+ if (client.inputTokensKey) {
+ client.inputTokensKey = null;
+ }
+ if (client.outputTokensKey) {
+ client.outputTokensKey = null;
+ }
+ if (client.skipSaveUserMessage !== undefined) {
+ client.skipSaveUserMessage = null;
+ }
+ if (client.visionMode) {
+ client.visionMode = null;
+ }
+ if (client.continued !== undefined) {
+ client.continued = null;
+ }
+ if (client.fetchedConvo !== undefined) {
+ client.fetchedConvo = null;
+ }
+ if (client.previous_summary) {
+ client.previous_summary = null;
+ }
+ if (client.metadata) {
+ client.metadata = null;
+ }
+ if (client.isVisionModel) {
+ client.isVisionModel = null;
+ }
+ if (client.isChatCompletion !== undefined) {
+ client.isChatCompletion = null;
+ }
+ if (client.contextHandlers) {
+ client.contextHandlers = null;
+ }
+ if (client.augmentedPrompt) {
+ client.augmentedPrompt = null;
+ }
+ if (client.systemMessage) {
+ client.systemMessage = null;
+ }
+ if (client.azureEndpoint) {
+ client.azureEndpoint = null;
+ }
+ if (client.langchainProxy) {
+ client.langchainProxy = null;
+ }
+ if (client.isOmni !== undefined) {
+ client.isOmni = null;
+ }
+ if (client.runManager) {
+ client.runManager = null;
+ }
+ // Properties specific to AnthropicClient
+ if (client.message_start) {
+ client.message_start = null;
+ }
+ if (client.message_delta) {
+ client.message_delta = null;
+ }
+ if (client.isClaudeLatest !== undefined) {
+ client.isClaudeLatest = null;
+ }
+ if (client.useMessages !== undefined) {
+ client.useMessages = null;
+ }
+ if (client.supportsCacheControl !== undefined) {
+ client.supportsCacheControl = null;
+ }
+ // Properties specific to GoogleClient
+ if (client.serviceKey) {
+ client.serviceKey = null;
+ }
+ if (client.project_id) {
+ client.project_id = null;
+ }
+ if (client.client_email) {
+ client.client_email = null;
+ }
+ if (client.private_key) {
+ client.private_key = null;
+ }
+ if (client.access_token) {
+ client.access_token = null;
+ }
+ if (client.reverseProxyUrl) {
+ client.reverseProxyUrl = null;
+ }
+ if (client.authHeader) {
+ client.authHeader = null;
+ }
+ if (client.isGenerativeModel !== undefined) {
+ client.isGenerativeModel = null;
+ }
+ // Properties specific to OpenAIClient
+ if (client.completionsUrl) {
+ client.completionsUrl = null;
+ }
+ if (client.shouldSummarize !== undefined) {
+ client.shouldSummarize = null;
+ }
+ if (client.isOllama !== undefined) {
+ client.isOllama = null;
+ }
+ if (client.FORCE_PROMPT !== undefined) {
+ client.FORCE_PROMPT = null;
+ }
+ if (client.isChatGptModel !== undefined) {
+ client.isChatGptModel = null;
+ }
+ if (client.isUnofficialChatGptModel !== undefined) {
+ client.isUnofficialChatGptModel = null;
+ }
+ if (client.useOpenRouter !== undefined) {
+ client.useOpenRouter = null;
+ }
+ if (client.startToken) {
+ client.startToken = null;
+ }
+ if (client.endToken) {
+ client.endToken = null;
+ }
+ if (client.userLabel) {
+ client.userLabel = null;
+ }
+ if (client.chatGptLabel) {
+ client.chatGptLabel = null;
+ }
+ if (client.modelLabel) {
+ client.modelLabel = null;
+ }
+ if (client.modelOptions) {
+ client.modelOptions = null;
+ }
+ if (client.defaultVisionModel) {
+ client.defaultVisionModel = null;
+ }
+ if (client.maxPromptTokens) {
+ client.maxPromptTokens = null;
+ }
+ if (client.maxResponseTokens) {
+ client.maxResponseTokens = null;
+ }
+ if (client.processMemory) {
+ client.processMemory = null;
+ }
+
+ if (client.run) {
+ if (client.run.Graph) {
+ if (typeof client.run.Graph.clearHeavyState === 'function') {
+ client.run.Graph.clearHeavyState();
+ } else {
+ client.run.Graph.resetValues();
+ }
+
+ if (client.run.Graph.agentContexts) {
+ client.run.Graph.agentContexts.clear();
+ client.run.Graph.agentContexts = null;
+ }
+
+ graphPropsToClean.forEach((prop) => {
+ if (client.run.Graph[prop] !== undefined) {
+ client.run.Graph[prop] = null;
+ }
+ });
+
+ client.run.Graph = null;
+ }
+
+ if (client.run.graphRunnable) {
+ graphRunnablePropsToClean.forEach((prop) => {
+ if (client.run.graphRunnable[prop] !== undefined) {
+ client.run.graphRunnable[prop] = null;
+ }
+ });
+
+ if (client.run.graphRunnable.builder) {
+ if (client.run.graphRunnable.builder.nodes !== undefined) {
+ client.run.graphRunnable.builder.nodes = null;
+ }
+ client.run.graphRunnable.builder = null;
+ }
+
+ client.run.graphRunnable = null;
+ }
+
+ const runPropsToClean = [
+ 'handlerRegistry',
+ 'id',
+ 'indexTokenCountMap',
+ 'returnContent',
+ 'tokenCounter',
+ ];
+
+ runPropsToClean.forEach((prop) => {
+ if (client.run[prop] !== undefined) {
+ client.run[prop] = null;
+ }
+ });
+
+ client.run = null;
+ }
+
+ if (client.sendMessage) {
+ client.sendMessage = null;
+ }
+ if (client.savedMessageIds) {
+ client.savedMessageIds.clear();
+ client.savedMessageIds = null;
+ }
+ if (client.currentMessages) {
+ client.currentMessages = null;
+ }
+ if (client.streamHandler) {
+ client.streamHandler = null;
+ }
+ if (client.contentParts) {
+ client.contentParts = null;
+ }
+ if (client.abortController) {
+ client.abortController = null;
+ }
+ if (client.collectedUsage) {
+ client.collectedUsage = null;
+ }
+ if (client.indexTokenCountMap) {
+ client.indexTokenCountMap = null;
+ }
+ if (client.agentConfigs) {
+ client.agentConfigs = null;
+ }
+ if (client.artifactPromises) {
+ client.artifactPromises = null;
+ }
+ if (client.usage) {
+ client.usage = null;
+ }
+ if (typeof client.dispose === 'function') {
+ client.dispose();
+ }
+ if (client.options) {
+ if (client.options.req) {
+ client.options.req = null;
+ }
+ if (client.options.res) {
+ client.options.res = null;
+ }
+ if (client.options.attachments) {
+ client.options.attachments = null;
+ }
+ if (client.options.agent) {
+ client.options.agent = null;
+ }
+ }
+ client.options = null;
+ } catch {
+ // Ignore errors during disposal
+ } finally {
+ logger.debug('[disposeClient] Client disposed');
+ }
+}
+
+function processReqData(data = {}, context) {
+ let {
+ abortKey,
+ userMessage,
+ userMessagePromise,
+ responseMessageId,
+ promptTokens,
+ conversationId,
+ userMessageId,
+ } = context;
+ for (const key in data) {
+ if (key === 'userMessage') {
+ userMessage = data[key];
+ userMessageId = data[key].messageId;
+ } else if (key === 'userMessagePromise') {
+ userMessagePromise = data[key];
+ } else if (key === 'responseMessageId') {
+ responseMessageId = data[key];
+ } else if (key === 'promptTokens') {
+ promptTokens = data[key];
+ } else if (key === 'abortKey') {
+ abortKey = data[key];
+ } else if (!conversationId && key === 'conversationId') {
+ conversationId = data[key];
+ }
+ }
+ return {
+ abortKey,
+ userMessage,
+ userMessagePromise,
+ responseMessageId,
+ promptTokens,
+ conversationId,
+ userMessageId,
+ };
+}
+
+module.exports = {
+ disposeClient,
+ requestDataMap,
+ clientRegistry,
+ processReqData,
+};
diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js
new file mode 100644
index 0000000..b3743df
--- /dev/null
+++ b/api/server/controllers/AuthController.js
@@ -0,0 +1,352 @@
+const cookies = require('cookie');
+const jwt = require('jsonwebtoken');
+const openIdClient = require('openid-client');
+const { logger } = require('@librechat/data-schemas');
+const {
+ math,
+ isEnabled,
+ findOpenIDUser,
+ getOpenIdIssuer,
+ buildOpenIDRefreshParams,
+} = require('@librechat/api');
+const {
+ requestPasswordReset,
+ setOpenIDAuthTokens,
+ setCloudFrontAuthCookies,
+ resetPassword,
+ setAuthTokens,
+ registerUser,
+} = require('~/server/services/AuthService');
+const {
+ deleteAllUserSessions,
+ getUserById,
+ findSession,
+ updateUser,
+ findUser,
+} = require('~/models');
+const { getGraphApiToken } = require('~/server/services/GraphTokenService');
+const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
+
+const AUTH_REFRESH_USER_PROJECTION = '-password -__v -totpSecret -backupCodes -federatedTokens';
+const OPENID_REUSE_EXPIRY_BUFFER_SECONDS = 30;
+/**
+ * Max age (ms) LibreChat reuses a cached OpenID session token before forcing an IdP refresh.
+ * Env-overridable (accepts an arithmetic expression, e.g. `60 * 60 * 24 * 1000`, like
+ * `SESSION_EXPIRY`): deployments whose IdP revokes the previous access token on refresh can
+ * widen this to the access-token lifetime so a still-valid token is not rotated/revoked out
+ * from under downstream consumers (e.g. MCP servers that introspect the bearer). Defaults to
+ * 15 minutes.
+ */
+const OPENID_REUSE_MAX_SESSION_AGE_MS = math(
+ process.env.OPENID_REUSE_MAX_SESSION_AGE_MS,
+ 15 * 60 * 1000,
+);
+
+const registrationController = async (req, res) => {
+ try {
+ const response = await registerUser(req.body);
+ const { status, message } = response;
+ res.status(status).send({ message });
+ } catch (err) {
+ logger.error('[registrationController]', err);
+ return res.status(500).json({ message: err.message });
+ }
+};
+
+const sanitizeUserForAuthResponse = (user) => {
+ const source = (typeof user?.toObject === 'function' ? user.toObject() : user) || {};
+ const {
+ password: _pw,
+ __v: _v,
+ totpSecret: _ts,
+ backupCodes: _bc,
+ federatedTokens: _ft,
+ ...safeUser
+ } = source;
+ return safeUser;
+};
+
+const getValidOpenIDReuseUserId = (parsedCookies) => {
+ const openidUserId = parsedCookies.openid_user_id;
+ if (!openidUserId || !process.env.JWT_REFRESH_SECRET) {
+ return null;
+ }
+
+ try {
+ const payload = jwt.verify(openidUserId, process.env.JWT_REFRESH_SECRET);
+ return typeof payload === 'object' && payload != null && typeof payload.id === 'string'
+ ? payload.id
+ : null;
+ } catch {
+ return null;
+ }
+};
+
+const isRecentOpenIDSessionRefresh = (openidTokens) => {
+ const lastRefreshedAt = Number(openidTokens?.lastRefreshedAt);
+ const elapsed = Date.now() - lastRefreshedAt;
+ return (
+ Number.isFinite(lastRefreshedAt) && elapsed >= 0 && elapsed <= OPENID_REUSE_MAX_SESSION_AGE_MS
+ );
+};
+
+const getReusableOpenIDSessionToken = (openidTokens) => {
+ if (!isRecentOpenIDSessionRefresh(openidTokens)) {
+ return null;
+ }
+
+ const candidates = [
+ { token: openidTokens?.idToken, type: 'id_token' },
+ { token: openidTokens?.accessToken, type: 'access_token' },
+ ];
+ const now = Math.floor(Date.now() / 1000);
+
+ for (const candidate of candidates) {
+ if (!candidate.token) {
+ continue;
+ }
+ /** Decode only: tokens are from the trusted server-side session; expiry gates reuse. */
+ const decoded = jwt.decode(candidate.token);
+ if (
+ decoded &&
+ typeof decoded === 'object' &&
+ decoded.exp > now + OPENID_REUSE_EXPIRY_BUFFER_SECONDS
+ ) {
+ return candidate;
+ }
+ }
+
+ return null;
+};
+
+const resetPasswordRequestController = async (req, res) => {
+ try {
+ const resetService = await requestPasswordReset(req);
+ if (resetService instanceof Error) {
+ return res.status(400).json(resetService);
+ } else {
+ return res.status(200).json(resetService);
+ }
+ } catch (e) {
+ logger.error('[resetPasswordRequestController]', e);
+ return res.status(400).json({ message: e.message });
+ }
+};
+
+const resetPasswordController = async (req, res) => {
+ try {
+ const resetPasswordService = await resetPassword(
+ req.body.userId,
+ req.body.token,
+ req.body.password,
+ );
+ if (resetPasswordService instanceof Error) {
+ return res.status(400).json(resetPasswordService);
+ } else {
+ await deleteAllUserSessions({ userId: req.body.userId });
+ return res.status(200).json(resetPasswordService);
+ }
+ } catch (e) {
+ logger.error('[resetPasswordController]', e);
+ return res.status(400).json({ message: e.message });
+ }
+};
+
+const refreshController = async (req, res) => {
+ const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {};
+ const token_provider = parsedCookies.token_provider;
+
+ if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
+ /** For OpenID users, read refresh token from session to avoid large cookie issues */
+ const refreshToken = req.session?.openidTokens?.refreshToken || parsedCookies.refreshToken;
+
+ if (!refreshToken) {
+ return res.status(200).send('Refresh token not provided');
+ }
+
+ try {
+ /**
+ * Reuse skips an IdP refresh only for recently-refreshed server-side tokens.
+ * Stale, missing, or near-expiry tokens fall through to refreshTokenGrant so
+ * upstream revocations and cookie/session extension are checked regularly.
+ */
+ const reusableSessionToken = getReusableOpenIDSessionToken(req.session?.openidTokens);
+ const reuseUserId = reusableSessionToken ? getValidOpenIDReuseUserId(parsedCookies) : null;
+ if (reuseUserId) {
+ const user = await getUserById(reuseUserId, AUTH_REFRESH_USER_PROJECTION);
+ if (user) {
+ const cloudFrontCookiesSet = setCloudFrontAuthCookies(req, res, user);
+ logger.debug('[refreshController] OpenID session token reused', {
+ token_type: reusableSessionToken.type,
+ has_id_token: Boolean(req.session?.openidTokens?.idToken),
+ has_access_token: Boolean(req.session?.openidTokens?.accessToken),
+ cloudfront_cookies_set: cloudFrontCookiesSet,
+ });
+ return res.status(200).send({
+ token: reusableSessionToken.token,
+ user: sanitizeUserForAuthResponse(user),
+ });
+ }
+ }
+
+ const openIdConfig = getOpenIdConfig();
+ const refreshParams = buildOpenIDRefreshParams();
+ logger.debug('[refreshController] OpenID refresh params', {
+ has_scope: Boolean(process.env.OPENID_SCOPE),
+ has_refresh_audience: Boolean(process.env.OPENID_REFRESH_AUDIENCE),
+ });
+ const tokenset = await openIdClient.refreshTokenGrant(
+ openIdConfig,
+ refreshToken,
+ refreshParams,
+ );
+ logger.debug('[refreshController] OpenID refresh succeeded', {
+ has_access_token: Boolean(tokenset.access_token),
+ has_id_token: Boolean(tokenset.id_token),
+ has_refresh_token: Boolean(tokenset.refresh_token),
+ expires_in: tokenset.expires_in,
+ });
+ const claims = tokenset.claims();
+ const openidIssuer = getOpenIdIssuer(claims, openIdConfig);
+ const { user, error, migration } = await findOpenIDUser({
+ findUser,
+ email: getOpenIdEmail(claims),
+ openidId: claims.sub,
+ openidIssuer,
+ idOnTheSource: claims.oid,
+ strategyName: 'refreshController',
+ });
+
+ logger.debug(
+ `[refreshController] findOpenIDUser result: user=${user?.email ?? 'null'}, error=${error ?? 'null'}, migration=${migration}, userOpenidId=${user?.openidId ?? 'null'}, claimsSub=${claims.sub}`,
+ );
+
+ if (error || !user) {
+ logger.warn(
+ `[refreshController] Redirecting to /login: error=${error ?? 'null'}, user=${user ? 'exists' : 'null'}`,
+ );
+ return res.status(401).redirect('/login');
+ }
+
+ // Handle migration: update user with openidId if found by email without openidId
+ // Also handle case where user has mismatched openidId (e.g., after database switch)
+ if (migration || user.openidId !== claims.sub) {
+ const reason = migration ? 'migration' : 'openidId mismatch';
+ await updateUser(user._id.toString(), {
+ provider: 'openid',
+ openidId: claims.sub,
+ ...(openidIssuer ? { openidIssuer } : {}),
+ });
+ logger.info(
+ `[refreshController] Updated user ${user.email} openidId (${reason}): ${user.openidId ?? 'null'} -> ${claims.sub}`,
+ );
+ }
+
+ const token = setOpenIDAuthTokens(tokenset, req, res, {
+ userId: user._id.toString(),
+ existingRefreshToken: refreshToken,
+ tenantId: user.tenantId,
+ });
+
+ return res.status(200).send({ token, user: sanitizeUserForAuthResponse(user) });
+ } catch (error) {
+ logger.error('[refreshController] OpenID token refresh error', error);
+ return res.status(403).send('Invalid OpenID refresh token');
+ }
+ }
+
+ /** For non-OpenID users, read refresh token from cookies */
+ const refreshToken = parsedCookies.refreshToken;
+ if (!refreshToken) {
+ return res.status(200).send('Refresh token not provided');
+ }
+
+ try {
+ const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
+ const user = await getUserById(payload.id, AUTH_REFRESH_USER_PROJECTION);
+ if (!user) {
+ return res.status(401).redirect('/login');
+ }
+
+ const userId = payload.id;
+
+ if (process.env.NODE_ENV === 'CI') {
+ const token = await setAuthTokens(userId, res, null, req);
+ return res.status(200).send({ token, user: sanitizeUserForAuthResponse(user) });
+ }
+
+ /** Session with the hashed refresh token */
+ const session = await findSession(
+ {
+ userId: userId,
+ refreshToken: refreshToken,
+ },
+ { lean: false },
+ );
+
+ if (session && session.expiration > new Date()) {
+ const token = await setAuthTokens(userId, res, session, req);
+
+ res.status(200).send({ token, user: sanitizeUserForAuthResponse(user) });
+ } else if (req?.query?.retry) {
+ // Retrying from a refresh token request that failed (401)
+ res.status(403).send('No session found');
+ } else if (payload.exp < Date.now() / 1000) {
+ res.status(403).redirect('/login');
+ } else {
+ res.status(401).send('Refresh token expired or not found for this user');
+ }
+ } catch (err) {
+ logger.error(`[refreshController] Invalid refresh token:`, err);
+ res.status(403).send('Invalid refresh token');
+ }
+};
+
+const graphTokenController = async (req, res) => {
+ try {
+ // Validate user is authenticated via Entra ID
+ if (!req.user.openidId || req.user.provider !== 'openid') {
+ return res.status(403).json({
+ message: 'Microsoft Graph access requires Entra ID authentication',
+ });
+ }
+
+ // Check if OpenID token reuse is active (required for on-behalf-of flow)
+ if (!isEnabled(process.env.OPENID_REUSE_TOKENS)) {
+ return res.status(403).json({
+ message: 'SharePoint integration requires OpenID token reuse to be enabled',
+ });
+ }
+
+ const scopes = req.query.scopes;
+ if (!scopes) {
+ return res.status(400).json({
+ message: 'Graph API scopes are required as query parameter',
+ });
+ }
+
+ const accessToken = req.user.federatedTokens?.access_token;
+ if (!accessToken) {
+ return res.status(401).json({
+ message: 'No federated access token available for token exchange',
+ });
+ }
+
+ const tokenResponse = await getGraphApiToken(req.user, accessToken, scopes);
+
+ res.json(tokenResponse);
+ } catch (error) {
+ logger.error('[graphTokenController] Failed to obtain Graph API token:', error);
+ res.status(500).json({
+ message: 'Failed to obtain Microsoft Graph token',
+ });
+ }
+};
+
+module.exports = {
+ refreshController,
+ registrationController,
+ resetPasswordController,
+ resetPasswordRequestController,
+ graphTokenController,
+};
diff --git a/api/server/controllers/AuthController.spec.js b/api/server/controllers/AuthController.spec.js
new file mode 100644
index 0000000..40c20bb
--- /dev/null
+++ b/api/server/controllers/AuthController.spec.js
@@ -0,0 +1,842 @@
+jest.mock('@librechat/data-schemas', () => ({
+ logger: { error: jest.fn(), debug: jest.fn(), warn: jest.fn(), info: jest.fn() },
+}));
+jest.mock('~/server/services/GraphTokenService', () => ({
+ getGraphApiToken: jest.fn(),
+}));
+jest.mock('~/server/services/AuthService', () => ({
+ requestPasswordReset: jest.fn(),
+ setOpenIDAuthTokens: jest.fn(),
+ setCloudFrontAuthCookies: jest.fn(),
+ resetPassword: jest.fn(),
+ setAuthTokens: jest.fn(),
+ registerUser: jest.fn(),
+}));
+jest.mock('~/strategies', () => ({ getOpenIdConfig: jest.fn(), getOpenIdEmail: jest.fn() }));
+jest.mock('openid-client', () => ({ refreshTokenGrant: jest.fn() }));
+jest.mock('~/models', () => ({
+ deleteAllUserSessions: jest.fn(),
+ getUserById: jest.fn(),
+ findSession: jest.fn(),
+ updateUser: jest.fn(),
+ findUser: jest.fn(),
+}));
+jest.mock('@librechat/api', () => ({
+ math: jest.fn((value, fallback) => fallback),
+ isEnabled: jest.fn(),
+ findOpenIDUser: jest.fn(),
+ getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'),
+ buildOpenIDRefreshParams: jest.fn(() => {
+ const params = {};
+ if (process.env.OPENID_SCOPE) {
+ params.scope = process.env.OPENID_SCOPE;
+ }
+ if (process.env.OPENID_REFRESH_AUDIENCE) {
+ params.audience = process.env.OPENID_REFRESH_AUDIENCE;
+ }
+ return params;
+ }),
+}));
+
+const openIdClient = require('openid-client');
+const jwt = require('jsonwebtoken');
+const { logger } = require('@librechat/data-schemas');
+const { isEnabled, findOpenIDUser, buildOpenIDRefreshParams } = require('@librechat/api');
+const { graphTokenController, refreshController } = require('./AuthController');
+const { getGraphApiToken } = require('~/server/services/GraphTokenService');
+const {
+ setOpenIDAuthTokens,
+ setCloudFrontAuthCookies,
+ setAuthTokens,
+} = require('~/server/services/AuthService');
+const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
+const { getUserById, findSession, updateUser } = require('~/models');
+
+const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE;
+const ORIGINAL_OPENID_REFRESH_AUDIENCE = process.env.OPENID_REFRESH_AUDIENCE;
+const ORIGINAL_JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
+const ORIGINAL_NODE_ENV = process.env.NODE_ENV;
+
+describe('graphTokenController', () => {
+ let req, res;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ isEnabled.mockReturnValue(true);
+
+ req = {
+ user: {
+ openidId: 'oid-123',
+ provider: 'openid',
+ federatedTokens: {
+ access_token: 'federated-access-token',
+ id_token: 'federated-id-token',
+ },
+ },
+ headers: { authorization: 'Bearer app-jwt-which-is-id-token' },
+ query: { scopes: 'https://graph.microsoft.com/.default' },
+ };
+
+ res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+
+ getGraphApiToken.mockResolvedValue({
+ access_token: 'graph-access-token',
+ token_type: 'Bearer',
+ expires_in: 3600,
+ });
+ });
+
+ it('should pass federatedTokens.access_token as OBO assertion, not the auth header bearer token', async () => {
+ await graphTokenController(req, res);
+
+ expect(getGraphApiToken).toHaveBeenCalledWith(
+ req.user,
+ 'federated-access-token',
+ 'https://graph.microsoft.com/.default',
+ );
+ expect(getGraphApiToken).not.toHaveBeenCalledWith(
+ expect.anything(),
+ 'app-jwt-which-is-id-token',
+ expect.anything(),
+ );
+ });
+
+ it('should return the graph token response on success', async () => {
+ await graphTokenController(req, res);
+
+ expect(res.json).toHaveBeenCalledWith({
+ access_token: 'graph-access-token',
+ token_type: 'Bearer',
+ expires_in: 3600,
+ });
+ });
+
+ it('should return 403 when user is not authenticated via Entra ID', async () => {
+ req.user.provider = 'google';
+ req.user.openidId = undefined;
+
+ await graphTokenController(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(getGraphApiToken).not.toHaveBeenCalled();
+ });
+
+ it('should return 403 when OPENID_REUSE_TOKENS is not enabled', async () => {
+ isEnabled.mockReturnValue(false);
+
+ await graphTokenController(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(getGraphApiToken).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 when scopes query param is missing', async () => {
+ req.query.scopes = undefined;
+
+ await graphTokenController(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(getGraphApiToken).not.toHaveBeenCalled();
+ });
+
+ it('should return 401 when federatedTokens.access_token is missing', async () => {
+ req.user.federatedTokens = {};
+
+ await graphTokenController(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(getGraphApiToken).not.toHaveBeenCalled();
+ });
+
+ it('should return 401 when federatedTokens is absent entirely', async () => {
+ req.user.federatedTokens = undefined;
+
+ await graphTokenController(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(getGraphApiToken).not.toHaveBeenCalled();
+ });
+
+ it('should return 500 when getGraphApiToken throws', async () => {
+ getGraphApiToken.mockRejectedValue(new Error('OBO exchange failed'));
+
+ await graphTokenController(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(500);
+ expect(res.json).toHaveBeenCalledWith({
+ message: 'Failed to obtain Microsoft Graph token',
+ });
+ });
+});
+
+describe('refreshController – OpenID path', () => {
+ const mockTokenset = {
+ claims: jest.fn(),
+ access_token: 'new-access',
+ id_token: 'new-id',
+ refresh_token: 'new-refresh',
+ expires_in: 3600,
+ };
+
+ const baseClaims = {
+ iss: 'https://issuer.example.com',
+ sub: 'oidc-sub-123',
+ oid: 'oid-456',
+ email: 'user@example.com',
+ exp: 9999999999,
+ };
+
+ const defaultUser = {
+ _id: 'user-db-id',
+ email: baseClaims.email,
+ openidId: baseClaims.sub,
+ password: '$2b$10$hashedpassword',
+ __v: 0,
+ totpSecret: 'encrypted-totp-secret',
+ backupCodes: ['hashed-code-1', 'hashed-code-2'],
+ };
+
+ let req, res;
+ const idpSigningSecret = 'idp-signing-secret';
+
+ const makeSessionToken = (claims = {}) =>
+ jwt.sign(
+ {
+ sub: baseClaims.sub,
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ ...claims,
+ },
+ idpSigningSecret,
+ );
+
+ const makeSignedUserId = (id = 'user-db-id', options = { expiresIn: '1h' }) =>
+ jwt.sign({ id }, process.env.JWT_REFRESH_SECRET, options);
+
+ const setOpenIDReuseCookies = (signedUserId = makeSignedUserId()) => {
+ req.headers.cookie = [
+ 'token_provider=openid',
+ 'refreshToken=stored-refresh',
+ `openid_user_id=${signedUserId}`,
+ ].join('; ');
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ delete process.env.OPENID_SCOPE;
+ delete process.env.OPENID_REFRESH_AUDIENCE;
+ process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
+
+ isEnabled.mockReturnValue(true);
+ getOpenIdConfig.mockReturnValue({ some: 'config' });
+ openIdClient.refreshTokenGrant.mockResolvedValue(mockTokenset);
+ mockTokenset.claims.mockReturnValue(baseClaims);
+ getOpenIdEmail.mockReturnValue(baseClaims.email);
+ setOpenIDAuthTokens.mockReturnValue('new-app-token');
+ setCloudFrontAuthCookies.mockReturnValue(true);
+ findOpenIDUser.mockResolvedValue({ user: { ...defaultUser }, error: null, migration: false });
+ getUserById.mockResolvedValue({
+ _id: 'user-db-id',
+ email: baseClaims.email,
+ openidId: baseClaims.sub,
+ });
+ updateUser.mockResolvedValue({});
+
+ req = {
+ headers: { cookie: 'token_provider=openid; refreshToken=stored-refresh' },
+ session: {},
+ };
+
+ res = {
+ status: jest.fn().mockReturnThis(),
+ send: jest.fn().mockReturnThis(),
+ redirect: jest.fn(),
+ };
+ });
+
+ afterAll(() => {
+ if (ORIGINAL_OPENID_SCOPE === undefined) {
+ delete process.env.OPENID_SCOPE;
+ } else {
+ process.env.OPENID_SCOPE = ORIGINAL_OPENID_SCOPE;
+ }
+
+ if (ORIGINAL_OPENID_REFRESH_AUDIENCE === undefined) {
+ delete process.env.OPENID_REFRESH_AUDIENCE;
+ } else {
+ process.env.OPENID_REFRESH_AUDIENCE = ORIGINAL_OPENID_REFRESH_AUDIENCE;
+ }
+
+ if (ORIGINAL_JWT_REFRESH_SECRET === undefined) {
+ delete process.env.JWT_REFRESH_SECRET;
+ } else {
+ process.env.JWT_REFRESH_SECRET = ORIGINAL_JWT_REFRESH_SECRET;
+ }
+ });
+
+ /** Asserts the full OpenID refresh grant was triggered using default mock state. */
+ const expectOpenIDRefreshGrant = () => {
+ expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
+ { some: 'config' },
+ 'stored-refresh',
+ {},
+ );
+ expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, {
+ userId: 'user-db-id',
+ existingRefreshToken: 'stored-refresh',
+ tenantId: undefined,
+ });
+ };
+
+ it('should call getOpenIdEmail with token claims and use result for findOpenIDUser', async () => {
+ await refreshController(req, res);
+
+ expect(buildOpenIDRefreshParams).toHaveBeenCalledTimes(1);
+ expect(getOpenIdEmail).toHaveBeenCalledWith(baseClaims);
+ expect(findOpenIDUser).toHaveBeenCalledWith(
+ expect.objectContaining({
+ email: baseClaims.email,
+ openidIssuer: baseClaims.iss,
+ }),
+ );
+ expect(res.status).toHaveBeenCalledWith(200);
+ });
+
+ it('reuses valid OpenID session tokens and refreshes CloudFront cookies', async () => {
+ const reusableIdToken = makeSessionToken();
+ const signedUserId = makeSignedUserId();
+ setOpenIDReuseCookies(signedUserId);
+ req.session = {
+ openidTokens: {
+ accessToken: 'session-access-token',
+ idToken: reusableIdToken,
+ refreshToken: 'stored-refresh',
+ lastRefreshedAt: Date.now(),
+ },
+ };
+ const user = {
+ ...defaultUser,
+ federatedTokens: { access_token: 'do-not-return' },
+ };
+ getUserById.mockResolvedValue(user);
+
+ await refreshController(req, res);
+
+ expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
+ expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
+ expect(getUserById).toHaveBeenCalledWith(
+ 'user-db-id',
+ '-password -__v -totpSecret -backupCodes -federatedTokens',
+ );
+ expect(setCloudFrontAuthCookies).toHaveBeenCalledWith(req, res, user);
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.send).toHaveBeenCalledWith({
+ token: reusableIdToken,
+ user: expect.objectContaining({
+ _id: 'user-db-id',
+ email: baseClaims.email,
+ openidId: baseClaims.sub,
+ }),
+ });
+
+ const sentPayload = res.send.mock.calls[0][0];
+ expect(sentPayload.user).not.toHaveProperty('password');
+ expect(sentPayload.user).not.toHaveProperty('totpSecret');
+ expect(sentPayload.user).not.toHaveProperty('backupCodes');
+ expect(sentPayload.user).not.toHaveProperty('federatedTokens');
+ expect(logger.debug).toHaveBeenCalledWith(
+ '[refreshController] OpenID session token reused',
+ expect.objectContaining({
+ token_type: 'id_token',
+ cloudfront_cookies_set: true,
+ }),
+ );
+ const debugOutput = JSON.stringify(logger.debug.mock.calls);
+ expect(debugOutput).not.toContain(reusableIdToken);
+ expect(debugOutput).not.toContain(signedUserId);
+ expect(debugOutput).not.toContain('session-access-token');
+ });
+
+ it('falls through to full OpenID refresh when session tokens are expired', async () => {
+ const expiredToken = makeSessionToken({ exp: Math.floor(Date.now() / 1000) - 60 });
+ setOpenIDReuseCookies();
+ req.session = {
+ openidTokens: {
+ accessToken: expiredToken,
+ idToken: expiredToken,
+ refreshToken: 'stored-refresh',
+ lastRefreshedAt: Date.now(),
+ },
+ };
+
+ await refreshController(req, res);
+
+ expect(getUserById).not.toHaveBeenCalled();
+ expect(setCloudFrontAuthCookies).not.toHaveBeenCalled();
+ expectOpenIDRefreshGrant();
+ });
+
+ it('falls through to full OpenID refresh when session tokens are near expiry', async () => {
+ const nearExpiryToken = makeSessionToken({ exp: Math.floor(Date.now() / 1000) + 5 });
+ setOpenIDReuseCookies();
+ req.session = {
+ openidTokens: {
+ accessToken: nearExpiryToken,
+ idToken: nearExpiryToken,
+ refreshToken: 'stored-refresh',
+ lastRefreshedAt: Date.now(),
+ },
+ };
+
+ await refreshController(req, res);
+
+ expect(getUserById).not.toHaveBeenCalled();
+ expectOpenIDRefreshGrant();
+ });
+
+ it('falls through to full OpenID refresh when session tokens have no exp claim', async () => {
+ const tokenWithoutExp = jwt.sign({ sub: baseClaims.sub }, idpSigningSecret);
+ setOpenIDReuseCookies();
+ req.session = {
+ openidTokens: {
+ accessToken: tokenWithoutExp,
+ idToken: tokenWithoutExp,
+ refreshToken: 'stored-refresh',
+ lastRefreshedAt: Date.now(),
+ },
+ };
+
+ await refreshController(req, res);
+
+ expect(getUserById).not.toHaveBeenCalled();
+ expectOpenIDRefreshGrant();
+ });
+
+ it('falls through to full OpenID refresh when the signed reuse user cookie is invalid', async () => {
+ setOpenIDReuseCookies('tampered-cookie');
+ req.session = {
+ openidTokens: {
+ accessToken: 'session-access-token',
+ idToken: makeSessionToken(),
+ refreshToken: 'stored-refresh',
+ lastRefreshedAt: Date.now(),
+ },
+ };
+
+ await refreshController(req, res);
+
+ expect(getUserById).not.toHaveBeenCalled();
+ expectOpenIDRefreshGrant();
+ });
+
+ it('falls through to full OpenID refresh when the reuse user no longer exists', async () => {
+ setOpenIDReuseCookies();
+ req.session = {
+ openidTokens: {
+ accessToken: 'session-access-token',
+ idToken: makeSessionToken(),
+ refreshToken: 'stored-refresh',
+ lastRefreshedAt: Date.now(),
+ },
+ };
+ getUserById.mockResolvedValueOnce(null);
+
+ await refreshController(req, res);
+
+ expect(getUserById).toHaveBeenCalledWith(
+ 'user-db-id',
+ '-password -__v -totpSecret -backupCodes -federatedTokens',
+ );
+ expect(setCloudFrontAuthCookies).not.toHaveBeenCalled();
+ expectOpenIDRefreshGrant();
+ });
+
+ it('falls through to full OpenID refresh when session tokens are stale', async () => {
+ setOpenIDReuseCookies();
+ req.session = {
+ openidTokens: {
+ accessToken: 'session-access-token',
+ idToken: makeSessionToken(),
+ refreshToken: 'stored-refresh',
+ lastRefreshedAt: Date.now() - 16 * 60 * 1000,
+ },
+ };
+
+ await refreshController(req, res);
+
+ expect(getUserById).not.toHaveBeenCalled();
+ expectOpenIDRefreshGrant();
+ });
+
+ it('falls through to full OpenID refresh when session refresh timestamp is in the future', async () => {
+ setOpenIDReuseCookies();
+ req.session = {
+ openidTokens: {
+ accessToken: 'session-access-token',
+ idToken: makeSessionToken(),
+ refreshToken: 'stored-refresh',
+ lastRefreshedAt: Date.now() + 60 * 1000,
+ },
+ };
+
+ await refreshController(req, res);
+
+ expect(getUserById).not.toHaveBeenCalled();
+ expectOpenIDRefreshGrant();
+ });
+
+ it('falls through to full OpenID refresh for pre-upgrade sessions without lastRefreshedAt', async () => {
+ setOpenIDReuseCookies();
+ req.session = {
+ openidTokens: {
+ accessToken: 'session-access-token',
+ idToken: makeSessionToken(),
+ refreshToken: 'stored-refresh',
+ },
+ };
+
+ await refreshController(req, res);
+
+ expect(getUserById).not.toHaveBeenCalled();
+ expectOpenIDRefreshGrant();
+ });
+
+ it('sanitizes Mongoose-style user documents on the OpenID reuse path', async () => {
+ const reusableIdToken = makeSessionToken();
+ setOpenIDReuseCookies();
+ req.session = {
+ openidTokens: {
+ accessToken: 'session-access-token',
+ idToken: reusableIdToken,
+ refreshToken: 'stored-refresh',
+ lastRefreshedAt: Date.now(),
+ },
+ };
+ const userDocument = {
+ toObject: () => ({
+ ...defaultUser,
+ federatedTokens: { access_token: 'do-not-return' },
+ }),
+ };
+ getUserById.mockResolvedValue(userDocument);
+
+ await refreshController(req, res);
+
+ const sentPayload = res.send.mock.calls[0][0];
+ expect(setCloudFrontAuthCookies).toHaveBeenCalledWith(req, res, userDocument);
+ expect(sentPayload).toEqual({
+ token: reusableIdToken,
+ user: expect.objectContaining({
+ _id: 'user-db-id',
+ email: baseClaims.email,
+ }),
+ });
+ expect(sentPayload.user).not.toHaveProperty('password');
+ expect(sentPayload.user).not.toHaveProperty('federatedTokens');
+ });
+
+ it('should pass scope-only OpenID refresh params when OPENID_SCOPE is set', async () => {
+ process.env.OPENID_SCOPE = 'openid profile email';
+
+ await refreshController(req, res);
+
+ expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
+ { some: 'config' },
+ 'stored-refresh',
+ { scope: 'openid profile email' },
+ );
+ });
+
+ it('should pass scope and audience OpenID refresh params when both are set', async () => {
+ process.env.OPENID_SCOPE = 'openid profile email';
+ process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
+
+ await refreshController(req, res);
+
+ expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
+ { some: 'config' },
+ 'stored-refresh',
+ {
+ scope: 'openid profile email',
+ audience: 'https://api.example.com',
+ },
+ );
+ });
+
+ it('should pass audience-only OpenID refresh params when scope is unset', async () => {
+ process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
+
+ await refreshController(req, res);
+
+ expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
+ { some: 'config' },
+ 'stored-refresh',
+ { audience: 'https://api.example.com' },
+ );
+ });
+
+ it('should omit empty OpenID refresh audience', async () => {
+ process.env.OPENID_SCOPE = 'openid profile email';
+ process.env.OPENID_REFRESH_AUDIENCE = '';
+
+ await refreshController(req, res);
+
+ expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
+ { some: 'config' },
+ 'stored-refresh',
+ { scope: 'openid profile email' },
+ );
+ });
+
+ it('should keep OpenID refresh diagnostics free of token and audience values', async () => {
+ process.env.OPENID_SCOPE = 'openid profile email';
+ process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
+
+ await refreshController(req, res);
+
+ expect(logger.debug).toHaveBeenCalledWith('[refreshController] OpenID refresh params', {
+ has_scope: true,
+ has_refresh_audience: true,
+ });
+ expect(logger.debug).toHaveBeenCalledWith('[refreshController] OpenID refresh succeeded', {
+ has_access_token: true,
+ has_id_token: true,
+ has_refresh_token: true,
+ expires_in: 3600,
+ });
+ const debugOutput = JSON.stringify(logger.debug.mock.calls);
+ expect(debugOutput).not.toContain('stored-refresh');
+ expect(debugOutput).not.toContain('new-access');
+ expect(debugOutput).not.toContain('new-id');
+ expect(debugOutput).not.toContain('new-refresh');
+ expect(debugOutput).not.toContain('https://api.example.com');
+ });
+
+ it('should use OPENID_EMAIL_CLAIM-resolved value when claim is present in token', async () => {
+ const claimsWithUpn = { ...baseClaims, upn: 'user@corp.example.com' };
+ mockTokenset.claims.mockReturnValue(claimsWithUpn);
+ getOpenIdEmail.mockReturnValue('user@corp.example.com');
+
+ const user = {
+ _id: 'user-db-id',
+ email: 'user@corp.example.com',
+ openidId: baseClaims.sub,
+ };
+ findOpenIDUser.mockResolvedValue({ user, error: null, migration: false });
+
+ await refreshController(req, res);
+
+ expect(getOpenIdEmail).toHaveBeenCalledWith(claimsWithUpn);
+ expect(findOpenIDUser).toHaveBeenCalledWith(
+ expect.objectContaining({
+ email: 'user@corp.example.com',
+ openidIssuer: baseClaims.iss,
+ }),
+ );
+ expect(res.status).toHaveBeenCalledWith(200);
+ });
+
+ it('should fall back to claims.email when configured claim is absent from token claims', async () => {
+ getOpenIdEmail.mockReturnValue(baseClaims.email);
+
+ await refreshController(req, res);
+
+ expect(findOpenIDUser).toHaveBeenCalledWith(
+ expect.objectContaining({
+ email: baseClaims.email,
+ openidIssuer: baseClaims.iss,
+ }),
+ );
+ });
+
+ it('should not expose sensitive fields or federatedTokens in refresh response', async () => {
+ await refreshController(req, res);
+
+ const sentPayload = res.send.mock.calls[0][0];
+ expect(sentPayload).toEqual({
+ token: 'new-app-token',
+ user: expect.objectContaining({
+ _id: 'user-db-id',
+ email: baseClaims.email,
+ openidId: baseClaims.sub,
+ }),
+ });
+ expect(sentPayload.user).not.toHaveProperty('federatedTokens');
+ expect(sentPayload.user).not.toHaveProperty('password');
+ expect(sentPayload.user).not.toHaveProperty('totpSecret');
+ expect(sentPayload.user).not.toHaveProperty('backupCodes');
+ expect(sentPayload.user).not.toHaveProperty('__v');
+ });
+
+ it('should update openidId when migration is triggered on refresh', async () => {
+ const user = { _id: 'user-db-id', email: baseClaims.email, openidId: null };
+ findOpenIDUser.mockResolvedValue({ user, error: null, migration: true });
+
+ await refreshController(req, res);
+
+ expect(updateUser).toHaveBeenCalledWith(
+ 'user-db-id',
+ expect.objectContaining({
+ provider: 'openid',
+ openidId: baseClaims.sub,
+ openidIssuer: baseClaims.iss,
+ }),
+ );
+ expect(res.status).toHaveBeenCalledWith(200);
+ });
+
+ it('should return 401 and redirect to /login when findOpenIDUser returns no user', async () => {
+ findOpenIDUser.mockResolvedValue({ user: null, error: null, migration: false });
+
+ await refreshController(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.redirect).toHaveBeenCalledWith('/login');
+ });
+
+ it('should return 401 and redirect when findOpenIDUser returns an error', async () => {
+ findOpenIDUser.mockResolvedValue({ user: null, error: 'AUTH_FAILED', migration: false });
+
+ await refreshController(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.redirect).toHaveBeenCalledWith('/login');
+ });
+
+ it('should preserve invalid OpenID refresh token behavior', async () => {
+ openIdClient.refreshTokenGrant.mockRejectedValue(new Error('invalid_grant'));
+
+ await refreshController(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Invalid OpenID refresh token');
+ });
+
+ it('should skip OpenID path when token_provider is not openid', async () => {
+ req.headers.cookie = 'token_provider=local; refreshToken=some-token';
+
+ await refreshController(req, res);
+
+ expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
+ });
+
+ it('should skip OpenID path when OPENID_REUSE_TOKENS is disabled', async () => {
+ isEnabled.mockReturnValue(false);
+
+ await refreshController(req, res);
+
+ expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
+ });
+
+ it('should return 200 with token not provided when refresh token is absent', async () => {
+ req.headers.cookie = 'token_provider=openid';
+ req.session = {};
+
+ await refreshController(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.send).toHaveBeenCalledWith('Refresh token not provided');
+ });
+});
+
+describe('refreshController – LibreChat path', () => {
+ let req, res;
+ const refreshSecret = 'test-refresh-secret';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ process.env.JWT_REFRESH_SECRET = refreshSecret;
+ process.env.NODE_ENV = 'test';
+ setAuthTokens.mockResolvedValue('local-app-token');
+ findSession.mockResolvedValue({ expiration: new Date(Date.now() + 60_000) });
+
+ const refreshToken = jwt.sign({ id: 'local-user-id' }, refreshSecret, {
+ expiresIn: '1h',
+ });
+ req = {
+ headers: { cookie: `refreshToken=${refreshToken}` },
+ query: {},
+ session: {},
+ };
+ res = {
+ status: jest.fn().mockReturnThis(),
+ send: jest.fn().mockReturnThis(),
+ redirect: jest.fn(),
+ };
+ });
+
+ afterAll(() => {
+ if (ORIGINAL_JWT_REFRESH_SECRET === undefined) {
+ delete process.env.JWT_REFRESH_SECRET;
+ } else {
+ process.env.JWT_REFRESH_SECRET = ORIGINAL_JWT_REFRESH_SECRET;
+ }
+
+ if (ORIGINAL_NODE_ENV === undefined) {
+ delete process.env.NODE_ENV;
+ } else {
+ process.env.NODE_ENV = ORIGINAL_NODE_ENV;
+ }
+ });
+
+ it('sanitizes user documents before returning local refresh responses', async () => {
+ getUserById.mockResolvedValue({
+ toObject: () => ({
+ _id: 'local-user-id',
+ email: 'local@example.com',
+ password: 'hashed-password',
+ __v: 1,
+ totpSecret: 'totp-secret',
+ backupCodes: ['backup-code'],
+ federatedTokens: { access_token: 'do-not-return' },
+ }),
+ });
+
+ await refreshController(req, res);
+
+ const sentPayload = res.send.mock.calls[0][0];
+ expect(setAuthTokens).toHaveBeenCalledWith(
+ 'local-user-id',
+ res,
+ { expiration: expect.any(Date) },
+ req,
+ );
+ expect(sentPayload).toEqual({
+ token: 'local-app-token',
+ user: {
+ _id: 'local-user-id',
+ email: 'local@example.com',
+ },
+ });
+ });
+
+ it('sanitizes user documents before returning CI refresh responses', async () => {
+ process.env.NODE_ENV = 'CI';
+ getUserById.mockResolvedValue({
+ toObject: () => ({
+ _id: 'local-user-id',
+ email: 'local@example.com',
+ password: 'hashed-password',
+ __v: 1,
+ totpSecret: 'totp-secret',
+ backupCodes: ['backup-code'],
+ federatedTokens: { access_token: 'do-not-return' },
+ }),
+ });
+
+ await refreshController(req, res);
+
+ const sentPayload = res.send.mock.calls[0][0];
+ expect(findSession).not.toHaveBeenCalled();
+ expect(setAuthTokens).toHaveBeenCalledWith('local-user-id', res, null, req);
+ expect(sentPayload).toEqual({
+ token: 'local-app-token',
+ user: {
+ _id: 'local-user-id',
+ email: 'local@example.com',
+ },
+ });
+ });
+});
diff --git a/api/server/controllers/Balance.js b/api/server/controllers/Balance.js
new file mode 100644
index 0000000..8df579e
--- /dev/null
+++ b/api/server/controllers/Balance.js
@@ -0,0 +1,28 @@
+const { findBalanceByUser } = require('~/models');
+
+async function balanceController(req, res) {
+ const balanceLocals = res.locals || {};
+
+ if (balanceLocals.balanceConfigEnabled === false) {
+ return res.sendStatus(204);
+ }
+
+ const balanceData = balanceLocals.balanceData ?? (await findBalanceByUser(req.user.id));
+
+ if (!balanceData) {
+ return res.status(404).json({ error: 'Balance not found' });
+ }
+
+ const { _id: _, ...result } = balanceData;
+
+ if (!result.autoRefillEnabled) {
+ delete result.refillIntervalValue;
+ delete result.refillIntervalUnit;
+ delete result.lastRefill;
+ delete result.refillAmount;
+ }
+
+ res.status(200).json(result);
+}
+
+module.exports = balanceController;
diff --git a/api/server/controllers/Balance.spec.js b/api/server/controllers/Balance.spec.js
new file mode 100644
index 0000000..833f2d8
--- /dev/null
+++ b/api/server/controllers/Balance.spec.js
@@ -0,0 +1,72 @@
+jest.mock('~/models', () => ({
+ findBalanceByUser: jest.fn(),
+}));
+
+const { findBalanceByUser } = require('~/models');
+const balanceController = require('./Balance');
+
+describe('balanceController', () => {
+ const createResponse = () => ({
+ locals: {},
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ sendStatus: jest.fn().mockReturnThis(),
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns no content without reading balance when balance config is disabled', async () => {
+ const req = {
+ user: { id: 'user-1' },
+ };
+ const res = createResponse();
+ res.locals.balanceConfigEnabled = false;
+
+ await balanceController(req, res);
+
+ expect(findBalanceByUser).not.toHaveBeenCalled();
+ expect(res.sendStatus).toHaveBeenCalledWith(204);
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ it('uses balance data attached by middleware without a second read', async () => {
+ const req = {
+ user: { id: 'user-1' },
+ };
+ const res = createResponse();
+ res.locals.balanceConfigEnabled = true;
+ res.locals.balanceData = {
+ _id: 'balance-1',
+ user: 'user-1',
+ tokenCredits: 100,
+ autoRefillEnabled: false,
+ };
+
+ await balanceController(req, res);
+
+ expect(findBalanceByUser).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith({
+ user: 'user-1',
+ tokenCredits: 100,
+ autoRefillEnabled: false,
+ });
+ });
+
+ it('returns not found when balance is enabled and no record exists', async () => {
+ findBalanceByUser.mockResolvedValue(null);
+ const req = {
+ user: { id: 'user-1' },
+ };
+ const res = createResponse();
+ res.locals.balanceConfigEnabled = true;
+
+ await balanceController(req, res);
+
+ expect(findBalanceByUser).toHaveBeenCalledWith('user-1');
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({ error: 'Balance not found' });
+ });
+});
diff --git a/api/server/controllers/EndpointController.js b/api/server/controllers/EndpointController.js
new file mode 100644
index 0000000..322ff17
--- /dev/null
+++ b/api/server/controllers/EndpointController.js
@@ -0,0 +1,8 @@
+const { getEndpointsConfig } = require('~/server/services/Config');
+
+async function endpointController(req, res) {
+ const endpointsConfig = await getEndpointsConfig(req);
+ res.send(JSON.stringify(endpointsConfig));
+}
+
+module.exports = endpointController;
diff --git a/api/server/controllers/FavoritesController.js b/api/server/controllers/FavoritesController.js
new file mode 100644
index 0000000..1dfe8e5
--- /dev/null
+++ b/api/server/controllers/FavoritesController.js
@@ -0,0 +1,128 @@
+const { updateUser, getUserById } = require('~/models');
+
+const MAX_FAVORITES = 50;
+const MAX_STRING_LENGTH = 256;
+
+const updateFavoritesController = async (req, res) => {
+ try {
+ const { favorites } = req.body;
+ const userId = req.user.id;
+
+ if (!favorites) {
+ return res.status(400).json({ message: 'Favorites data is required' });
+ }
+
+ if (!Array.isArray(favorites)) {
+ return res.status(400).json({ message: 'Favorites must be an array' });
+ }
+
+ if (favorites.length > MAX_FAVORITES) {
+ return res.status(400).json({
+ code: 'MAX_FAVORITES_EXCEEDED',
+ message: `Maximum ${MAX_FAVORITES} favorites allowed`,
+ limit: MAX_FAVORITES,
+ });
+ }
+
+ for (const fav of favorites) {
+ const hasAgent = !!fav.agentId;
+ const hasModel = !!(fav.model && fav.endpoint);
+ const hasSpec = !!fav.spec;
+
+ if (fav.agentId && fav.agentId.length > MAX_STRING_LENGTH) {
+ return res
+ .status(400)
+ .json({ message: `agentId exceeds maximum length of ${MAX_STRING_LENGTH}` });
+ }
+ if (fav.model && fav.model.length > MAX_STRING_LENGTH) {
+ return res
+ .status(400)
+ .json({ message: `model exceeds maximum length of ${MAX_STRING_LENGTH}` });
+ }
+ if (fav.endpoint && fav.endpoint.length > MAX_STRING_LENGTH) {
+ return res
+ .status(400)
+ .json({ message: `endpoint exceeds maximum length of ${MAX_STRING_LENGTH}` });
+ }
+ if (fav.spec !== undefined && fav.spec !== null) {
+ if (typeof fav.spec !== 'string' || fav.spec.length === 0) {
+ return res.status(400).json({ message: 'spec must be a non-empty string' });
+ }
+ if (fav.spec.length > MAX_STRING_LENGTH) {
+ return res
+ .status(400)
+ .json({ message: `spec exceeds maximum length of ${MAX_STRING_LENGTH}` });
+ }
+ }
+
+ const hasPartialModel = !hasModel && !!(fav.model || fav.endpoint);
+
+ if (hasPartialModel && !hasAgent && !hasSpec) {
+ return res.status(400).json({ message: 'model and endpoint must be provided together' });
+ }
+
+ const typeCount = [hasAgent, hasModel, hasSpec].filter(Boolean).length;
+ if (typeCount === 0) {
+ return res.status(400).json({
+ message: 'Each favorite must have either agentId, model+endpoint, or spec',
+ });
+ }
+
+ if (typeCount > 1) {
+ return res.status(400).json({
+ message: 'Favorite cannot have multiple types (agentId, model/endpoint, or spec)',
+ });
+ }
+
+ if (hasSpec && (fav.agentId || fav.model || fav.endpoint)) {
+ return res
+ .status(400)
+ .json({ message: 'spec cannot be combined with agentId, model, or endpoint' });
+ }
+ if (hasAgent && (fav.model || fav.endpoint)) {
+ return res
+ .status(400)
+ .json({ message: 'agentId cannot be combined with model or endpoint' });
+ }
+ }
+
+ const user = await updateUser(userId, { favorites });
+
+ if (!user) {
+ return res.status(404).json({ message: 'User not found' });
+ }
+
+ return res.status(200).json(user.favorites);
+ } catch (error) {
+ console.error('Error updating favorites:', error);
+ return res.status(500).json({ message: 'Internal server error' });
+ }
+};
+
+const getFavoritesController = async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const user = await getUserById(userId, 'favorites');
+
+ if (!user) {
+ return res.status(404).json({ message: 'User not found' });
+ }
+
+ let favorites = user.favorites || [];
+
+ if (!Array.isArray(favorites)) {
+ favorites = [];
+ await updateUser(userId, { favorites: [] });
+ }
+
+ return res.status(200).json(favorites);
+ } catch (error) {
+ console.error('Error fetching favorites:', error);
+ return res.status(500).json({ message: 'Internal server error' });
+ }
+};
+
+module.exports = {
+ updateFavoritesController,
+ getFavoritesController,
+};
diff --git a/api/server/controllers/FavoritesController.spec.js b/api/server/controllers/FavoritesController.spec.js
new file mode 100644
index 0000000..c3aea30
--- /dev/null
+++ b/api/server/controllers/FavoritesController.spec.js
@@ -0,0 +1,308 @@
+jest.mock('~/models', () => ({
+ updateUser: jest.fn(),
+ getUserById: jest.fn(),
+}));
+
+const { updateUser, getUserById } = require('~/models');
+const { updateFavoritesController, getFavoritesController } = require('./FavoritesController');
+
+const makeRes = () => {
+ const res = {};
+ res.status = jest.fn().mockReturnValue(res);
+ res.json = jest.fn().mockReturnValue(res);
+ return res;
+};
+
+const makeReq = (body = {}) => ({
+ body,
+ user: { id: 'user-123' },
+});
+
+describe('FavoritesController', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe('updateFavoritesController - payload envelope', () => {
+ it('rejects missing favorites key with 400', async () => {
+ const req = makeReq({});
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({ message: 'Favorites data is required' });
+ expect(updateUser).not.toHaveBeenCalled();
+ });
+
+ it('rejects non-array favorites with 400', async () => {
+ const req = makeReq({ favorites: 'not-an-array' });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({ message: 'Favorites must be an array' });
+ });
+
+ it('rejects favorites over MAX_FAVORITES with 400 + code', async () => {
+ const favorites = Array.from({ length: 51 }, (_, i) => ({ agentId: `agent-${i}` }));
+ const req = makeReq({ favorites });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ code: 'MAX_FAVORITES_EXCEEDED',
+ message: 'Maximum 50 favorites allowed',
+ limit: 50,
+ });
+ });
+ });
+
+ describe('updateFavoritesController - agent/model length validation', () => {
+ it('rejects oversized agentId', async () => {
+ const req = makeReq({ favorites: [{ agentId: 'a'.repeat(257) }] });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({ message: 'agentId exceeds maximum length of 256' });
+ });
+
+ it('rejects oversized model', async () => {
+ const req = makeReq({ favorites: [{ model: 'm'.repeat(257), endpoint: 'openai' }] });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({ message: 'model exceeds maximum length of 256' });
+ });
+ });
+
+ describe('updateFavoritesController - spec validation', () => {
+ it('accepts a valid spec favorite', async () => {
+ updateUser.mockResolvedValue({ favorites: [{ spec: 'my-spec' }] });
+ const req = makeReq({ favorites: [{ spec: 'my-spec' }] });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith([{ spec: 'my-spec' }]);
+ expect(updateUser).toHaveBeenCalledWith('user-123', {
+ favorites: [{ spec: 'my-spec' }],
+ });
+ });
+
+ it('rejects non-string spec with 400', async () => {
+ const req = makeReq({ favorites: [{ spec: 42 }] });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({ message: 'spec must be a non-empty string' });
+ });
+
+ it('rejects empty string spec with 400', async () => {
+ const req = makeReq({ favorites: [{ spec: '' }] });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({ message: 'spec must be a non-empty string' });
+ });
+
+ it('rejects oversized spec with 400', async () => {
+ const req = makeReq({ favorites: [{ spec: 's'.repeat(257) }] });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({ message: 'spec exceeds maximum length of 256' });
+ });
+
+ it('allows undefined/null spec (treated as absent)', async () => {
+ updateUser.mockResolvedValue({ favorites: [{ agentId: 'a1' }] });
+ const req = makeReq({ favorites: [{ agentId: 'a1', spec: null }] });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(200);
+ });
+ });
+
+ describe('updateFavoritesController - exclusivity (typeCount)', () => {
+ it('rejects empty favorite entry with 400', async () => {
+ const req = makeReq({ favorites: [{}] });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ message: 'Each favorite must have either agentId, model+endpoint, or spec',
+ });
+ });
+
+ it('rejects agentId + model combination', async () => {
+ const req = makeReq({
+ favorites: [{ agentId: 'a1', model: 'gpt-5', endpoint: 'openai' }],
+ });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ message: 'Favorite cannot have multiple types (agentId, model/endpoint, or spec)',
+ });
+ });
+
+ it('rejects agentId + spec combination', async () => {
+ const req = makeReq({ favorites: [{ agentId: 'a1', spec: 's1' }] });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ message: 'Favorite cannot have multiple types (agentId, model/endpoint, or spec)',
+ });
+ });
+
+ it('rejects model + spec combination', async () => {
+ const req = makeReq({
+ favorites: [{ model: 'gpt-5', endpoint: 'openai', spec: 's1' }],
+ });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ });
+
+ it('rejects spec with stray endpoint field', async () => {
+ const req = makeReq({ favorites: [{ spec: 's1', endpoint: 'openai' }] });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ message: 'spec cannot be combined with agentId, model, or endpoint',
+ });
+ });
+
+ it('rejects spec with stray model field', async () => {
+ const req = makeReq({ favorites: [{ spec: 's1', model: 'gpt-5' }] });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ message: 'spec cannot be combined with agentId, model, or endpoint',
+ });
+ });
+
+ it('rejects agentId with stray model field (no endpoint)', async () => {
+ const req = makeReq({ favorites: [{ agentId: 'a1', model: 'gpt-5' }] });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ message: 'agentId cannot be combined with model or endpoint',
+ });
+ });
+
+ it('rejects agentId with stray endpoint field (no model)', async () => {
+ const req = makeReq({ favorites: [{ agentId: 'a1', endpoint: 'openai' }] });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ message: 'agentId cannot be combined with model or endpoint',
+ });
+ });
+
+ it('rejects model without endpoint (partial model pair)', async () => {
+ const req = makeReq({ favorites: [{ model: 'gpt-5' }] });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ message: 'model and endpoint must be provided together',
+ });
+ });
+
+ it('rejects endpoint without model (partial model pair)', async () => {
+ const req = makeReq({ favorites: [{ endpoint: 'openai' }] });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ message: 'model and endpoint must be provided together',
+ });
+ });
+
+ it('accepts a mixed array of valid single-type favorites', async () => {
+ const favorites = [{ agentId: 'a1' }, { model: 'gpt-5', endpoint: 'openai' }, { spec: 's1' }];
+ updateUser.mockResolvedValue({ favorites });
+ const req = makeReq({ favorites });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith(favorites);
+ });
+ });
+
+ describe('updateFavoritesController - persistence', () => {
+ it('returns 404 when user is not found', async () => {
+ updateUser.mockResolvedValue(null);
+ const req = makeReq({ favorites: [{ spec: 's1' }] });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({ message: 'User not found' });
+ });
+
+ it('returns 500 when updateUser throws', async () => {
+ updateUser.mockRejectedValue(new Error('db down'));
+ const req = makeReq({ favorites: [{ spec: 's1' }] });
+ const res = makeRes();
+ await updateFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(500);
+ expect(res.json).toHaveBeenCalledWith({ message: 'Internal server error' });
+ });
+ });
+
+ describe('getFavoritesController', () => {
+ it('returns the user favorites array', async () => {
+ const favorites = [{ agentId: 'a1' }, { spec: 's1' }];
+ getUserById.mockResolvedValue({ favorites });
+ const req = makeReq();
+ const res = makeRes();
+ await getFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith(favorites);
+ });
+
+ it('returns [] when user.favorites is null (falsy)', async () => {
+ getUserById.mockResolvedValue({ favorites: null });
+ const req = makeReq();
+ const res = makeRes();
+ await getFavoritesController(req, res);
+ expect(updateUser).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith([]);
+ });
+
+ it('repairs corrupt favorites field (non-array truthy)', async () => {
+ getUserById.mockResolvedValue({ favorites: 'corrupt' });
+ updateUser.mockResolvedValue({ favorites: [] });
+ const req = makeReq();
+ const res = makeRes();
+ await getFavoritesController(req, res);
+ expect(updateUser).toHaveBeenCalledWith('user-123', { favorites: [] });
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith([]);
+ });
+
+ it('returns 404 when user not found', async () => {
+ getUserById.mockResolvedValue(null);
+ const req = makeReq();
+ const res = makeRes();
+ await getFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(404);
+ });
+
+ it('returns 500 when getUserById throws', async () => {
+ getUserById.mockRejectedValue(new Error('db down'));
+ const req = makeReq();
+ const res = makeRes();
+ await getFavoritesController(req, res);
+ expect(res.status).toHaveBeenCalledWith(500);
+ });
+ });
+});
diff --git a/api/server/controllers/ModelController.js b/api/server/controllers/ModelController.js
new file mode 100644
index 0000000..4738d45
--- /dev/null
+++ b/api/server/controllers/ModelController.js
@@ -0,0 +1,22 @@
+const { logger } = require('@librechat/data-schemas');
+const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config');
+
+const getModelsConfig = (req) => loadModels(req);
+
+async function loadModels(req) {
+ const defaultModelsConfig = await loadDefaultModels(req);
+ const customModelsConfig = await loadConfigModels(req);
+ return { ...defaultModelsConfig, ...customModelsConfig };
+}
+
+async function modelController(req, res) {
+ try {
+ const modelConfig = await loadModels(req);
+ res.send(modelConfig);
+ } catch (error) {
+ logger.error('Error fetching models:', error);
+ res.status(500).send({ error: error.message });
+ }
+}
+
+module.exports = { modelController, loadModels, getModelsConfig };
diff --git a/api/server/controllers/PermissionsController.js b/api/server/controllers/PermissionsController.js
new file mode 100644
index 0000000..076de31
--- /dev/null
+++ b/api/server/controllers/PermissionsController.js
@@ -0,0 +1,570 @@
+/**
+ * @import { TUpdateResourcePermissionsRequest, TUpdateResourcePermissionsResponse } from 'librechat-data-provider'
+ */
+
+const mongoose = require('mongoose');
+const { logger, getTenantId, SYSTEM_TENANT_ID } = require('@librechat/data-schemas');
+const { ResourceType, PrincipalType, PermissionBits } = require('librechat-data-provider');
+const { enrichRemoteAgentPrincipals, backfillRemoteAgentPermissions } = require('@librechat/api');
+const {
+ bulkUpdateResourcePermissions,
+ ensureGroupPrincipalExists,
+ getResourcePermissionsMap,
+ findAccessibleResources,
+ getEffectivePermissions,
+ ensurePrincipalExists,
+ getAvailableRoles,
+} = require('~/server/services/PermissionService');
+const {
+ entraIdPrincipalFeatureEnabled,
+ searchEntraIdPrincipals,
+} = require('~/server/services/GraphApiService');
+const db = require('~/models');
+
+const matchesCurrentTenant = (principal, tenantId) => {
+ if (!tenantId || tenantId === SYSTEM_TENANT_ID) {
+ return true;
+ }
+ return principal?.tenantId === tenantId;
+};
+
+/**
+ * Generic controller for resource permission endpoints
+ * Delegates validation and logic to PermissionService
+ */
+
+/**
+ * Validates that the resourceType is one of the supported enum values
+ * @param {string} resourceType - The resource type to validate
+ * @throws {Error} If resourceType is not valid
+ */
+const validateResourceType = (resourceType) => {
+ const validTypes = Object.values(ResourceType);
+ if (!validTypes.includes(resourceType)) {
+ throw new Error(`Invalid resourceType: ${resourceType}. Valid types: ${validTypes.join(', ')}`);
+ }
+};
+
+/**
+ * Bulk update permissions for a resource (grant, update, remove)
+ * @route PUT /api/{resourceType}/{resourceId}/permissions
+ * @param {Object} req - Express request object
+ * @param {Object} req.params - Route parameters
+ * @param {string} req.params.resourceType - Resource type (e.g., 'agent')
+ * @param {string} req.params.resourceId - Resource ID
+ * @param {TUpdateResourcePermissionsRequest} req.body - Request body
+ * @param {Object} res - Express response object
+ * @returns {Promise} Updated permissions response
+ */
+const updateResourcePermissions = async (req, res) => {
+ try {
+ const { resourceType, resourceId } = req.params;
+ validateResourceType(resourceType);
+
+ /** @type {TUpdateResourcePermissionsRequest} */
+ const { updated, removed, public: isPublic, publicAccessRoleId } = req.body;
+ const { id: userId } = req.user;
+
+ // Prepare principals for the service call
+ const updatedPrincipals = [];
+ const revokedPrincipals = [];
+
+ // Add updated principals
+ if (updated && Array.isArray(updated)) {
+ updatedPrincipals.push(...updated);
+ }
+
+ // Add public permission if enabled
+ if (isPublic && publicAccessRoleId) {
+ updatedPrincipals.push({
+ type: PrincipalType.PUBLIC,
+ id: null,
+ accessRoleId: publicAccessRoleId,
+ });
+ }
+
+ // Prepare authentication context for enhanced group member fetching
+ const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
+ const authHeader = req.headers.authorization;
+ const accessToken =
+ authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
+ const authContext =
+ useEntraId && accessToken
+ ? {
+ accessToken,
+ sub: req.user.openidId,
+ }
+ : null;
+
+ // Ensure updated principals exist in the database before processing permissions
+ const validatedPrincipals = [];
+ for (const principal of updatedPrincipals) {
+ try {
+ let principalId;
+
+ if (principal.type === PrincipalType.PUBLIC) {
+ principalId = null; // Public principals don't need database records
+ } else if (principal.type === PrincipalType.ROLE) {
+ principalId = principal.id; // Role principals use role name as ID
+ } else if (principal.type === PrincipalType.USER) {
+ principalId = await ensurePrincipalExists(principal);
+ } else if (principal.type === PrincipalType.GROUP) {
+ // Pass authContext to enable member fetching for Entra ID groups when available
+ principalId = await ensureGroupPrincipalExists(principal, authContext);
+ } else {
+ logger.error(`Unsupported principal type: ${principal.type}`);
+ continue; // Skip invalid principal types
+ }
+
+ // Update the principal with the validated ID for ACL operations
+ validatedPrincipals.push({
+ ...principal,
+ id: principalId,
+ });
+ } catch (error) {
+ logger.error('Error ensuring principal exists:', {
+ principal: {
+ type: principal.type,
+ id: principal.id,
+ name: principal.name,
+ source: principal.source,
+ },
+ error: error.message,
+ });
+ // Continue with other principals instead of failing the entire operation
+ continue;
+ }
+ }
+
+ // Add removed principals
+ if (removed && Array.isArray(removed)) {
+ revokedPrincipals.push(...removed);
+ }
+
+ // If public is explicitly disabled, add public to revoked list
+ if (isPublic === false) {
+ revokedPrincipals.push({
+ type: PrincipalType.PUBLIC,
+ id: null,
+ });
+ }
+
+ const results = await bulkUpdateResourcePermissions({
+ resourceType,
+ resourceId,
+ updatedPrincipals: validatedPrincipals,
+ revokedPrincipals,
+ 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',
+ results: {
+ principals: results.granted,
+ ...(isPublic !== undefined ? { public: isPublic } : {}),
+ publicAccessRoleId: isPublic ? publicAccessRoleId : undefined,
+ },
+ };
+
+ res.status(200).json(response);
+ } catch (error) {
+ logger.error('Error updating resource permissions:', error);
+ res.status(400).json({
+ error: 'Failed to update permissions',
+ details: error.message,
+ });
+ }
+};
+
+/**
+ * Get principals with their permission roles for a resource (UI-friendly format)
+ * Uses efficient aggregation pipeline to join User/Group data in single query
+ * @route GET /api/permissions/{resourceType}/{resourceId}
+ */
+const getResourcePermissions = async (req, res) => {
+ try {
+ const { resourceType, resourceId } = req.params;
+ validateResourceType(resourceType);
+ const tenantId = getTenantId();
+
+ const results = await db.aggregateAclEntries([
+ // Match ACL entries for this resource
+ {
+ $match: {
+ resourceType,
+ resourceId: mongoose.Types.ObjectId.isValid(resourceId)
+ ? mongoose.Types.ObjectId.createFromHexString(resourceId)
+ : resourceId,
+ },
+ },
+ // Lookup AccessRole information
+ {
+ $lookup: {
+ from: 'accessroles',
+ localField: 'roleId',
+ foreignField: '_id',
+ as: 'role',
+ },
+ },
+ // Lookup User information (for user principals)
+ {
+ $lookup: {
+ from: 'users',
+ localField: 'principalId',
+ foreignField: '_id',
+ as: 'userInfo',
+ },
+ },
+ // Lookup Group information (for group principals)
+ {
+ $lookup: {
+ from: 'groups',
+ localField: 'principalId',
+ foreignField: '_id',
+ as: 'groupInfo',
+ },
+ },
+ // Project final structure
+ {
+ $project: {
+ principalType: 1,
+ principalId: 1,
+ accessRoleId: { $arrayElemAt: ['$role.accessRoleId', 0] },
+ userInfo: { $arrayElemAt: ['$userInfo', 0] },
+ groupInfo: { $arrayElemAt: ['$groupInfo', 0] },
+ },
+ },
+ ]);
+
+ let principals = [];
+ let publicPermission = null;
+
+ for (const result of results) {
+ if (result.principalType === PrincipalType.PUBLIC) {
+ publicPermission = {
+ public: true,
+ publicAccessRoleId: result.accessRoleId,
+ };
+ } else if (
+ result.principalType === PrincipalType.USER &&
+ result.userInfo &&
+ matchesCurrentTenant(result.userInfo, tenantId)
+ ) {
+ principals.push({
+ type: PrincipalType.USER,
+ id: result.userInfo._id.toString(),
+ name: result.userInfo.name || result.userInfo.username,
+ email: result.userInfo.email,
+ avatar: result.userInfo.avatar,
+ source: !result.userInfo._id ? 'entra' : 'local',
+ idOnTheSource: result.userInfo.idOnTheSource || result.userInfo._id.toString(),
+ accessRoleId: result.accessRoleId,
+ });
+ } else if (
+ result.principalType === PrincipalType.GROUP &&
+ result.groupInfo &&
+ matchesCurrentTenant(result.groupInfo, tenantId)
+ ) {
+ principals.push({
+ type: PrincipalType.GROUP,
+ id: result.groupInfo._id.toString(),
+ name: result.groupInfo.name,
+ email: result.groupInfo.email,
+ description: result.groupInfo.description,
+ avatar: result.groupInfo.avatar,
+ source: result.groupInfo.source || 'local',
+ idOnTheSource: result.groupInfo.idOnTheSource || result.groupInfo._id.toString(),
+ accessRoleId: result.accessRoleId,
+ });
+ } else if (result.principalType === PrincipalType.ROLE) {
+ principals.push({
+ type: PrincipalType.ROLE,
+ /** Role name as ID */
+ id: result.principalId,
+ /** Display the role name */
+ name: result.principalId,
+ description: `System role: ${result.principalId}`,
+ accessRoleId: result.accessRoleId,
+ });
+ }
+ }
+
+ if (resourceType === ResourceType.REMOTE_AGENT) {
+ 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);
+ }
+
+ // Return response in format expected by frontend
+ const response = {
+ resourceType,
+ resourceId,
+ principals,
+ public: publicPermission?.public || false,
+ ...(publicPermission?.publicAccessRoleId && {
+ publicAccessRoleId: publicPermission.publicAccessRoleId,
+ }),
+ };
+
+ res.status(200).json(response);
+ } catch (error) {
+ logger.error('Error getting resource permissions principals:', error);
+ res.status(500).json({
+ error: 'Failed to get permissions principals',
+ details: error.message,
+ });
+ }
+};
+
+/**
+ * Get available roles for a resource type
+ * @route GET /api/{resourceType}/roles
+ */
+const getResourceRoles = async (req, res) => {
+ try {
+ const { resourceType } = req.params;
+ validateResourceType(resourceType);
+
+ const roles = await getAvailableRoles({ resourceType });
+
+ res.status(200).json(
+ roles.map((role) => ({
+ accessRoleId: role.accessRoleId,
+ name: role.name,
+ description: role.description,
+ permBits: role.permBits,
+ })),
+ );
+ } catch (error) {
+ logger.error('Error getting resource roles:', error);
+ res.status(500).json({
+ error: 'Failed to get roles',
+ details: error.message,
+ });
+ }
+};
+
+/**
+ * Get user's effective permission bitmask for a resource
+ * @route GET /api/{resourceType}/{resourceId}/effective
+ */
+const getUserEffectivePermissions = async (req, res) => {
+ try {
+ const { resourceType, resourceId } = req.params;
+ validateResourceType(resourceType);
+
+ const { id: userId } = req.user;
+
+ const permissionBits = await getEffectivePermissions({
+ userId,
+ role: req.user.role,
+ resourceType,
+ resourceId,
+ });
+
+ res.status(200).json({
+ permissionBits,
+ });
+ } catch (error) {
+ logger.error('Error getting user effective permissions:', error);
+ res.status(500).json({
+ error: 'Failed to get effective permissions',
+ details: error.message,
+ });
+ }
+};
+
+/**
+ * Search for users and groups to grant permissions
+ * Supports hybrid local database + Entra ID search when configured
+ * @route GET /api/permissions/search-principals
+ */
+const searchPrincipals = async (req, res) => {
+ try {
+ const { q: rawQuery, limit = 20, types } = req.query;
+
+ if (typeof rawQuery !== 'string' || rawQuery.trim().length === 0) {
+ return res.status(400).json({
+ error: 'Query parameter "q" is required and must not be empty',
+ });
+ }
+
+ const query = rawQuery.trim();
+
+ if (query.length < 2) {
+ return res.status(400).json({
+ error: 'Query must be at least 2 characters long',
+ });
+ }
+
+ const searchLimit = Math.min(Math.max(1, parseInt(limit) || 10), 50);
+
+ let typeFilters = null;
+ if (types) {
+ const typesArray = Array.isArray(types) ? types : types.split(',');
+ const validTypes = typesArray.filter((t) =>
+ [PrincipalType.USER, PrincipalType.GROUP, PrincipalType.ROLE].includes(t),
+ );
+ typeFilters = validTypes.length > 0 ? validTypes : null;
+ }
+
+ const localResults = await db.searchPrincipals(query, searchLimit, typeFilters);
+ let allPrincipals = [...localResults];
+
+ const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
+
+ if (useEntraId && localResults.length < searchLimit) {
+ try {
+ let graphType = 'all';
+ if (typeFilters && typeFilters.length === 1) {
+ const graphTypeMap = {
+ [PrincipalType.USER]: 'users',
+ [PrincipalType.GROUP]: 'groups',
+ };
+ const mappedType = graphTypeMap[typeFilters[0]];
+ if (mappedType) {
+ graphType = mappedType;
+ }
+ }
+
+ const authHeader = req.headers.authorization;
+ const accessToken =
+ authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
+
+ if (accessToken) {
+ const graphResults = await searchEntraIdPrincipals(
+ accessToken,
+ req.user.openidId,
+ query,
+ graphType,
+ searchLimit - localResults.length,
+ );
+
+ const localEmails = new Set(
+ localResults.map((p) => p.email?.toLowerCase()).filter(Boolean),
+ );
+ const localGroupSourceIds = new Set(
+ localResults.map((p) => p.idOnTheSource).filter(Boolean),
+ );
+
+ for (const principal of graphResults) {
+ const isDuplicateByEmail =
+ principal.email && localEmails.has(principal.email.toLowerCase());
+ const isDuplicateBySourceId =
+ principal.idOnTheSource && localGroupSourceIds.has(principal.idOnTheSource);
+
+ if (!isDuplicateByEmail && !isDuplicateBySourceId) {
+ allPrincipals.push(principal);
+ }
+ }
+ }
+ } catch (graphError) {
+ logger.warn('Graph API search failed, falling back to local results:', graphError.message);
+ }
+ }
+ const scoredResults = allPrincipals.map((item) => ({
+ ...item,
+ _searchScore: db.calculateRelevanceScore(item, query),
+ }));
+
+ const finalResults = db
+ .sortPrincipalsByRelevance(scoredResults)
+ .slice(0, searchLimit)
+ .map((result) => {
+ const { _searchScore, ...resultWithoutScore } = result;
+ return resultWithoutScore;
+ });
+
+ res.status(200).json({
+ query,
+ limit: searchLimit,
+ types: typeFilters,
+ results: finalResults,
+ count: finalResults.length,
+ sources: {
+ local: finalResults.filter((r) => r.source === 'local').length,
+ entra: finalResults.filter((r) => r.source === 'entra').length,
+ },
+ });
+ } catch (error) {
+ logger.error('Error searching principals:', error);
+ res.status(500).json({
+ error: 'Failed to search principals',
+ });
+ }
+};
+
+/**
+ * Get user's effective permissions for all accessible resources of a type
+ * @route GET /api/permissions/{resourceType}/effective/all
+ */
+const getAllEffectivePermissions = async (req, res) => {
+ try {
+ const { resourceType } = req.params;
+ validateResourceType(resourceType);
+
+ const { id: userId } = req.user;
+
+ // Find all resources the user has at least VIEW access to
+ const accessibleResourceIds = await findAccessibleResources({
+ userId,
+ role: req.user.role,
+ resourceType,
+ requiredPermissions: PermissionBits.VIEW,
+ });
+
+ if (accessibleResourceIds.length === 0) {
+ return res.status(200).json({});
+ }
+
+ // Get effective permissions for all accessible resources
+ const permissionsMap = await getResourcePermissionsMap({
+ userId,
+ role: req.user.role,
+ resourceType,
+ resourceIds: accessibleResourceIds,
+ });
+
+ // Convert Map to plain object for JSON response
+ const result = {};
+ for (const [resourceId, permBits] of permissionsMap) {
+ result[resourceId] = permBits;
+ }
+
+ res.status(200).json(result);
+ } catch (error) {
+ logger.error('Error getting all effective permissions:', error);
+ res.status(500).json({
+ error: 'Failed to get all effective permissions',
+ details: error.message,
+ });
+ }
+};
+
+module.exports = {
+ updateResourcePermissions,
+ getResourcePermissions,
+ getResourceRoles,
+ getUserEffectivePermissions,
+ getAllEffectivePermissions,
+ searchPrincipals,
+};
diff --git a/api/server/controllers/PluginController.js b/api/server/controllers/PluginController.js
new file mode 100644
index 0000000..15e952c
--- /dev/null
+++ b/api/server/controllers/PluginController.js
@@ -0,0 +1,114 @@
+const { logger } = require('@librechat/data-schemas');
+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 getAvailablePluginsController = async (req, res) => {
+ try {
+ const appConfig =
+ req.config ??
+ (await getAppConfig({
+ role: req.user?.role,
+ userId: req.user?.id,
+ tenantId: req.user?.tenantId,
+ }));
+ const { filteredTools = [], includedTools = [] } = appConfig;
+
+ 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) {
+ /** Agents-runtime-only tools (e.g. ask_user_question) never work on the
+ * legacy plugins endpoint — no run to pause, no resume surface. */
+ if (plugin.agentsOnly === true) {
+ continue;
+ }
+ 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);
+ }
+
+ res.status(200).json(plugins);
+ } catch (error) {
+ res.status(500).json({ message: error.message });
+ }
+};
+
+const getAvailableTools = async (req, res) => {
+ try {
+ const userId = req.user?.id;
+ if (!userId) {
+ logger.warn('[getAvailableTools] User ID not found in request');
+ return res.status(401).json({ message: 'Unauthorized' });
+ }
+
+ const appConfig =
+ req.config ??
+ (await getAppConfig({
+ role: req.user?.role,
+ userId: req.user?.id,
+ tenantId: req.user?.tenantId,
+ }));
+
+ let toolDefinitions = await getCachedTools();
+
+ if (toolDefinitions == null && appConfig?.availableTools != null) {
+ logger.warn('[getAvailableTools] Tool cache was empty, re-initializing from app config');
+ await setCachedTools(appConfig.availableTools);
+ toolDefinitions = appConfig.availableTools;
+ }
+
+ const uniquePlugins = filterUniquePlugins(availableTools);
+ const toolDefKeysList = toolDefinitions ? Object.keys(toolDefinitions) : null;
+ const toolDefKeys = toolDefKeysList ? new Set(toolDefKeysList) : null;
+
+ /**
+ * `getAvailableTools` serves BOTH tool dialogs — /api/agents/tools and
+ * /api/assistants/tools. Tools flagged `agentsOnly` in the manifest (e.g.
+ * ask_user_question, which pauses an agents run via a LangGraph interrupt)
+ * cannot work on the assistants runtime: it executes tools directly with no
+ * run to pause and no resume surface, so attaching one there guarantees a
+ * permanent tool error. Scope them out of the assistants listing by route.
+ */
+ const isAssistantsRoute = req.baseUrl?.includes('/assistants') === true;
+
+ const toolsOutput = [];
+ for (const plugin of uniquePlugins) {
+ if (plugin.agentsOnly === true && isAssistantsRoute) {
+ continue;
+ }
+ const isToolDefined = toolDefKeys?.has(plugin.pluginKey) === true;
+ const isToolkit =
+ plugin.toolkit === true &&
+ toolDefKeysList != null &&
+ toolDefKeysList.some(
+ (key) => getToolkitKey({ toolkits, toolName: key }) === plugin.pluginKey,
+ );
+
+ if (!isToolDefined && !isToolkit) {
+ continue;
+ }
+
+ toolsOutput.push(checkPluginAuth(plugin) ? { ...plugin, authenticated: true } : plugin);
+ }
+
+ res.status(200).json(toolsOutput);
+ } catch (error) {
+ logger.error('[getAvailableTools]', error);
+ res.status(500).json({ message: error.message });
+ }
+};
+
+module.exports = {
+ getAvailableTools,
+ getAvailablePluginsController,
+};
diff --git a/api/server/controllers/PluginController.spec.js b/api/server/controllers/PluginController.spec.js
new file mode 100644
index 0000000..d5d2369
--- /dev/null
+++ b/api/server/controllers/PluginController.spec.js
@@ -0,0 +1,478 @@
+const { getCachedTools, getAppConfig } = require('~/server/services/Config');
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: {
+ debug: jest.fn(),
+ error: jest.fn(),
+ warn: jest.fn(),
+ },
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ getCachedTools: jest.fn(),
+ getAppConfig: jest.fn().mockResolvedValue({
+ filteredTools: [],
+ includedTools: [],
+ }),
+ setCachedTools: jest.fn(),
+}));
+
+jest.mock('~/app/clients/tools', () => ({
+ availableTools: [],
+ toolkits: [],
+}));
+
+const { getAvailableTools, getAvailablePluginsController } = require('./PluginController');
+
+describe('PluginController', () => {
+ let mockReq, mockRes;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockReq = {
+ user: { id: 'test-user-id' },
+ config: {
+ filteredTools: [],
+ includedTools: [],
+ },
+ };
+ mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
+
+ require('~/app/clients/tools').availableTools.length = 0;
+ require('~/app/clients/tools').toolkits.length = 0;
+
+ getCachedTools.mockReset();
+
+ getAppConfig.mockReset();
+ getAppConfig.mockResolvedValue({
+ filteredTools: [],
+ includedTools: [],
+ });
+ });
+
+ describe('getAvailablePluginsController', () => {
+ it('should use filterUniquePlugins to remove duplicate plugins', async () => {
+ const mockPlugins = [
+ { name: 'Plugin1', pluginKey: 'key1', description: 'First' },
+ { name: 'Plugin1', pluginKey: 'key1', description: 'First duplicate' },
+ { name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
+ ];
+
+ require('~/app/clients/tools').availableTools.push(...mockPlugins);
+
+ getAppConfig.mockResolvedValueOnce({
+ filteredTools: [],
+ includedTools: [],
+ });
+
+ await getAvailablePluginsController(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ const responseData = mockRes.json.mock.calls[0][0];
+ 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 () => {
+ const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' };
+
+ require('~/app/clients/tools').availableTools.push(mockPlugin);
+
+ getAppConfig.mockResolvedValueOnce({
+ filteredTools: [],
+ includedTools: [],
+ });
+
+ await getAvailablePluginsController(mockReq, mockRes);
+
+ const responseData = mockRes.json.mock.calls[0][0];
+ expect(responseData[0].authenticated).toBeUndefined();
+ });
+
+ it('excludes agentsOnly plugins from the legacy plugins endpoint (no run to pause)', async () => {
+ require('~/app/clients/tools').availableTools.push(
+ { name: 'Ask User', pluginKey: 'ask_user_question', description: 'q', agentsOnly: true },
+ { name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
+ );
+
+ await getAvailablePluginsController(mockReq, mockRes);
+
+ const responseData = mockRes.json.mock.calls[0][0];
+ expect(responseData.map((p) => p.pluginKey)).toEqual(['key2']);
+ });
+
+ it('should filter plugins based on includedTools', async () => {
+ const mockPlugins = [
+ { name: 'Plugin1', pluginKey: 'key1', description: 'First' },
+ { name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
+ ];
+
+ require('~/app/clients/tools').availableTools.push(...mockPlugins);
+
+ mockReq.config = {
+ filteredTools: [],
+ includedTools: ['key1'],
+ };
+
+ await getAvailablePluginsController(mockReq, mockRes);
+
+ const responseData = mockRes.json.mock.calls[0][0];
+ 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);
+
+ mockReq.config = {
+ 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);
+
+ mockReq.config = {
+ 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', () => {
+ it('scopes agentsOnly plugins out of the ASSISTANTS listing but keeps them for agents', async () => {
+ const cached = {
+ ask_user_question: {
+ type: 'function',
+ function: { name: 'ask_user_question', description: 'q', parameters: {} },
+ },
+ };
+ require('~/app/clients/tools').availableTools.push({
+ name: 'Ask User',
+ pluginKey: 'ask_user_question',
+ description: 'q',
+ agentsOnly: true,
+ });
+
+ // Agents route: listed.
+ getCachedTools.mockResolvedValueOnce(cached);
+ mockReq.baseUrl = '/api/agents/tools';
+ await getAvailableTools(mockReq, mockRes);
+ expect(mockRes.json.mock.calls[0][0].map((t) => t.pluginKey)).toContain('ask_user_question');
+
+ // Assistants route: the runtime executes tools with no run to pause — excluded.
+ mockRes.json.mockClear();
+ getCachedTools.mockResolvedValueOnce(cached);
+ mockReq.baseUrl = '/api/assistants/v2/tools';
+ await getAvailableTools(mockReq, mockRes);
+ expect(mockRes.json.mock.calls[0][0].map((t) => t.pluginKey)).not.toContain(
+ 'ask_user_question',
+ );
+ });
+
+ it('should use filterUniquePlugins to deduplicate combined tools', async () => {
+ const mockUserTools = {
+ 'user-tool': {
+ type: 'function',
+ function: {
+ name: 'user-tool',
+ description: 'User tool',
+ parameters: { type: 'object', properties: {} },
+ },
+ },
+ };
+
+ require('~/app/clients/tools').availableTools.push(
+ { name: 'user-tool', pluginKey: 'user-tool', description: 'Duplicate user tool' },
+ { name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' },
+ );
+
+ getCachedTools.mockResolvedValueOnce(mockUserTools);
+ mockReq.config = {
+ mcpConfig: null,
+ paths: { structuredTools: '/mock/path' },
+ };
+
+ await getAvailableTools(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ const responseData = mockRes.json.mock.calls[0][0];
+ expect(Array.isArray(responseData)).toBe(true);
+ const userToolCount = responseData.filter((tool) => tool.pluginKey === 'user-tool').length;
+ expect(userToolCount).toBe(1);
+ });
+
+ it('should use checkPluginAuth to verify authentication status', async () => {
+ const mockPlugin = {
+ name: 'Tool1',
+ pluginKey: 'tool1',
+ description: 'Tool 1',
+ };
+
+ require('~/app/clients/tools').availableTools.push(mockPlugin);
+
+ getCachedTools.mockResolvedValueOnce({
+ tool1: {
+ type: 'function',
+ function: {
+ name: 'tool1',
+ description: 'Tool 1',
+ parameters: {},
+ },
+ },
+ });
+ mockReq.config = {
+ mcpConfig: null,
+ paths: { structuredTools: '/mock/path' },
+ };
+
+ await getAvailableTools(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ const responseData = mockRes.json.mock.calls[0][0];
+ expect(Array.isArray(responseData)).toBe(true);
+ const tool = responseData.find((t) => t.pluginKey === 'tool1');
+ expect(tool).toBeDefined();
+ expect(tool.authenticated).toBeUndefined();
+ });
+
+ it('should use getToolkitKey for toolkit validation', async () => {
+ const mockToolkit = {
+ name: 'Toolkit1',
+ pluginKey: 'toolkit1',
+ description: 'Toolkit 1',
+ toolkit: true,
+ };
+
+ require('~/app/clients/tools').availableTools.push(mockToolkit);
+
+ require('~/app/clients/tools').toolkits.push({
+ name: 'Toolkit1',
+ pluginKey: 'toolkit1',
+ tools: ['toolkit1_function'],
+ });
+
+ getCachedTools.mockResolvedValueOnce({
+ toolkit1_function: {
+ type: 'function',
+ function: {
+ name: 'toolkit1_function',
+ description: 'Toolkit function',
+ parameters: {},
+ },
+ },
+ });
+ mockReq.config = {
+ mcpConfig: null,
+ paths: { structuredTools: '/mock/path' },
+ };
+
+ await getAvailableTools(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ const responseData = mockRes.json.mock.calls[0][0];
+ expect(Array.isArray(responseData)).toBe(true);
+ const toolkit = responseData.find((t) => t.pluginKey === 'toolkit1');
+ expect(toolkit).toBeDefined();
+ });
+ });
+
+ describe('helper function integration', () => {
+ it('should handle error cases gracefully', async () => {
+ getCachedTools.mockRejectedValue(new Error('Cache error'));
+
+ await getAvailableTools(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(500);
+ expect(mockRes.json).toHaveBeenCalledWith({ message: 'Cache error' });
+ });
+ });
+
+ describe('edge cases with undefined/null values', () => {
+ it('should handle null cachedTools', async () => {
+ getCachedTools.mockResolvedValueOnce({});
+ mockReq.config = {
+ mcpConfig: null,
+ paths: { structuredTools: '/mock/path' },
+ };
+
+ await getAvailableTools(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ expect(mockRes.json).toHaveBeenCalledWith([]);
+ });
+
+ it('should handle when getCachedTools returns undefined', async () => {
+ mockReq.config = {
+ mcpConfig: null,
+ paths: { structuredTools: '/mock/path' },
+ };
+
+ getCachedTools.mockReset();
+ getCachedTools.mockResolvedValueOnce(undefined);
+
+ await getAvailableTools(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ expect(mockRes.json).toHaveBeenCalledWith([]);
+ });
+
+ it('should handle empty toolDefinitions object', async () => {
+ getCachedTools.mockReset();
+ getCachedTools.mockResolvedValue({});
+ mockReq.config = {};
+
+ require('~/app/clients/tools').availableTools.length = 0;
+
+ await getAvailableTools(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalledWith([]);
+ });
+
+ it('should handle undefined filteredTools and includedTools', async () => {
+ mockReq.config = {};
+
+ getAppConfig.mockResolvedValueOnce({});
+
+ await getAvailablePluginsController(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ expect(mockRes.json).toHaveBeenCalledWith([]);
+ });
+
+ it('should handle toolkit with undefined toolDefinitions keys', async () => {
+ const mockToolkit = {
+ name: 'Toolkit1',
+ pluginKey: 'toolkit1',
+ description: 'Toolkit 1',
+ toolkit: true,
+ };
+
+ require('~/app/clients/tools').availableTools.push(mockToolkit);
+
+ getCachedTools.mockResolvedValueOnce({});
+ mockReq.config = {
+ mcpConfig: null,
+ paths: { structuredTools: '/mock/path' },
+ };
+
+ await getAvailableTools(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ });
+
+ it('should handle undefined toolDefinitions when checking isToolDefined', async () => {
+ const mockPlugin = {
+ name: 'Traversaal Search',
+ pluginKey: 'traversaal_search',
+ description: 'Search plugin',
+ };
+
+ require('~/app/clients/tools').availableTools.push(mockPlugin);
+
+ mockReq.config = {
+ mcpConfig: null,
+ paths: { structuredTools: '/mock/path' },
+ };
+
+ getCachedTools.mockResolvedValueOnce(undefined);
+
+ await getAvailableTools(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ expect(mockRes.json).toHaveBeenCalledWith([]);
+ });
+
+ it('should re-initialize tools from appConfig when cache returns null', async () => {
+ const mockAppTools = {
+ tool1: {
+ type: 'function',
+ function: {
+ name: 'tool1',
+ description: 'Tool 1',
+ parameters: {},
+ },
+ },
+ tool2: {
+ type: 'function',
+ function: {
+ name: 'tool2',
+ description: 'Tool 2',
+ parameters: {},
+ },
+ },
+ };
+
+ require('~/app/clients/tools').availableTools.push(
+ { name: 'Tool 1', pluginKey: 'tool1', description: 'Tool 1' },
+ { name: 'Tool 2', pluginKey: 'tool2', description: 'Tool 2' },
+ );
+
+ getCachedTools.mockResolvedValueOnce(null);
+
+ mockReq.config = {
+ filteredTools: [],
+ includedTools: [],
+ availableTools: mockAppTools,
+ };
+
+ const { setCachedTools } = require('~/server/services/Config');
+
+ await getAvailableTools(mockReq, mockRes);
+
+ expect(setCachedTools).toHaveBeenCalledWith(mockAppTools);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ const responseData = mockRes.json.mock.calls[0][0];
+ expect(responseData).toHaveLength(2);
+ expect(responseData.find((t) => t.pluginKey === 'tool1')).toBeDefined();
+ expect(responseData.find((t) => t.pluginKey === 'tool2')).toBeDefined();
+ });
+
+ it('should handle cache clear without appConfig.availableTools gracefully', async () => {
+ getAppConfig.mockResolvedValue({
+ filteredTools: [],
+ includedTools: [],
+ });
+
+ require('~/app/clients/tools').availableTools.length = 0;
+
+ getCachedTools.mockResolvedValueOnce(null);
+
+ mockReq.config = {
+ filteredTools: [],
+ includedTools: [],
+ };
+
+ await getAvailableTools(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ expect(mockRes.json).toHaveBeenCalledWith([]);
+ });
+ });
+});
diff --git a/api/server/controllers/SkillStatesController.js b/api/server/controllers/SkillStatesController.js
new file mode 100644
index 0000000..1afeb27
--- /dev/null
+++ b/api/server/controllers/SkillStatesController.js
@@ -0,0 +1,100 @@
+const mongoose = require('mongoose');
+const { logger } = require('@librechat/data-schemas');
+const {
+ MAX_SKILL_STATES,
+ toSkillStatesRecord,
+ validateSkillStatesPayload,
+ pruneOrphanSkillStates,
+ getDeploymentSkillIds,
+ mergeDeploymentSkillIds,
+} = require('@librechat/api');
+const { ResourceType, PermissionBits } = require('librechat-data-provider');
+const { findAccessibleResources } = require('~/server/services/PermissionService');
+const { updateUser, getUserById } = require('~/models');
+
+/** Builds the injected deps for `pruneOrphanSkillStates` from live models. */
+function buildPruneDeps(user) {
+ return {
+ findExistingSkillIds: async (validIds) => {
+ const Skill = mongoose.models.Skill;
+ if (!Skill) {
+ return validIds;
+ }
+ const existing = await Skill.find({ _id: { $in: validIds } })
+ .select('_id')
+ .lean();
+ const deploymentIds = getDeploymentSkillIds()
+ .map((id) => id.toString())
+ .filter((id) => validIds.includes(id));
+ return [...existing.map((doc) => doc._id.toString()), ...deploymentIds];
+ },
+ findAccessibleSkillIds: async () =>
+ mergeDeploymentSkillIds(
+ await findAccessibleResources({
+ userId: user.id,
+ role: user.role,
+ resourceType: ResourceType.SKILL,
+ requiredPermissions: PermissionBits.VIEW,
+ }),
+ ),
+ };
+}
+
+const getSkillStatesController = async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const user = await getUserById(userId, 'skillStates');
+
+ if (!user) {
+ return res.status(404).json({ message: 'User not found' });
+ }
+
+ const states = toSkillStatesRecord(user.skillStates);
+ const pruned = await pruneOrphanSkillStates(states, buildPruneDeps(req.user));
+ return res.status(200).json(pruned);
+ } catch (error) {
+ logger.error('[SkillStatesController] Error fetching skill states:', error);
+ return res.status(500).json({ message: 'Internal server error' });
+ }
+};
+
+const updateSkillStatesController = async (req, res) => {
+ try {
+ const { skillStates } = req.body;
+
+ const validationError = validateSkillStatesPayload(skillStates);
+ if (validationError) {
+ const { message, code, limit } = validationError;
+ const payload = { message };
+ if (code) payload.code = code;
+ if (limit != null) payload.limit = limit;
+ return res.status(400).json(payload);
+ }
+
+ const pruned = await pruneOrphanSkillStates(skillStates, buildPruneDeps(req.user));
+
+ if (Object.keys(pruned).length > MAX_SKILL_STATES) {
+ return res.status(400).json({
+ code: 'MAX_SKILL_STATES_EXCEEDED',
+ message: `Maximum ${MAX_SKILL_STATES} skill state overrides allowed`,
+ limit: MAX_SKILL_STATES,
+ });
+ }
+
+ const user = await updateUser(req.user.id, { skillStates: pruned });
+
+ if (!user) {
+ return res.status(404).json({ message: 'User not found' });
+ }
+
+ return res.status(200).json(toSkillStatesRecord(user.skillStates));
+ } catch (error) {
+ logger.error('[SkillStatesController] Error updating skill states:', error);
+ return res.status(500).json({ message: 'Internal server error' });
+ }
+};
+
+module.exports = {
+ getSkillStatesController,
+ updateSkillStatesController,
+};
diff --git a/api/server/controllers/TokenConfigController.js b/api/server/controllers/TokenConfigController.js
new file mode 100644
index 0000000..059f144
--- /dev/null
+++ b/api/server/controllers/TokenConfigController.js
@@ -0,0 +1,32 @@
+const { logger } = require('@librechat/data-schemas');
+const { resolveTokenConfigMap } = require('@librechat/api');
+const { getModelsConfig } = require('~/server/controllers/ModelController');
+const { getValueKey, getMultiplier, getCacheMultiplier } = require('~/models');
+
+/**
+ * Returns server-resolved context windows (and pricing when
+ * `interface.contextCost` is enabled) for every configured model. Resolution
+ * lives in `@librechat/api`; this controller only supplies request-scoped deps.
+ * @param {ServerRequest} req
+ * @param {ServerResponse} res
+ */
+async function tokenConfigController(req, res) {
+ try {
+ const modelsConfig = await getModelsConfig(req);
+ const tokenConfigMap = await resolveTokenConfigMap(
+ {
+ appConfig: req.config,
+ modelsConfig,
+ userId: req.user.id,
+ tenantId: req.user.tenantId,
+ },
+ { getValueKey, getMultiplier, getCacheMultiplier },
+ );
+ res.json(tokenConfigMap);
+ } catch (error) {
+ logger.error('[tokenConfigController]', error);
+ res.status(500).json({ error: 'Failed to resolve token config' });
+ }
+}
+
+module.exports = tokenConfigController;
diff --git a/api/server/controllers/TwoFactorController.js b/api/server/controllers/TwoFactorController.js
new file mode 100644
index 0000000..18a0ee3
--- /dev/null
+++ b/api/server/controllers/TwoFactorController.js
@@ -0,0 +1,210 @@
+const { encryptV3, logger } = require('@librechat/data-schemas');
+const {
+ verifyOTPOrBackupCode,
+ generateBackupCodes,
+ generateTOTPSecret,
+ verifyBackupCode,
+ getTOTPSecret,
+ verifyTOTP,
+} = require('~/server/services/twoFactorService');
+const { getUserById, updateUser } = require('~/models');
+
+const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');
+
+/**
+ * Enable 2FA for the user by generating a new TOTP secret and backup codes.
+ * The secret is encrypted and stored, and 2FA is marked as disabled until confirmed.
+ * If 2FA is already enabled, requires OTP or backup code verification to re-enroll.
+ */
+const enable2FA = async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const existingUser = await getUserById(
+ userId,
+ '+totpSecret +backupCodes _id twoFactorEnabled email',
+ );
+
+ if (existingUser && existingUser.twoFactorEnabled) {
+ const { token, backupCode } = req.body;
+ const result = await verifyOTPOrBackupCode({
+ user: existingUser,
+ token,
+ backupCode,
+ persistBackupUse: false,
+ });
+
+ if (!result.verified) {
+ const msg = result.message ?? 'TOTP token or backup code is required to re-enroll 2FA';
+ return res.status(result.status ?? 400).json({ message: msg });
+ }
+ }
+
+ const secret = generateTOTPSecret();
+ const { plainCodes, codeObjects } = await generateBackupCodes();
+ const encryptedSecret = encryptV3(secret);
+
+ const user = await updateUser(userId, {
+ pendingTotpSecret: encryptedSecret,
+ pendingBackupCodes: codeObjects,
+ });
+
+ const email = user.email || (existingUser && existingUser.email) || '';
+ const otpauthUrl = `otpauth://totp/${safeAppTitle}:${email}?secret=${secret}&issuer=${safeAppTitle}`;
+
+ return res.status(200).json({ otpauthUrl, backupCodes: plainCodes });
+ } catch (err) {
+ logger.error('[enable2FA]', err);
+ return res.status(500).json({ message: err.message });
+ }
+};
+
+/**
+ * Verify a 2FA code (either TOTP or backup code) during setup.
+ */
+const verify2FA = async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { token, backupCode } = req.body;
+ const user = await getUserById(userId, '+totpSecret +pendingTotpSecret +backupCodes _id');
+ const secretSource = user?.pendingTotpSecret ?? user?.totpSecret;
+
+ if (!user || !secretSource) {
+ return res.status(400).json({ message: '2FA not initiated' });
+ }
+
+ const secret = await getTOTPSecret(secretSource);
+ let isVerified = false;
+
+ if (token) {
+ isVerified = await verifyTOTP(secret, token);
+ } else if (backupCode) {
+ isVerified = await verifyBackupCode({ user, backupCode });
+ }
+
+ if (isVerified) {
+ return res.status(200).json();
+ }
+ return res.status(400).json({ message: 'Invalid token or backup code.' });
+ } catch (err) {
+ logger.error('[verify2FA]', err);
+ return res.status(500).json({ message: err.message });
+ }
+};
+
+/**
+ * Confirm and enable 2FA after a successful verification.
+ */
+const confirm2FA = async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { token } = req.body;
+ const user = await getUserById(
+ userId,
+ '+totpSecret +pendingTotpSecret +pendingBackupCodes _id',
+ );
+ const secretSource = user?.pendingTotpSecret ?? user?.totpSecret;
+
+ if (!user || !secretSource) {
+ return res.status(400).json({ message: '2FA not initiated' });
+ }
+
+ const secret = await getTOTPSecret(secretSource);
+ if (await verifyTOTP(secret, token)) {
+ const update = {
+ totpSecret: user.pendingTotpSecret ?? user.totpSecret,
+ twoFactorEnabled: true,
+ pendingTotpSecret: null,
+ pendingBackupCodes: [],
+ };
+ if (user.pendingBackupCodes?.length) {
+ update.backupCodes = user.pendingBackupCodes;
+ }
+ await updateUser(userId, update);
+ return res.status(200).json();
+ }
+ return res.status(400).json({ message: 'Invalid token.' });
+ } catch (err) {
+ logger.error('[confirm2FA]', err);
+ return res.status(500).json({ message: err.message });
+ }
+};
+
+/**
+ * Disable 2FA by clearing the stored secret and backup codes.
+ * Requires verification with either TOTP token or backup code if 2FA is fully enabled.
+ */
+const disable2FA = async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { token, backupCode } = req.body;
+ const user = await getUserById(userId, '+totpSecret +backupCodes _id twoFactorEnabled');
+
+ if (!user || !user.totpSecret) {
+ return res.status(400).json({ message: '2FA is not setup for this user' });
+ }
+
+ if (user.twoFactorEnabled) {
+ const result = await verifyOTPOrBackupCode({ user, token, backupCode });
+
+ if (!result.verified) {
+ const msg = result.message ?? 'Either token or backup code is required to disable 2FA';
+ return res.status(result.status ?? 400).json({ message: msg });
+ }
+ }
+ await updateUser(userId, {
+ totpSecret: null,
+ backupCodes: [],
+ twoFactorEnabled: false,
+ pendingTotpSecret: null,
+ pendingBackupCodes: [],
+ });
+ return res.status(200).json();
+ } catch (err) {
+ logger.error('[disable2FA]', err);
+ return res.status(500).json({ message: err.message });
+ }
+};
+
+/**
+ * Regenerate backup codes for the user.
+ * Requires OTP or backup code verification if 2FA is already enabled.
+ */
+const regenerateBackupCodes = async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const user = await getUserById(userId, '+totpSecret +backupCodes _id twoFactorEnabled');
+
+ if (!user) {
+ return res.status(404).json({ message: 'User not found' });
+ }
+
+ if (user.twoFactorEnabled) {
+ const { token, backupCode } = req.body;
+ const result = await verifyOTPOrBackupCode({ user, token, backupCode });
+
+ if (!result.verified) {
+ const msg =
+ result.message ?? 'TOTP token or backup code is required to regenerate backup codes';
+ return res.status(result.status ?? 400).json({ message: msg });
+ }
+ }
+
+ const { plainCodes, codeObjects } = await generateBackupCodes();
+ await updateUser(userId, { backupCodes: codeObjects });
+ return res.status(200).json({
+ backupCodes: plainCodes,
+ backupCodesHash: codeObjects,
+ });
+ } catch (err) {
+ logger.error('[regenerateBackupCodes]', err);
+ return res.status(500).json({ message: err.message });
+ }
+};
+
+module.exports = {
+ enable2FA,
+ verify2FA,
+ confirm2FA,
+ disable2FA,
+ regenerateBackupCodes,
+};
diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js
new file mode 100644
index 0000000..ff10b96
--- /dev/null
+++ b/api/server/controllers/UserController.js
@@ -0,0 +1,611 @@
+const mongoose = require('mongoose');
+const { logger, getTenantId, webSearchKeys } = require('@librechat/data-schemas');
+const {
+ getNewS3URL,
+ needsRefresh,
+ MCPOAuthHandler,
+ MCPTokenStorage,
+ getAppConfigOptionsFromUser,
+ normalizeHttpError,
+ extractWebSearchEnvVars,
+ deleteAgentCheckpoints,
+ deleteAllSharedLinksWithCleanup,
+} = require('@librechat/api');
+const {
+ 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 { processDeleteRequest } = require('~/server/services/Files/process');
+const { getAppConfig } = require('~/server/services/Config');
+const { getLogStores } = require('~/cache');
+const db = require('~/models');
+
+const PUBLIC_USER_RESPONSE_FIELDS = [
+ '_id',
+ 'id',
+ 'name',
+ 'username',
+ 'email',
+ 'emailVerified',
+ 'avatar',
+ 'provider',
+ 'role',
+ 'plugins',
+ 'twoFactorEnabled',
+ 'termsAccepted',
+ 'personalization',
+ 'favorites',
+ 'skillStates',
+ 'createdAt',
+ 'updatedAt',
+ 'tenantId',
+];
+
+const sanitizeUserForResponse = (user) => {
+ const source = user.toObject != null ? user.toObject() : user;
+ return PUBLIC_USER_RESPONSE_FIELDS.reduce((userData, field) => {
+ if (source[field] !== undefined) {
+ userData[field] = source[field];
+ }
+ return userData;
+ }, {});
+};
+
+const getUserController = async (req, res) => {
+ const appConfig = req.config ?? (await getAppConfig(getAppConfigOptionsFromUser(req.user)));
+ /** @type {IUser} */
+ const userData = sanitizeUserForResponse(req.user);
+ if (appConfig.fileStrategy === FileSources.s3 && userData.avatar) {
+ const avatarNeedsRefresh = needsRefresh(userData.avatar, 3600);
+ if (!avatarNeedsRefresh) {
+ return res.status(200).send(userData);
+ }
+ const originalAvatar = userData.avatar;
+ try {
+ userData.avatar = await getNewS3URL(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);
+ }
+ }
+ res.status(200).send(userData);
+};
+
+const getTermsStatusController = async (req, res) => {
+ try {
+ const user = await db.getUserById(req.user.id, 'termsAccepted termsAcceptedAt');
+ if (!user) {
+ return res.status(404).json({ message: 'User not found' });
+ }
+ res.status(200).json({
+ termsAccepted: !!user.termsAccepted,
+ termsAcceptedAt: user.termsAcceptedAt || null,
+ });
+ } catch (error) {
+ logger.error('Error fetching terms acceptance status:', error);
+ res.status(500).json({ message: 'Error fetching terms acceptance status' });
+ }
+};
+
+const acceptTermsController = async (req, res) => {
+ try {
+ const user = await db.acceptTerms(req.user.id);
+ if (!user) {
+ return res.status(404).json({ message: 'User not found' });
+ }
+ res.status(200).json({
+ message: 'Terms accepted successfully',
+ termsAcceptedAt: user.termsAcceptedAt,
+ });
+ } catch (error) {
+ logger.error('Error accepting terms:', error);
+ res.status(500).json({ message: 'Error accepting terms' });
+ }
+};
+
+const deleteUserFiles = async (req) => {
+ try {
+ const userFiles = await db.getFiles({ user: req.user.id });
+ await processDeleteRequest({
+ req,
+ files: userFiles,
+ });
+ } catch (error) {
+ logger.error('[deleteUserFiles]', error);
+ }
+};
+
+/**
+ * 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 = req.config ?? (await getAppConfig(getAppConfigOptionsFromUser(req.user)));
+ const { user } = req;
+ const { pluginKey, action, auth, isEntityTool } = req.body;
+ try {
+ if (!isEntityTool) {
+ await db.updateUserPlugins(user._id, user.plugins, pluginKey, action);
+ }
+
+ if (auth == null) {
+ return res.status(200).send();
+ }
+
+ let keys = Object.keys(auth);
+ const values = Object.values(auth); // Used in 'install' block
+
+ const isMCPTool = pluginKey.startsWith('mcp_') || pluginKey.includes(Constants.mcp_delimiter);
+
+ // Early exit condition:
+ // If keys are empty (meaning auth: {} was likely sent for uninstall, or auth was empty for install)
+ // AND it's not web_search (which has special key handling to populate `keys` for uninstall)
+ // AND it's NOT (an uninstall action FOR an MCP tool - we need to proceed for this case to clear all its auth)
+ // THEN return.
+ if (
+ keys.length === 0 &&
+ pluginKey !== Tools.web_search &&
+ !(action === 'uninstall' && isMCPTool)
+ ) {
+ return res.status(200).send();
+ }
+
+ /** @type {number} */
+ let status = 200;
+ /** @type {string} */
+ let message;
+ /** @type {IPluginAuth | Error} */
+ let authService;
+
+ if (pluginKey === Tools.web_search) {
+ /** @type {TCustomConfig['webSearch']} */
+ const webSearchConfig = appConfig?.webSearch;
+ keys = extractWebSearchEnvVars({
+ keys: action === 'install' ? keys : webSearchKeys,
+ config: webSearchConfig,
+ });
+ }
+
+ if (action === 'install') {
+ for (let i = 0; i < keys.length; i++) {
+ authService = await updateUserPluginAuth(user.id, keys[i], pluginKey, values[i]);
+ if (authService instanceof Error) {
+ logger.error('[authService]', authService);
+ ({ status, message } = normalizeHttpError(authService));
+ }
+ }
+ } else if (action === 'uninstall') {
+ // const isMCPTool was defined earlier
+ if (isMCPTool && keys.length === 0) {
+ // This handles the case where auth: {} is sent for an MCP tool uninstall.
+ // It means "delete all credentials associated with this MCP pluginKey".
+ authService = await deleteUserPluginAuth(user.id, null, true, pluginKey);
+ if (authService instanceof Error) {
+ logger.error(
+ `[authService] Error deleting all auth for MCP tool ${pluginKey}:`,
+ authService,
+ );
+ ({ status, message } = normalizeHttpError(authService));
+ }
+ try {
+ // if the MCP server uses OAuth, perform a full cleanup and token revocation
+ await maybeUninstallOAuthMCP(user.id, pluginKey, appConfig);
+ } catch (error) {
+ logger.error(
+ `[updateUserPluginsController] Error uninstalling OAuth MCP for ${pluginKey}:`,
+ error,
+ );
+ }
+ } else {
+ // This handles:
+ // 1. Web_search uninstall (keys will be populated with all webSearchKeys if auth was {}).
+ // 2. Other tools uninstall (if keys were provided).
+ // 3. MCP tool uninstall if specific keys were provided in `auth` (not current frontend behavior).
+ // If keys is empty for non-MCP tools (and not web_search), this loop won't run, and nothing is deleted.
+ for (let i = 0; i < keys.length; i++) {
+ authService = await deleteUserPluginAuth(user.id, keys[i]); // Deletes by authField name
+ if (authService instanceof Error) {
+ logger.error('[authService] Error deleting specific auth key:', authService);
+ ({ status, message } = normalizeHttpError(authService));
+ }
+ }
+ }
+ }
+
+ if (status === 200) {
+ // If auth was updated successfully, disconnect MCP sessions as they might use these credentials
+ if (pluginKey.startsWith(Constants.mcp_prefix)) {
+ try {
+ const mcpManager = getMCPManager();
+ if (mcpManager) {
+ // Extract server name from pluginKey (format: "mcp_")
+ const serverName = pluginKey.replace(Constants.mcp_prefix, '');
+ logger.info(
+ `[updateUserPluginsController] Attempting disconnect of MCP server "${serverName}" for user ${user.id} after plugin auth update.`,
+ );
+ await mcpManager.disconnectUserConnection(user.id, serverName);
+ await invalidateCachedTools({ userId: user.id, serverName });
+ }
+ } catch (disconnectError) {
+ logger.error(
+ `[updateUserPluginsController] Error disconnecting MCP connection for user ${user.id} after plugin auth update:`,
+ disconnectError,
+ );
+ // Do not fail the request for this, but log it.
+ }
+ }
+ return res.status(status).send();
+ }
+
+ const normalized = normalizeHttpError({ status, message });
+ return res.status(normalized.status).send({ message: normalized.message });
+ } catch (err) {
+ logger.error('[updateUserPluginsController]', err);
+ return res.status(500).json({ message: 'Something went wrong.' });
+ }
+};
+
+const deleteUserController = async (req, res) => {
+ const { user } = req;
+
+ try {
+ const existingUser = await db.getUserById(
+ user.id,
+ '+totpSecret +backupCodes _id twoFactorEnabled',
+ );
+ if (existingUser && existingUser.twoFactorEnabled) {
+ const { token, backupCode } = req.body;
+ const result = await verifyOTPOrBackupCode({ user: existingUser, token, backupCode });
+
+ if (!result.verified) {
+ const msg =
+ result.message ??
+ 'TOTP token or backup code is required to delete account with 2FA enabled';
+ return res.status(result.status ?? 400).json({ message: msg });
+ }
+ }
+
+ 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 {
+ const convoDeletion = await db.deleteConvos(user.id);
+ // HITL: prune the deleted conversations' durable checkpoints — a paused run's
+ // checkpoint would otherwise persist until the Mongo TTL. Never throws.
+ const appConfig =
+ req.config ??
+ (await getAppConfig({
+ role: req.user?.role,
+ userId: req.user?.id,
+ tenantId: req.user?.tenantId,
+ }));
+ await deleteAgentCheckpoints(
+ convoDeletion?.conversationIds,
+ appConfig?.endpoints?.agents?.checkpointer,
+ );
+ } catch (error) {
+ logger.error('[deleteUserController] Error deleting user convos, likely no convos', error);
+ }
+ await deleteUserPluginAuth(user.id, null, true);
+ await db.deleteUserById(user.id);
+ await deleteAllSharedLinksWithCleanup(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 db.deleteUserSkills(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) {
+ logger.error('[deleteUserController]', err);
+ return res.status(500).json({ message: 'Something went wrong.' });
+ }
+};
+
+const verifyEmailController = async (req, res) => {
+ try {
+ const verifyEmailService = await verifyEmail(req);
+ if (verifyEmailService instanceof Error) {
+ return res.status(400).json({ message: verifyEmailService.message });
+ } else {
+ return res.status(200).json(verifyEmailService);
+ }
+ } catch (e) {
+ logger.error('[verifyEmailController]', e);
+ return res.status(500).json({ message: 'Something went wrong.' });
+ }
+};
+
+const resendVerificationController = async (req, res) => {
+ try {
+ const result = await resendVerificationEmail(req);
+ if (result instanceof Error) {
+ return res.status(400).json({ message: result.message });
+ } else {
+ return res.status(result.status ?? 200).json({ message: result.message });
+ }
+ } catch (e) {
+ logger.error('[verifyEmailController]', e);
+ return res.status(500).json({ message: 'Something went wrong.' });
+ }
+};
+
+/** Best-effort cleanup of stored MCP OAuth tokens and flow state. */
+const clearStoredMCPOAuthState = async (userId, serverName) => {
+ try {
+ await MCPTokenStorage.deleteUserTokens({
+ userId,
+ serverName,
+ deleteToken: async (filter) => {
+ await db.deleteTokens(filter);
+ },
+ });
+ } catch (error) {
+ logger.warn(
+ `[clearStoredMCPOAuthState] Failed to delete MCP OAuth tokens for ${serverName}:`,
+ error,
+ );
+ }
+
+ try {
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+ const baseFlowId = MCPOAuthHandler.generateFlowId(userId, serverName);
+ const tenantId = getTenantId();
+ const tokenFlowId = MCPOAuthHandler.generateTokenFlowId(userId, serverName, tenantId);
+ const oauthFlowId = MCPOAuthHandler.generateFlowId(userId, serverName, tenantId);
+ const flowDeletes = [
+ [tokenFlowId, 'mcp_get_tokens'],
+ [oauthFlowId, 'mcp_oauth'],
+ [baseFlowId, 'mcp_get_tokens'],
+ [baseFlowId, 'mcp_oauth'],
+ ].filter(
+ ([flowId, type], index, deletes) =>
+ deletes.findIndex(([candidateId, candidateType]) => {
+ return candidateId === flowId && candidateType === type;
+ }) === index,
+ );
+ const results = await Promise.allSettled(
+ flowDeletes.map(([flowId, type]) => flowManager.deleteFlow(flowId, type)),
+ );
+ for (const result of results) {
+ if (result.status === 'rejected') {
+ logger.warn(
+ `[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for ${serverName}:`,
+ result.reason,
+ );
+ }
+ }
+ } catch (error) {
+ logger.warn(
+ `[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for ${serverName}:`,
+ error,
+ );
+ }
+};
+
+/** Revokes MCP OAuth tokens at the provider when possible, then clears local state. */
+const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
+ if (!pluginKey.startsWith(Constants.mcp_prefix)) {
+ // this is not an MCP server, so nothing to do here
+ return;
+ }
+
+ const serverName = pluginKey.replace(Constants.mcp_prefix, '');
+ const serverConfig =
+ (await getMCPServersRegistry().getServerConfig(serverName, userId)) ??
+ appConfig?.mcpServers?.[serverName];
+ const oauthServers = await getMCPServersRegistry().getOAuthServers(userId);
+ if (!oauthServers.has(serverName) || !serverConfig) {
+ await clearStoredMCPOAuthState(userId, serverName);
+ return;
+ }
+
+ // 1. get client info used for revocation (client id, secret)
+ let clientTokenData = null;
+ try {
+ clientTokenData = await MCPTokenStorage.getClientInfoAndMetadata({
+ userId,
+ serverName,
+ findToken: db.findToken,
+ });
+ } catch (error) {
+ logger.warn(
+ `[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`,
+ error,
+ );
+ await clearStoredMCPOAuthState(userId, serverName);
+ return;
+ }
+ if (clientTokenData == null) {
+ logger.info(
+ `[maybeUninstallOAuthMCP] Missing OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`,
+ );
+ await clearStoredMCPOAuthState(userId, serverName);
+ return;
+ }
+ const { clientInfo, clientMetadata } = clientTokenData;
+
+ // 2. get decrypted tokens before deletion
+ let tokens = null;
+ try {
+ tokens = await MCPTokenStorage.getTokens({
+ userId,
+ serverName,
+ findToken: db.findToken,
+ });
+ } catch (error) {
+ logger.warn(
+ `[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`,
+ error,
+ );
+ }
+
+ // 3. revoke OAuth tokens at the provider
+ const revocationEndpoint =
+ serverConfig.oauth?.revocation_endpoint ?? clientMetadata.revocation_endpoint;
+ const revocationEndpointAuthMethodsSupported =
+ serverConfig.oauth?.revocation_endpoint_auth_methods_supported ??
+ clientMetadata.revocation_endpoint_auth_methods_supported;
+ const oauthHeaders = serverConfig.oauth_headers ?? {};
+ // Use the request's merged (tenant/principal-scoped) allowlists so admin-panel mcpSettings
+ // overrides are honored for OAuth revocation, consistent with inspection/connection.
+ const allowedDomains = appConfig?.mcpSettings?.allowedDomains;
+ const allowedAddresses = appConfig?.mcpSettings?.allowedAddresses;
+
+ if (tokens?.access_token) {
+ try {
+ await MCPOAuthHandler.revokeOAuthToken(
+ serverName,
+ tokens.access_token,
+ 'access',
+ {
+ serverUrl: serverConfig.url,
+ clientId: clientInfo.client_id,
+ clientSecret: clientInfo.client_secret ?? '',
+ revocationEndpoint,
+ revocationEndpointAuthMethodsSupported,
+ },
+ oauthHeaders,
+ allowedDomains,
+ allowedAddresses,
+ );
+ } catch (error) {
+ logger.error(
+ `[maybeUninstallOAuthMCP] Error revoking OAuth access token for ${serverName}:`,
+ error,
+ );
+ }
+ }
+
+ if (tokens?.refresh_token) {
+ try {
+ await MCPOAuthHandler.revokeOAuthToken(
+ serverName,
+ tokens.refresh_token,
+ 'refresh',
+ {
+ serverUrl: serverConfig.url,
+ clientId: clientInfo.client_id,
+ clientSecret: clientInfo.client_secret ?? '',
+ revocationEndpoint,
+ revocationEndpointAuthMethodsSupported,
+ },
+ oauthHeaders,
+ allowedDomains,
+ allowedAddresses,
+ );
+ } catch (error) {
+ logger.error(
+ `[maybeUninstallOAuthMCP] Error revoking OAuth refresh token for ${serverName}:`,
+ error,
+ );
+ }
+ }
+
+ // 4. delete tokens from the DB and clear the flow state after revocation attempts
+ await clearStoredMCPOAuthState(userId, serverName);
+};
+
+module.exports = {
+ getUserController,
+ getTermsStatusController,
+ acceptTermsController,
+ deleteUserController,
+ verifyEmailController,
+ updateUserPluginsController,
+ resendVerificationController,
+ deleteUserMcpServers,
+ maybeUninstallOAuthMCP,
+};
diff --git a/api/server/controllers/UserController.spec.js b/api/server/controllers/UserController.spec.js
new file mode 100644
index 0000000..1466dce
--- /dev/null
+++ b/api/server/controllers/UserController.spec.js
@@ -0,0 +1,416 @@
+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),
+ deleteUserSkills: 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(),
+ acceptTerms: 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({ deletedFileIds: [], failedFileIds: [] }),
+}));
+
+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,
+ getUserController,
+ acceptTermsController,
+ resendVerificationController,
+ verifyEmailController,
+} = require('./UserController');
+const { Group } = require('~/db/models');
+const { deleteConvos, acceptTerms } = require('~/models');
+const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
+
+describe('verifyEmailController', () => {
+ const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns the generic verification error message from service failures', async () => {
+ verifyEmail.mockResolvedValue(new Error('Invalid or expired email verification token'));
+
+ await verifyEmailController(
+ { body: { email: 'user%40example.com', token: 'not-the-token' } },
+ mockRes,
+ );
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: 'Invalid or expired email verification token',
+ });
+ });
+
+ it('uses the service status for resend verification responses', async () => {
+ resendVerificationEmail.mockResolvedValue({ status: 500, message: 'Something went wrong.' });
+
+ await resendVerificationController({ body: { email: 'user@example.com' } }, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(500);
+ expect(mockRes.json).toHaveBeenCalledWith({ message: 'Something went wrong.' });
+ });
+});
+
+describe('getUserController', () => {
+ const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ send: jest.fn().mockReturnThis(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should only expose public user response fields from the request user', async () => {
+ const createdAt = new Date('2026-01-01T00:00:00.000Z');
+ const updatedAt = new Date('2026-01-02T00:00:00.000Z');
+ const req = {
+ config: {},
+ user: {
+ id: 'user-id',
+ _id: 'user-id',
+ name: 'OpenID User',
+ username: 'openid-user',
+ email: 'openid@test.com',
+ emailVerified: true,
+ avatar: '/avatars/user-id.png',
+ provider: 'openid',
+ role: 'USER',
+ plugins: ['web_search'],
+ twoFactorEnabled: true,
+ termsAccepted: true,
+ personalization: { memories: false },
+ favorites: [{ model: 'gpt-5', endpoint: 'openAI' }],
+ skillStates: { skill_one: true },
+ createdAt,
+ updatedAt,
+ tenantId: 'tenant-id',
+ password: 'hashed-password',
+ __v: 1,
+ totpSecret: 'totp-secret',
+ backupCodes: [{ codeHash: 'backup-code' }],
+ pendingTotpSecret: 'pending-totp-secret',
+ pendingBackupCodes: [{ codeHash: 'pending-backup-code' }],
+ refreshToken: [{ refreshToken: 'legacy-refresh-token' }],
+ googleId: 'google-id',
+ openidId: 'openid-id',
+ openidIssuer: 'openid-issuer',
+ idOnTheSource: 'external-source-id',
+ federatedTokens: {
+ access_token: 'access-token',
+ id_token: 'id-token',
+ refresh_token: 'refresh-token',
+ },
+ openidTokens: {
+ access_token: 'openid-access-token',
+ refresh_token: 'openid-refresh-token',
+ },
+ tokenset: {
+ access_token: 'tokenset-access-token',
+ refresh_token: 'tokenset-refresh-token',
+ },
+ safeLookingRuntimeField: 'internal-value',
+ },
+ };
+
+ await getUserController(req, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ const sentUser = mockRes.send.mock.calls[0][0];
+ expect(sentUser).toMatchObject({
+ id: 'user-id',
+ _id: 'user-id',
+ name: 'OpenID User',
+ username: 'openid-user',
+ email: 'openid@test.com',
+ emailVerified: true,
+ avatar: '/avatars/user-id.png',
+ provider: 'openid',
+ role: 'USER',
+ plugins: ['web_search'],
+ twoFactorEnabled: true,
+ termsAccepted: true,
+ personalization: { memories: false },
+ favorites: [{ model: 'gpt-5', endpoint: 'openAI' }],
+ skillStates: { skill_one: true },
+ createdAt,
+ updatedAt,
+ tenantId: 'tenant-id',
+ });
+ expect(sentUser).not.toHaveProperty('password');
+ expect(sentUser).not.toHaveProperty('__v');
+ expect(sentUser).not.toHaveProperty('totpSecret');
+ expect(sentUser).not.toHaveProperty('backupCodes');
+ expect(sentUser).not.toHaveProperty('pendingTotpSecret');
+ expect(sentUser).not.toHaveProperty('pendingBackupCodes');
+ expect(sentUser).not.toHaveProperty('refreshToken');
+ expect(sentUser).not.toHaveProperty('googleId');
+ expect(sentUser).not.toHaveProperty('openidId');
+ expect(sentUser).not.toHaveProperty('openidIssuer');
+ expect(sentUser).not.toHaveProperty('idOnTheSource');
+ expect(sentUser).not.toHaveProperty('federatedTokens');
+ expect(sentUser).not.toHaveProperty('openidTokens');
+ expect(sentUser).not.toHaveProperty('tokenset');
+ expect(sentUser).not.toHaveProperty('safeLookingRuntimeField');
+ });
+});
+
+describe('acceptTermsController', () => {
+ const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns 404 when the user does not exist', async () => {
+ acceptTerms.mockResolvedValueOnce(null);
+
+ await acceptTermsController({ user: { id: 'missing-user' } }, mockRes);
+
+ expect(acceptTerms).toHaveBeenCalledWith('missing-user');
+ expect(mockRes.status).toHaveBeenCalledWith(404);
+ expect(mockRes.json).toHaveBeenCalledWith({ message: 'User not found' });
+ });
+
+ it('returns the recorded acceptance timestamp on success', async () => {
+ const acceptedAt = new Date('2026-06-14T10:00:00.000Z');
+ acceptTerms.mockResolvedValueOnce({ termsAccepted: true, termsAcceptedAt: acceptedAt });
+
+ await acceptTermsController({ user: { id: 'user-id' } }, mockRes);
+
+ expect(acceptTerms).toHaveBeenCalledWith('user-id');
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: 'Terms accepted successfully',
+ termsAcceptedAt: acceptedAt,
+ });
+ });
+
+ it('returns 500 when the update throws', async () => {
+ acceptTerms.mockRejectedValueOnce(new Error('db down'));
+
+ await acceptTermsController({ user: { id: 'user-id' } }, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(500);
+ expect(mockRes.json).toHaveBeenCalledWith({ message: 'Error accepting terms' });
+ });
+});
+
+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);
+ });
+});
diff --git a/api/server/controllers/__tests__/PermissionsController.spec.js b/api/server/controllers/__tests__/PermissionsController.spec.js
new file mode 100644
index 0000000..5976f9b
--- /dev/null
+++ b/api/server/controllers/__tests__/PermissionsController.spec.js
@@ -0,0 +1,426 @@
+const mongoose = require('mongoose');
+
+const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() };
+const mockGetTenantId = jest.fn();
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: mockLogger,
+ getTenantId: mockGetTenantId,
+ SYSTEM_TENANT_ID: '__SYSTEM__',
+}));
+
+const { AccessRoleIds, 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', () => ({
+ aggregateAclEntries: jest.fn(),
+ 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 db = require('~/models');
+const {
+ updateResourcePermissions,
+ searchPrincipals,
+ getResourcePermissions,
+} = 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();
+ mockGetTenantId.mockReturnValue(undefined);
+ });
+
+ describe('searchPrincipals', () => {
+ beforeEach(() => {
+ db.searchPrincipals.mockResolvedValue([]);
+ db.calculateRelevanceScore.mockReturnValue(50);
+ db.sortPrincipalsByRelevance.mockImplementation((results) => results);
+ });
+
+ it('rejects non-string query parameters', async () => {
+ const req = createMockReq({
+ query: { q: ['alice'] },
+ });
+ const res = createMockRes();
+
+ await searchPrincipals(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Query parameter "q" is required and must not be empty',
+ });
+ expect(db.searchPrincipals).not.toHaveBeenCalled();
+ });
+
+ it('searches with the trimmed literal query', async () => {
+ db.searchPrincipals.mockResolvedValue([
+ {
+ id: 'user-1',
+ type: PrincipalType.USER,
+ name: 'Regex [invalid User',
+ source: 'local',
+ },
+ ]);
+
+ const req = createMockReq({
+ query: { q: ' [invalid ', limit: '5', types: PrincipalType.USER },
+ });
+ const res = createMockRes();
+
+ await searchPrincipals(req, res);
+
+ expect(db.searchPrincipals).toHaveBeenCalledWith('[invalid', 5, [PrincipalType.USER]);
+ expect(db.calculateRelevanceScore).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'Regex [invalid User' }),
+ '[invalid',
+ );
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ query: '[invalid',
+ limit: 5,
+ count: 1,
+ }),
+ );
+ });
+
+ it('does not expose internal error details on search failures', async () => {
+ db.searchPrincipals.mockRejectedValue(new Error('database failure with internal detail'));
+
+ const req = createMockReq({
+ query: { q: 'alice' },
+ });
+ const res = createMockRes();
+
+ await searchPrincipals(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(500);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Failed to search principals',
+ });
+ });
+ });
+
+ describe('getResourcePermissions — principal details', () => {
+ const currentTenantId = 'tenant-a';
+ const otherTenantId = 'tenant-b';
+ const userId = new mongoose.Types.ObjectId();
+ const groupId = new mongoose.Types.ObjectId();
+
+ it('omits joined user and group details outside the current request context', async () => {
+ mockGetTenantId.mockReturnValue(currentTenantId);
+ db.aggregateAclEntries.mockResolvedValue([
+ {
+ principalType: PrincipalType.USER,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ userInfo: {
+ _id: userId,
+ tenantId: otherTenantId,
+ name: 'Outside User',
+ email: 'outside-user@example.com',
+ avatar: 'outside-user.png',
+ },
+ },
+ {
+ principalType: PrincipalType.GROUP,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ groupInfo: {
+ _id: groupId,
+ tenantId: otherTenantId,
+ name: 'Outside Group',
+ email: 'outside-group@example.com',
+ avatar: 'outside-group.png',
+ },
+ },
+ {
+ principalType: PrincipalType.PUBLIC,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ },
+ ]);
+
+ const req = createMockReq();
+ const res = createMockRes();
+
+ await getResourcePermissions(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith({
+ resourceType: ResourceType.AGENT,
+ resourceId: req.params.resourceId,
+ principals: [],
+ public: true,
+ publicAccessRoleId: AccessRoleIds.AGENT_VIEWER,
+ });
+ expect(JSON.stringify(res.json.mock.calls[0][0])).not.toContain('outside-user@example.com');
+ expect(JSON.stringify(res.json.mock.calls[0][0])).not.toContain('outside-group@example.com');
+ });
+
+ it('includes joined user and group details in the current request context', async () => {
+ mockGetTenantId.mockReturnValue(currentTenantId);
+ db.aggregateAclEntries.mockResolvedValue([
+ {
+ principalType: PrincipalType.USER,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ userInfo: {
+ _id: userId,
+ tenantId: currentTenantId,
+ name: 'Current User',
+ email: 'current-user@example.com',
+ avatar: 'current-user.png',
+ },
+ },
+ {
+ principalType: PrincipalType.GROUP,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ groupInfo: {
+ _id: groupId,
+ tenantId: currentTenantId,
+ name: 'Current Group',
+ email: 'current-group@example.com',
+ avatar: 'current-group.png',
+ },
+ },
+ ]);
+
+ const req = createMockReq();
+ const res = createMockRes();
+
+ await getResourcePermissions(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json.mock.calls[0][0].principals).toEqual([
+ expect.objectContaining({
+ type: PrincipalType.USER,
+ id: userId.toString(),
+ email: 'current-user@example.com',
+ }),
+ expect.objectContaining({
+ type: PrincipalType.GROUP,
+ id: groupId.toString(),
+ email: 'current-group@example.com',
+ }),
+ ]);
+ });
+ });
+
+ 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),
+ );
+ });
+ });
+});
diff --git a/api/server/controllers/__tests__/TwoFactorController.spec.js b/api/server/controllers/__tests__/TwoFactorController.spec.js
new file mode 100644
index 0000000..62531d9
--- /dev/null
+++ b/api/server/controllers/__tests__/TwoFactorController.spec.js
@@ -0,0 +1,264 @@
+const mockGetUserById = jest.fn();
+const mockUpdateUser = jest.fn();
+const mockVerifyOTPOrBackupCode = jest.fn();
+const mockGenerateTOTPSecret = jest.fn();
+const mockGenerateBackupCodes = jest.fn();
+const mockEncryptV3 = jest.fn();
+
+jest.mock('@librechat/data-schemas', () => ({
+ encryptV3: (...args) => mockEncryptV3(...args),
+ logger: { error: jest.fn() },
+}));
+
+jest.mock('~/server/services/twoFactorService', () => ({
+ verifyOTPOrBackupCode: (...args) => mockVerifyOTPOrBackupCode(...args),
+ generateBackupCodes: (...args) => mockGenerateBackupCodes(...args),
+ generateTOTPSecret: (...args) => mockGenerateTOTPSecret(...args),
+ verifyBackupCode: jest.fn(),
+ getTOTPSecret: jest.fn(),
+ verifyTOTP: jest.fn(),
+}));
+
+jest.mock('~/models', () => ({
+ getUserById: (...args) => mockGetUserById(...args),
+ updateUser: (...args) => mockUpdateUser(...args),
+}));
+
+const { enable2FA, regenerateBackupCodes } = require('~/server/controllers/TwoFactorController');
+
+function createRes() {
+ const res = {};
+ res.status = jest.fn().mockReturnValue(res);
+ res.json = jest.fn().mockReturnValue(res);
+ return res;
+}
+
+const PLAIN_CODES = ['code1', 'code2', 'code3'];
+const CODE_OBJECTS = [
+ { codeHash: 'h1', used: false, usedAt: null },
+ { codeHash: 'h2', used: false, usedAt: null },
+ { codeHash: 'h3', used: false, usedAt: null },
+];
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockGenerateTOTPSecret.mockReturnValue('NEWSECRET');
+ mockGenerateBackupCodes.mockResolvedValue({ plainCodes: PLAIN_CODES, codeObjects: CODE_OBJECTS });
+ mockEncryptV3.mockReturnValue('encrypted-secret');
+});
+
+describe('enable2FA', () => {
+ it('allows first-time setup without token — writes to pending fields', async () => {
+ const req = { user: { id: 'user1' }, body: {} };
+ const res = createRes();
+ mockGetUserById.mockResolvedValue({ _id: 'user1', twoFactorEnabled: false, email: 'a@b.com' });
+ mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
+
+ await enable2FA(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({ otpauthUrl: expect.any(String), backupCodes: PLAIN_CODES }),
+ );
+ expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
+ const updateCall = mockUpdateUser.mock.calls[0][1];
+ expect(updateCall).toHaveProperty('pendingTotpSecret', 'encrypted-secret');
+ expect(updateCall).toHaveProperty('pendingBackupCodes', CODE_OBJECTS);
+ expect(updateCall).not.toHaveProperty('twoFactorEnabled');
+ expect(updateCall).not.toHaveProperty('totpSecret');
+ expect(updateCall).not.toHaveProperty('backupCodes');
+ });
+
+ it('re-enrollment writes to pending fields, leaving live 2FA intact', async () => {
+ const req = { user: { id: 'user1' }, body: { token: '123456' } };
+ const res = createRes();
+ const existingUser = {
+ _id: 'user1',
+ twoFactorEnabled: true,
+ totpSecret: 'enc-secret',
+ email: 'a@b.com',
+ };
+ mockGetUserById.mockResolvedValue(existingUser);
+ mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
+ mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
+
+ await enable2FA(req, res);
+
+ expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
+ user: existingUser,
+ token: '123456',
+ backupCode: undefined,
+ persistBackupUse: false,
+ });
+ expect(res.status).toHaveBeenCalledWith(200);
+ const updateCall = mockUpdateUser.mock.calls[0][1];
+ expect(updateCall).toHaveProperty('pendingTotpSecret', 'encrypted-secret');
+ expect(updateCall).toHaveProperty('pendingBackupCodes', CODE_OBJECTS);
+ expect(updateCall).not.toHaveProperty('twoFactorEnabled');
+ expect(updateCall).not.toHaveProperty('totpSecret');
+ });
+
+ it('allows re-enrollment with valid backup code (persistBackupUse: false)', async () => {
+ const req = { user: { id: 'user1' }, body: { backupCode: 'backup123' } };
+ const res = createRes();
+ const existingUser = {
+ _id: 'user1',
+ twoFactorEnabled: true,
+ totpSecret: 'enc-secret',
+ email: 'a@b.com',
+ };
+ mockGetUserById.mockResolvedValue(existingUser);
+ mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
+ mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
+
+ await enable2FA(req, res);
+
+ expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith(
+ expect.objectContaining({ persistBackupUse: false }),
+ );
+ expect(res.status).toHaveBeenCalledWith(200);
+ });
+
+ it('returns error when no token provided and 2FA is enabled', async () => {
+ const req = { user: { id: 'user1' }, body: {} };
+ const res = createRes();
+ mockGetUserById.mockResolvedValue({
+ _id: 'user1',
+ twoFactorEnabled: true,
+ totpSecret: 'enc-secret',
+ });
+ mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
+
+ await enable2FA(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(mockUpdateUser).not.toHaveBeenCalled();
+ });
+
+ it('returns 401 when invalid token provided and 2FA is enabled', async () => {
+ const req = { user: { id: 'user1' }, body: { token: 'wrong' } };
+ const res = createRes();
+ mockGetUserById.mockResolvedValue({
+ _id: 'user1',
+ twoFactorEnabled: true,
+ totpSecret: 'enc-secret',
+ });
+ mockVerifyOTPOrBackupCode.mockResolvedValue({
+ verified: false,
+ status: 401,
+ message: 'Invalid token or backup code',
+ });
+
+ await enable2FA(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
+ expect(mockUpdateUser).not.toHaveBeenCalled();
+ });
+});
+
+describe('regenerateBackupCodes', () => {
+ it('returns 404 when user not found', async () => {
+ const req = { user: { id: 'user1' }, body: {} };
+ const res = createRes();
+ mockGetUserById.mockResolvedValue(null);
+
+ await regenerateBackupCodes(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({ message: 'User not found' });
+ });
+
+ it('requires OTP when 2FA is enabled', async () => {
+ const req = { user: { id: 'user1' }, body: { token: '123456' } };
+ const res = createRes();
+ mockGetUserById.mockResolvedValue({
+ _id: 'user1',
+ twoFactorEnabled: true,
+ totpSecret: 'enc-secret',
+ });
+ mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
+ mockUpdateUser.mockResolvedValue({});
+
+ await regenerateBackupCodes(req, res);
+
+ expect(mockVerifyOTPOrBackupCode).toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith({
+ backupCodes: PLAIN_CODES,
+ backupCodesHash: CODE_OBJECTS,
+ });
+ });
+
+ it('returns error when no token provided and 2FA is enabled', async () => {
+ const req = { user: { id: 'user1' }, body: {} };
+ const res = createRes();
+ mockGetUserById.mockResolvedValue({
+ _id: 'user1',
+ twoFactorEnabled: true,
+ totpSecret: 'enc-secret',
+ });
+ mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
+
+ await regenerateBackupCodes(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(400);
+ });
+
+ it('returns 401 when invalid token provided and 2FA is enabled', async () => {
+ const req = { user: { id: 'user1' }, body: { token: 'wrong' } };
+ const res = createRes();
+ mockGetUserById.mockResolvedValue({
+ _id: 'user1',
+ twoFactorEnabled: true,
+ totpSecret: 'enc-secret',
+ });
+ mockVerifyOTPOrBackupCode.mockResolvedValue({
+ verified: false,
+ status: 401,
+ message: 'Invalid token or backup code',
+ });
+
+ await regenerateBackupCodes(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
+ });
+
+ it('includes backupCodesHash in response', async () => {
+ const req = { user: { id: 'user1' }, body: { token: '123456' } };
+ const res = createRes();
+ mockGetUserById.mockResolvedValue({
+ _id: 'user1',
+ twoFactorEnabled: true,
+ totpSecret: 'enc-secret',
+ });
+ mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
+ mockUpdateUser.mockResolvedValue({});
+
+ await regenerateBackupCodes(req, res);
+
+ const responseBody = res.json.mock.calls[0][0];
+ expect(responseBody).toHaveProperty('backupCodesHash', CODE_OBJECTS);
+ expect(responseBody).toHaveProperty('backupCodes', PLAIN_CODES);
+ });
+
+ it('allows regeneration without token when 2FA is not enabled', async () => {
+ const req = { user: { id: 'user1' }, body: {} };
+ const res = createRes();
+ mockGetUserById.mockResolvedValue({
+ _id: 'user1',
+ twoFactorEnabled: false,
+ });
+ mockUpdateUser.mockResolvedValue({});
+
+ await regenerateBackupCodes(req, res);
+
+ expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith({
+ backupCodes: PLAIN_CODES,
+ backupCodesHash: CODE_OBJECTS,
+ });
+ });
+});
diff --git a/api/server/controllers/__tests__/UserController.mcpOAuth.spec.js b/api/server/controllers/__tests__/UserController.mcpOAuth.spec.js
new file mode 100644
index 0000000..d8c43b5
--- /dev/null
+++ b/api/server/controllers/__tests__/UserController.mcpOAuth.spec.js
@@ -0,0 +1,467 @@
+const mockUpdateUserPlugins = jest.fn();
+const mockFindToken = jest.fn();
+const mockDeleteUserPluginAuth = jest.fn();
+const mockGetAppConfig = jest.fn();
+const mockInvalidateCachedTools = jest.fn();
+const mockGetLogStores = jest.fn();
+const mockGetMCPManager = jest.fn();
+const mockGetFlowStateManager = jest.fn();
+const mockGetMCPServersRegistry = jest.fn();
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
+ getTenantId: jest.fn(),
+ webSearchKeys: [],
+}));
+
+jest.mock('librechat-data-provider', () => ({
+ Tools: {},
+ CacheKeys: { FLOWS: 'flows' },
+ Constants: { mcp_delimiter: '_mcp_', mcp_prefix: 'mcp_' },
+ FileSources: {},
+}));
+
+jest.mock('@librechat/api', () => ({
+ MCPOAuthHandler: {
+ generateFlowId: jest.fn((userId, serverName, tenantId) => {
+ const flowId = `${userId}:${serverName}`;
+ return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
+ }),
+ generateTokenFlowId: jest.fn((userId, serverName, tenantId) => {
+ const flowId = `${userId}:${serverName}`;
+ return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
+ }),
+ revokeOAuthToken: jest.fn(),
+ },
+ MCPTokenStorage: {
+ getClientInfoAndMetadata: jest.fn(),
+ getTokens: jest.fn(),
+ deleteUserTokens: jest.fn().mockResolvedValue(undefined),
+ },
+ normalizeHttpError: jest.fn((error) => error),
+ extractWebSearchEnvVars: jest.fn((params) => params.keys),
+ getAppConfigOptionsFromUser: jest.fn((user) => {
+ const hasSourceIdentity =
+ user != null && Object.prototype.hasOwnProperty.call(user, 'idOnTheSource');
+ return {
+ role: user?.role,
+ userId: user?.id,
+ idOnTheSource: user?.id && hasSourceIdentity ? (user.idOnTheSource ?? null) : undefined,
+ tenantId: user?.tenantId,
+ };
+ }),
+ needsRefresh: jest.fn(),
+ getNewS3URL: jest.fn(),
+}));
+
+jest.mock('~/models', () => ({
+ updateUserPlugins: (...args) => mockUpdateUserPlugins(...args),
+ findToken: mockFindToken,
+ deleteTokens: jest.fn(),
+}));
+
+jest.mock('~/server/services/PluginService', () => ({
+ updateUserPluginAuth: jest.fn(),
+ deleteUserPluginAuth: (...args) => mockDeleteUserPluginAuth(...args),
+}));
+
+jest.mock('~/server/services/twoFactorService', () => ({
+ verifyOTPOrBackupCode: jest.fn(),
+}));
+
+jest.mock('~/server/services/AuthService', () => ({
+ verifyEmail: jest.fn(),
+ resendVerificationEmail: jest.fn(),
+}));
+
+jest.mock('~/config', () => ({
+ getMCPManager: (...args) => mockGetMCPManager(...args),
+ getFlowStateManager: (...args) => mockGetFlowStateManager(...args),
+ getMCPServersRegistry: (...args) => mockGetMCPServersRegistry(...args),
+}));
+
+jest.mock('~/server/services/Config/getCachedTools', () => ({
+ invalidateCachedTools: (...args) => mockInvalidateCachedTools(...args),
+}));
+
+jest.mock('~/server/services/Files/process', () => ({
+ processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: (...args) => mockGetAppConfig(...args),
+}));
+
+jest.mock('~/cache', () => ({
+ getLogStores: (...args) => mockGetLogStores(...args),
+}));
+
+const { logger, getTenantId } = require('@librechat/data-schemas');
+const { MCPTokenStorage, MCPOAuthHandler } = require('@librechat/api');
+const { updateUserPluginsController } = require('~/server/controllers/UserController');
+
+function createResponse() {
+ const res = {};
+ res.status = jest.fn().mockReturnValue(res);
+ res.json = jest.fn().mockReturnValue(res);
+ res.send = jest.fn().mockReturnValue(res);
+ return res;
+}
+
+function createRequest() {
+ return {
+ user: {
+ id: 'user-1',
+ _id: 'user-1',
+ plugins: [],
+ role: 'USER',
+ },
+ body: {
+ pluginKey: 'mcp_test-server',
+ action: 'uninstall',
+ auth: {},
+ },
+ };
+}
+
+function setupMCPMocks() {
+ const flowManager = {
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const mcpManager = {
+ disconnectUserConnection: jest.fn().mockResolvedValue(),
+ };
+ const registry = {
+ getServerConfig: jest.fn().mockResolvedValue({
+ url: 'https://example.com/mcp',
+ oauth: {},
+ oauth_headers: {},
+ }),
+ getOAuthServers: jest.fn().mockResolvedValue(new Set(['test-server'])),
+ getAllowedDomains: jest.fn().mockReturnValue([]),
+ getAllowedAddresses: jest.fn().mockReturnValue(null),
+ };
+
+ // Revocation reads the merged config's mcpSettings allowlists (not the registry getters).
+ mockGetAppConfig.mockResolvedValue({
+ mcpSettings: { allowedDomains: [], allowedAddresses: null },
+ });
+ mockUpdateUserPlugins.mockResolvedValue();
+ mockDeleteUserPluginAuth.mockResolvedValue();
+ mockInvalidateCachedTools.mockResolvedValue();
+ mockGetLogStores.mockReturnValue({});
+ mockGetFlowStateManager.mockReturnValue(flowManager);
+ mockGetMCPManager.mockReturnValue(mcpManager);
+ mockGetMCPServersRegistry.mockReturnValue(registry);
+
+ return { flowManager, mcpManager, registry };
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ getTenantId.mockReturnValue(undefined);
+});
+
+describe('updateUserPluginsController MCP OAuth cleanup', () => {
+ it('clears stored OAuth token state when client metadata is missing', async () => {
+ const { flowManager, mcpManager } = setupMCPMocks();
+ MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
+
+ const res = createResponse();
+ await updateUserPluginsController(createRequest(), res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(MCPTokenStorage.getClientInfoAndMetadata).toHaveBeenCalledWith({
+ userId: 'user-1',
+ serverName: 'test-server',
+ findToken: mockFindToken,
+ });
+ expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
+ userId: 'user-1',
+ serverName: 'test-server',
+ deleteToken: expect.any(Function),
+ });
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
+ expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
+ expect(mcpManager.disconnectUserConnection).toHaveBeenCalledWith('user-1', 'test-server');
+ });
+
+ it('still clears OAuth flow state when stored token deletion fails', async () => {
+ const { flowManager } = setupMCPMocks();
+ const cleanupError = new Error('DB down');
+ MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
+ MCPTokenStorage.deleteUserTokens.mockRejectedValueOnce(cleanupError);
+
+ const res = createResponse();
+ await updateUserPluginsController(createRequest(), res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
+ expect(logger.warn).toHaveBeenCalledWith(
+ '[clearStoredMCPOAuthState] Failed to delete MCP OAuth tokens for test-server:',
+ cleanupError,
+ );
+ });
+
+ it('logs all flow cleanup failures without failing MCP OAuth cleanup', async () => {
+ const { flowManager } = setupMCPMocks();
+ const getTokensFlowError = new Error('get tokens flow cache down');
+ const oauthFlowError = new Error('oauth flow cache down');
+ MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
+ flowManager.deleteFlow
+ .mockRejectedValueOnce(getTokensFlowError)
+ .mockRejectedValueOnce(oauthFlowError);
+
+ const res = createResponse();
+ await updateUserPluginsController(createRequest(), res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
+ expect(logger.warn).toHaveBeenCalledWith(
+ '[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for test-server:',
+ getTokensFlowError,
+ );
+ expect(logger.warn).toHaveBeenCalledWith(
+ '[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for test-server:',
+ oauthFlowError,
+ );
+ });
+
+ it('clears stored OAuth token state when client metadata cannot be loaded', async () => {
+ const { flowManager } = setupMCPMocks();
+ MCPTokenStorage.getClientInfoAndMetadata.mockRejectedValue(new Error('invalid client info'));
+
+ const res = createResponse();
+ await updateUserPluginsController(createRequest(), res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(logger.warn).toHaveBeenCalledWith(
+ '[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for test-server; clearing local MCP OAuth state only.',
+ expect.any(Error),
+ );
+ expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
+ userId: 'user-1',
+ serverName: 'test-server',
+ deleteToken: expect.any(Function),
+ });
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
+ expect(MCPTokenStorage.getTokens).not.toHaveBeenCalled();
+ expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
+ });
+
+ it('clears tenant-scoped and legacy OAuth flow state when tenant context exists', async () => {
+ const { flowManager } = setupMCPMocks();
+ getTenantId.mockReturnValue('tenant-a');
+ MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
+
+ const res = createResponse();
+ await updateUserPluginsController(createRequest(), res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith(
+ 'tenant:tenant-a:user-1:test-server',
+ 'mcp_get_tokens',
+ );
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith(
+ 'tenant:tenant-a:user-1:test-server',
+ 'mcp_oauth',
+ );
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
+ });
+
+ it('clears stored OAuth token state when server config is missing', async () => {
+ const { flowManager, registry } = setupMCPMocks();
+ registry.getServerConfig.mockResolvedValue(undefined);
+
+ const res = createResponse();
+ await updateUserPluginsController(createRequest(), res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
+ userId: 'user-1',
+ serverName: 'test-server',
+ deleteToken: expect.any(Function),
+ });
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
+ expect(MCPTokenStorage.getClientInfoAndMetadata).not.toHaveBeenCalled();
+ expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
+ });
+
+ it('clears stored OAuth token state when server no longer requires OAuth', async () => {
+ const { flowManager, registry } = setupMCPMocks();
+ registry.getOAuthServers.mockResolvedValue(new Set());
+
+ const res = createResponse();
+ await updateUserPluginsController(createRequest(), res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
+ userId: 'user-1',
+ serverName: 'test-server',
+ deleteToken: expect.any(Function),
+ });
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
+ expect(MCPTokenStorage.getClientInfoAndMetadata).not.toHaveBeenCalled();
+ expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
+ });
+
+ it('clears stored OAuth token state when token loading fails before provider revocation', async () => {
+ const { flowManager } = setupMCPMocks();
+ MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({
+ clientInfo: { client_id: 'client-1' },
+ clientMetadata: {},
+ });
+ MCPTokenStorage.getTokens.mockRejectedValue(new Error('token lookup failed'));
+
+ const res = createResponse();
+ await updateUserPluginsController(createRequest(), res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(MCPTokenStorage.getTokens).toHaveBeenCalledWith({
+ userId: 'user-1',
+ serverName: 'test-server',
+ findToken: mockFindToken,
+ });
+ expect(logger.warn).toHaveBeenCalledWith(
+ '[maybeUninstallOAuthMCP] Unable to load OAuth tokens for test-server; clearing local token state.',
+ expect.any(Error),
+ );
+ expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
+ userId: 'user-1',
+ serverName: 'test-server',
+ deleteToken: expect.any(Function),
+ });
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
+ expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
+ expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
+ });
+
+ it('revokes provider tokens before clearing local token state when token data is available', async () => {
+ setupMCPMocks();
+ MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({
+ clientInfo: { client_id: 'client-1', client_secret: 'secret-1' },
+ clientMetadata: { revocation_endpoint: 'https://example.com/revoke' },
+ });
+ MCPTokenStorage.getTokens.mockResolvedValue({
+ access_token: 'access-token',
+ refresh_token: 'refresh-token',
+ });
+ MCPOAuthHandler.revokeOAuthToken.mockResolvedValue();
+
+ const res = createResponse();
+ await updateUserPluginsController(createRequest(), res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(MCPTokenStorage.getTokens).toHaveBeenCalledWith({
+ userId: 'user-1',
+ serverName: 'test-server',
+ findToken: mockFindToken,
+ });
+ expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith(
+ 'test-server',
+ 'access-token',
+ 'access',
+ {
+ serverUrl: 'https://example.com/mcp',
+ clientId: 'client-1',
+ clientSecret: 'secret-1',
+ revocationEndpoint: 'https://example.com/revoke',
+ revocationEndpointAuthMethodsSupported: undefined,
+ },
+ {},
+ [],
+ null,
+ );
+ expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith(
+ 'test-server',
+ 'refresh-token',
+ 'refresh',
+ {
+ serverUrl: 'https://example.com/mcp',
+ clientId: 'client-1',
+ clientSecret: 'secret-1',
+ revocationEndpoint: 'https://example.com/revoke',
+ revocationEndpointAuthMethodsSupported: undefined,
+ },
+ {},
+ [],
+ null,
+ );
+ expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
+ userId: 'user-1',
+ serverName: 'test-server',
+ deleteToken: expect.any(Function),
+ });
+ });
+
+ it('revokes only the access token when refresh token data is absent', async () => {
+ setupMCPMocks();
+ MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({
+ clientInfo: { client_id: 'client-1', client_secret: 'secret-1' },
+ clientMetadata: {},
+ });
+ MCPTokenStorage.getTokens.mockResolvedValue({
+ access_token: 'access-token',
+ });
+ MCPOAuthHandler.revokeOAuthToken.mockResolvedValue();
+
+ const res = createResponse();
+ await updateUserPluginsController(createRequest(), res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledTimes(1);
+ expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith(
+ 'test-server',
+ 'access-token',
+ 'access',
+ expect.objectContaining({ clientId: 'client-1' }),
+ {},
+ [],
+ null,
+ );
+ expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
+ userId: 'user-1',
+ serverName: 'test-server',
+ deleteToken: expect.any(Function),
+ });
+ });
+
+ it('revokes only the refresh token when access token data is absent', async () => {
+ setupMCPMocks();
+ MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({
+ clientInfo: { client_id: 'client-1', client_secret: 'secret-1' },
+ clientMetadata: {},
+ });
+ MCPTokenStorage.getTokens.mockResolvedValue({
+ refresh_token: 'refresh-token',
+ });
+ MCPOAuthHandler.revokeOAuthToken.mockResolvedValue();
+
+ const res = createResponse();
+ await updateUserPluginsController(createRequest(), res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledTimes(1);
+ expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith(
+ 'test-server',
+ 'refresh-token',
+ 'refresh',
+ expect.objectContaining({ clientId: 'client-1' }),
+ {},
+ [],
+ null,
+ );
+ expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
+ userId: 'user-1',
+ serverName: 'test-server',
+ deleteToken: expect.any(Function),
+ });
+ });
+});
diff --git a/api/server/controllers/__tests__/deleteUser.spec.js b/api/server/controllers/__tests__/deleteUser.spec.js
new file mode 100644
index 0000000..6198122
--- /dev/null
+++ b/api/server/controllers/__tests__/deleteUser.spec.js
@@ -0,0 +1,296 @@
+const mockGetUserById = jest.fn();
+const mockDeleteMessages = jest.fn();
+const mockDeleteAllUserSessions = jest.fn();
+const mockDeleteUserById = jest.fn();
+const mockDeleteAllSharedLinks = jest.fn();
+const mockDeleteAllSharedLinksWithCleanup = jest.fn();
+const mockDeletePresets = jest.fn();
+const mockDeleteUserKey = jest.fn();
+const mockDeleteConvos = jest.fn();
+const mockDeleteFiles = jest.fn();
+const mockGetFiles = jest.fn();
+const mockUpdateUserPlugins = jest.fn();
+const mockUpdateUser = jest.fn();
+const mockFindToken = jest.fn();
+const mockVerifyOTPOrBackupCode = jest.fn();
+const mockDeleteUserPluginAuth = jest.fn();
+const mockProcessDeleteRequest = jest.fn();
+const mockDeleteToolCalls = jest.fn();
+const mockDeleteUserAgents = jest.fn();
+const mockDeleteUserPrompts = jest.fn();
+const mockDeleteUserSkills = jest.fn();
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: { error: jest.fn(), info: jest.fn() },
+ webSearchKeys: [],
+}));
+
+jest.mock('librechat-data-provider', () => ({
+ Tools: {},
+ CacheKeys: {},
+ Constants: { mcp_delimiter: '::', mcp_prefix: 'mcp_' },
+ FileSources: {},
+}));
+
+jest.mock('@librechat/api', () => ({
+ MCPOAuthHandler: {},
+ MCPTokenStorage: {},
+ normalizeHttpError: jest.fn(),
+ extractWebSearchEnvVars: jest.fn(),
+ needsRefresh: jest.fn(),
+ getNewS3URL: jest.fn(),
+ deleteAllSharedLinksWithCleanup: (...args) => mockDeleteAllSharedLinksWithCleanup(...args),
+}));
+
+jest.mock('~/models', () => ({
+ deleteAllUserSessions: (...args) => mockDeleteAllUserSessions(...args),
+ deleteAllSharedLinks: (...args) => mockDeleteAllSharedLinks(...args),
+ updateUserPlugins: (...args) => mockUpdateUserPlugins(...args),
+ deleteUserById: (...args) => mockDeleteUserById(...args),
+ deleteMessages: (...args) => mockDeleteMessages(...args),
+ deletePresets: (...args) => mockDeletePresets(...args),
+ deleteUserKey: (...args) => mockDeleteUserKey(...args),
+ getUserById: (...args) => mockGetUserById(...args),
+ deleteConvos: (...args) => mockDeleteConvos(...args),
+ deleteFiles: (...args) => mockDeleteFiles(...args),
+ updateUser: (...args) => mockUpdateUser(...args),
+ findToken: (...args) => mockFindToken(...args),
+ getFiles: (...args) => mockGetFiles(...args),
+ deleteToolCalls: (...args) => mockDeleteToolCalls(...args),
+ deleteUserAgents: (...args) => mockDeleteUserAgents(...args),
+ deleteUserPrompts: (...args) => mockDeleteUserPrompts(...args),
+ deleteUserSkills: (...args) => mockDeleteUserSkills(...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', () => ({
+ updateUserPluginAuth: jest.fn(),
+ deleteUserPluginAuth: (...args) => mockDeleteUserPluginAuth(...args),
+}));
+
+jest.mock('~/server/services/twoFactorService', () => ({
+ verifyOTPOrBackupCode: (...args) => mockVerifyOTPOrBackupCode(...args),
+}));
+
+jest.mock('~/server/services/AuthService', () => ({
+ verifyEmail: jest.fn(),
+ resendVerificationEmail: jest.fn(),
+}));
+
+jest.mock('~/config', () => ({
+ getMCPManager: jest.fn(),
+ getFlowStateManager: jest.fn(),
+ getMCPServersRegistry: jest.fn(),
+}));
+
+jest.mock('~/server/services/Config/getCachedTools', () => ({
+ invalidateCachedTools: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/process', () => ({
+ processDeleteRequest: (...args) => mockProcessDeleteRequest(...args),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn(),
+}));
+
+jest.mock('~/cache', () => ({
+ getLogStores: jest.fn(),
+}));
+
+const { deleteUserController } = require('~/server/controllers/UserController');
+
+function createRes() {
+ const res = {};
+ res.status = jest.fn().mockReturnValue(res);
+ res.json = jest.fn().mockReturnValue(res);
+ res.send = jest.fn().mockReturnValue(res);
+ return res;
+}
+
+function stubDeletionMocks() {
+ mockDeleteMessages.mockResolvedValue();
+ mockDeleteAllUserSessions.mockResolvedValue();
+ mockDeleteUserKey.mockResolvedValue();
+ mockDeletePresets.mockResolvedValue();
+ mockDeleteConvos.mockResolvedValue();
+ mockDeleteUserPluginAuth.mockResolvedValue();
+ mockDeleteUserById.mockResolvedValue();
+ mockDeleteAllSharedLinks.mockResolvedValue();
+ mockDeleteAllSharedLinksWithCleanup.mockResolvedValue({ deletedCount: 0 });
+ mockGetFiles.mockResolvedValue([]);
+ mockProcessDeleteRequest.mockResolvedValue({ deletedFileIds: [], failedFileIds: [] });
+ mockDeleteFiles.mockResolvedValue();
+ mockDeleteToolCalls.mockResolvedValue();
+ mockDeleteUserAgents.mockResolvedValue();
+ mockDeleteUserPrompts.mockResolvedValue();
+ mockDeleteUserSkills.mockResolvedValue(0);
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ stubDeletionMocks();
+});
+
+describe('deleteUserController - 2FA enforcement', () => {
+ it('proceeds with deletion when 2FA is not enabled', async () => {
+ const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: {} };
+ const res = createRes();
+ mockGetUserById.mockResolvedValue({ _id: 'user1', twoFactorEnabled: false });
+
+ await deleteUserController(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
+ expect(mockDeleteMessages).toHaveBeenCalled();
+ expect(mockDeleteUserAgents).toHaveBeenCalledWith('user1');
+ expect(mockDeleteUserPrompts).toHaveBeenCalledWith('user1');
+ expect(mockDeleteUserSkills).toHaveBeenCalledWith('user1');
+ expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
+ });
+
+ it('proceeds with deletion when user has no 2FA record', async () => {
+ const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: {} };
+ const res = createRes();
+ mockGetUserById.mockResolvedValue(null);
+
+ await deleteUserController(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
+ });
+
+ it('returns error when 2FA is enabled and verification fails with 400', async () => {
+ const req = { user: { id: 'user1', _id: 'user1' }, body: {} };
+ const res = createRes();
+ mockGetUserById.mockResolvedValue({
+ _id: 'user1',
+ twoFactorEnabled: true,
+ totpSecret: 'enc-secret',
+ });
+ mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
+
+ await deleteUserController(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(mockDeleteMessages).not.toHaveBeenCalled();
+ });
+
+ it('returns 401 when 2FA is enabled and invalid TOTP token provided', async () => {
+ const existingUser = {
+ _id: 'user1',
+ twoFactorEnabled: true,
+ totpSecret: 'enc-secret',
+ };
+ const req = { user: { id: 'user1', _id: 'user1' }, body: { token: 'wrong' } };
+ const res = createRes();
+ mockGetUserById.mockResolvedValue(existingUser);
+ mockVerifyOTPOrBackupCode.mockResolvedValue({
+ verified: false,
+ status: 401,
+ message: 'Invalid token or backup code',
+ });
+
+ await deleteUserController(req, res);
+
+ expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
+ user: existingUser,
+ token: 'wrong',
+ backupCode: undefined,
+ });
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
+ expect(mockDeleteMessages).not.toHaveBeenCalled();
+ });
+
+ it('returns 401 when 2FA is enabled and invalid backup code provided', async () => {
+ const existingUser = {
+ _id: 'user1',
+ twoFactorEnabled: true,
+ totpSecret: 'enc-secret',
+ backupCodes: [],
+ };
+ const req = { user: { id: 'user1', _id: 'user1' }, body: { backupCode: 'bad-code' } };
+ const res = createRes();
+ mockGetUserById.mockResolvedValue(existingUser);
+ mockVerifyOTPOrBackupCode.mockResolvedValue({
+ verified: false,
+ status: 401,
+ message: 'Invalid token or backup code',
+ });
+
+ await deleteUserController(req, res);
+
+ expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
+ user: existingUser,
+ token: undefined,
+ backupCode: 'bad-code',
+ });
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(mockDeleteMessages).not.toHaveBeenCalled();
+ });
+
+ it('deletes account when valid TOTP token provided with 2FA enabled', async () => {
+ const existingUser = {
+ _id: 'user1',
+ twoFactorEnabled: true,
+ totpSecret: 'enc-secret',
+ };
+ const req = {
+ user: { id: 'user1', _id: 'user1', email: 'a@b.com' },
+ body: { token: '123456' },
+ };
+ const res = createRes();
+ mockGetUserById.mockResolvedValue(existingUser);
+ mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
+
+ await deleteUserController(req, res);
+
+ expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
+ user: existingUser,
+ token: '123456',
+ backupCode: undefined,
+ });
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
+ expect(mockDeleteMessages).toHaveBeenCalled();
+ });
+
+ it('deletes account when valid backup code provided with 2FA enabled', async () => {
+ const existingUser = {
+ _id: 'user1',
+ twoFactorEnabled: true,
+ totpSecret: 'enc-secret',
+ backupCodes: [{ codeHash: 'h1', used: false }],
+ };
+ const req = {
+ user: { id: 'user1', _id: 'user1', email: 'a@b.com' },
+ body: { backupCode: 'valid-code' },
+ };
+ const res = createRes();
+ mockGetUserById.mockResolvedValue(existingUser);
+ mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
+
+ await deleteUserController(req, res);
+
+ expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
+ user: existingUser,
+ token: undefined,
+ backupCode: 'valid-code',
+ });
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
+ expect(mockDeleteMessages).toHaveBeenCalled();
+ });
+});
diff --git a/api/server/controllers/__tests__/deleteUserMcpServers.spec.js b/api/server/controllers/__tests__/deleteUserMcpServers.spec.js
new file mode 100644
index 0000000..fcb3211
--- /dev/null
+++ b/api/server/controllers/__tests__/deleteUserMcpServers.spec.js
@@ -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();
+ });
+});
diff --git a/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js b/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js
new file mode 100644
index 0000000..1bd5b2e
--- /dev/null
+++ b/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js
@@ -0,0 +1,55 @@
+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',
+ [ResourceType.SKILL]: 'deleteUserSkills',
+ [ResourceType.SHARED_LINK]: 'deleteAllSharedLinksWithCleanup',
+};
+
+/**
+ * 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);
+ }
+ });
+});
diff --git a/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js b/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js
new file mode 100644
index 0000000..1b84362
--- /dev/null
+++ b/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js
@@ -0,0 +1,324 @@
+const mockGetTokens = jest.fn();
+const mockDeleteUserTokens = jest.fn();
+const mockGetClientInfoAndMetadata = jest.fn();
+const mockRevokeOAuthToken = jest.fn();
+const mockGetServerConfig = jest.fn();
+const mockGetOAuthServers = jest.fn();
+const mockGetAllowedDomains = jest.fn();
+const mockGetAllowedAddresses = jest.fn();
+const mockDeleteFlow = jest.fn();
+const mockGetLogStores = jest.fn();
+const mockFindToken = jest.fn();
+const mockDeleteTokens = jest.fn();
+const mockLoggerInfo = jest.fn();
+const mockLoggerWarn = jest.fn();
+const mockLoggerError = jest.fn();
+const mockGetTenantId = jest.fn();
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: { info: mockLoggerInfo, warn: mockLoggerWarn, error: mockLoggerError },
+ getTenantId: (...args) => mockGetTenantId(...args),
+ webSearchKeys: [],
+}));
+
+jest.mock('@librechat/api', () => {
+ return {
+ MCPOAuthHandler: {
+ revokeOAuthToken: (...args) => mockRevokeOAuthToken(...args),
+ generateFlowId: (userId, serverName, tenantId) => {
+ const flowId = `${userId}:${serverName}`;
+ return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
+ },
+ generateTokenFlowId: (userId, serverName, tenantId) => {
+ const flowId = `${userId}:${serverName}`;
+ return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
+ },
+ },
+ MCPTokenStorage: {
+ getTokens: (...args) => mockGetTokens(...args),
+ getClientInfoAndMetadata: (...args) => mockGetClientInfoAndMetadata(...args),
+ deleteUserTokens: (...args) => mockDeleteUserTokens(...args),
+ },
+ normalizeHttpError: jest.fn(),
+ extractWebSearchEnvVars: jest.fn(),
+ needsRefresh: jest.fn(),
+ getNewS3URL: jest.fn(),
+ };
+});
+
+jest.mock('librechat-data-provider', () => ({
+ Tools: {},
+ CacheKeys: { FLOWS: 'flows' },
+ Constants: { mcp_delimiter: '::', mcp_prefix: 'mcp_' },
+ FileSources: {},
+ ResourceType: {},
+}));
+
+jest.mock('~/config', () => ({
+ getMCPManager: jest.fn(),
+ getFlowStateManager: jest.fn(() => ({
+ deleteFlow: (...args) => mockDeleteFlow(...args),
+ })),
+ getMCPServersRegistry: jest.fn(() => ({
+ getServerConfig: (...args) => mockGetServerConfig(...args),
+ getOAuthServers: (...args) => mockGetOAuthServers(...args),
+ getAllowedDomains: (...args) => mockGetAllowedDomains(...args),
+ getAllowedAddresses: (...args) => mockGetAllowedAddresses(...args),
+ })),
+}));
+
+jest.mock('~/cache', () => ({
+ getLogStores: (...args) => mockGetLogStores(...args),
+}));
+
+jest.mock('~/server/services/PluginService', () => ({
+ updateUserPluginAuth: jest.fn(),
+ deleteUserPluginAuth: jest.fn(),
+}));
+
+jest.mock('~/server/services/twoFactorService', () => ({
+ verifyOTPOrBackupCode: jest.fn(),
+}));
+
+jest.mock('~/server/services/AuthService', () => ({
+ verifyEmail: jest.fn(),
+ resendVerificationEmail: jest.fn(),
+}));
+
+jest.mock('~/server/services/Config/getCachedTools', () => ({
+ invalidateCachedTools: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/process', () => ({
+ processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn(),
+}));
+
+jest.mock('~/models', () => ({
+ findToken: (...args) => mockFindToken(...args),
+ deleteTokens: (...args) => mockDeleteTokens(...args),
+ updateUser: jest.fn(),
+ deleteAllUserSessions: jest.fn(),
+ deleteAllSharedLinks: jest.fn(),
+ updateUserPlugins: jest.fn(),
+ deleteUserById: jest.fn(),
+ deleteMessages: jest.fn(),
+ deletePresets: jest.fn(),
+ deleteUserKey: jest.fn(),
+ getUserById: jest.fn(),
+ deleteConvos: jest.fn(),
+ deleteFiles: jest.fn(),
+ getFiles: jest.fn(),
+ deleteToolCalls: jest.fn(),
+ deleteUserAgents: jest.fn(),
+ deleteUserPrompts: jest.fn(),
+ deleteTransactions: jest.fn(),
+ deleteBalances: jest.fn(),
+ deleteAllAgentApiKeys: jest.fn(),
+ deleteAssistants: jest.fn(),
+ deleteConversationTags: jest.fn(),
+ deleteAllUserMemories: jest.fn(),
+ deleteActions: jest.fn(),
+ removeUserFromAllGroups: jest.fn(),
+ deleteAclEntries: jest.fn(),
+ getSoleOwnedResourceIds: jest.fn().mockResolvedValue([]),
+}));
+
+const { maybeUninstallOAuthMCP } = require('~/server/controllers/UserController');
+
+const userId = 'user-123';
+const pluginKey = 'mcp_acme';
+const serverName = 'acme';
+
+const serverConfig = {
+ url: 'https://acme.example.com',
+ oauth: {
+ revocation_endpoint: 'https://acme.example.com/revoke',
+ revocation_endpoint_auth_methods_supported: ['client_secret_basic'],
+ },
+ oauth_headers: { 'X-Tenant': 'acme' },
+};
+
+const appConfig = {
+ mcpServers: { acme: serverConfig },
+};
+
+const clientInfo = { client_id: 'cid', client_secret: 'csec' };
+const clientMetadata = {};
+
+function setupOAuthServerFound() {
+ mockGetServerConfig.mockResolvedValue(serverConfig);
+ mockGetOAuthServers.mockResolvedValue(new Set([serverName]));
+ mockGetAllowedDomains.mockReturnValue(['https://acme.example.com']);
+ mockGetAllowedAddresses.mockReturnValue(null);
+ mockGetClientInfoAndMetadata.mockResolvedValue({ clientInfo, clientMetadata });
+}
+
+describe('maybeUninstallOAuthMCP', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetTenantId.mockReturnValue(undefined);
+ });
+
+ test('is a no-op when pluginKey is not an MCP key', async () => {
+ await maybeUninstallOAuthMCP(userId, 'plugin_google_calendar', appConfig);
+
+ expect(mockGetServerConfig).not.toHaveBeenCalled();
+ expect(mockGetTokens).not.toHaveBeenCalled();
+ expect(mockDeleteUserTokens).not.toHaveBeenCalled();
+ expect(mockDeleteFlow).not.toHaveBeenCalled();
+ });
+
+ test('clears stored state when the MCP server is not an OAuth server', async () => {
+ mockGetServerConfig.mockResolvedValue(serverConfig);
+ mockGetOAuthServers.mockResolvedValue(new Set(['other']));
+
+ await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
+
+ expect(mockGetClientInfoAndMetadata).not.toHaveBeenCalled();
+ expect(mockGetTokens).not.toHaveBeenCalled();
+ expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
+ expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
+ expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
+ });
+
+ test('clears stored state when client info is missing', async () => {
+ setupOAuthServerFound();
+ mockGetClientInfoAndMetadata.mockResolvedValue(null);
+
+ await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
+
+ expect(mockGetTokens).not.toHaveBeenCalled();
+ expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
+ expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
+ expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
+ });
+
+ test('clears stored state when client info cannot be loaded', async () => {
+ setupOAuthServerFound();
+ mockGetClientInfoAndMetadata.mockRejectedValue(new Error('bad client data'));
+ mockDeleteUserTokens.mockResolvedValue(undefined);
+ mockDeleteFlow.mockResolvedValue(undefined);
+
+ await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
+
+ expect(mockGetTokens).not.toHaveBeenCalled();
+ expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
+ expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
+ expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
+ expect(mockLoggerWarn).toHaveBeenCalledWith(
+ `[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`,
+ expect.any(Error),
+ );
+ });
+
+ test('clears tenant-scoped and legacy flow state when tenant context exists', async () => {
+ setupOAuthServerFound();
+ mockGetTenantId.mockReturnValue('tenant-a');
+ mockGetClientInfoAndMetadata.mockResolvedValue(null);
+
+ await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
+
+ expect(mockDeleteFlow).toHaveBeenCalledTimes(4);
+ expect(mockDeleteFlow).toHaveBeenCalledWith('tenant:tenant-a:user-123:acme', 'mcp_get_tokens');
+ expect(mockDeleteFlow).toHaveBeenCalledWith('tenant:tenant-a:user-123:acme', 'mcp_oauth');
+ expect(mockDeleteFlow).toHaveBeenCalledWith('user-123:acme', 'mcp_get_tokens');
+ expect(mockDeleteFlow).toHaveBeenCalledWith('user-123:acme', 'mcp_oauth');
+ });
+
+ test('revokes both tokens and runs cleanup on happy path', async () => {
+ setupOAuthServerFound();
+ mockGetTokens.mockResolvedValue({
+ access_token: 'access-abc',
+ refresh_token: 'refresh-xyz',
+ });
+ mockRevokeOAuthToken.mockResolvedValue(undefined);
+ mockDeleteUserTokens.mockResolvedValue(undefined);
+ mockDeleteFlow.mockResolvedValue(undefined);
+
+ await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
+
+ expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(2);
+ expect(mockRevokeOAuthToken.mock.calls[0][1]).toBe('access-abc');
+ expect(mockRevokeOAuthToken.mock.calls[0][2]).toBe('access');
+ expect(mockRevokeOAuthToken.mock.calls[1][1]).toBe('refresh-xyz');
+ expect(mockRevokeOAuthToken.mock.calls[1][2]).toBe('refresh');
+
+ expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
+ expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
+
+ expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
+ expect(mockDeleteFlow.mock.calls[0][1]).toBe('mcp_get_tokens');
+ expect(mockDeleteFlow.mock.calls[1][1]).toBe('mcp_oauth');
+ });
+
+ test('skips revocation but still runs cleanup when token retrieval fails', async () => {
+ setupOAuthServerFound();
+ mockGetTokens.mockRejectedValue(new Error('missing'));
+ mockDeleteUserTokens.mockResolvedValue(undefined);
+ mockDeleteFlow.mockResolvedValue(undefined);
+
+ await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();
+
+ expect(mockRevokeOAuthToken).not.toHaveBeenCalled();
+ expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
+ expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
+ expect(mockLoggerWarn).toHaveBeenCalledWith(
+ `[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`,
+ expect.any(Error),
+ );
+ });
+
+ test('skips revocation, logs warn, and still runs cleanup on unexpected token-retrieval error', async () => {
+ setupOAuthServerFound();
+ mockGetTokens.mockRejectedValue(new Error('boom: unreachable'));
+ mockDeleteUserTokens.mockResolvedValue(undefined);
+ mockDeleteFlow.mockResolvedValue(undefined);
+
+ await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();
+
+ expect(mockRevokeOAuthToken).not.toHaveBeenCalled();
+ expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
+ expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
+ expect(mockLoggerWarn).toHaveBeenCalledWith(
+ `[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`,
+ expect.any(Error),
+ );
+ });
+
+ test('continues cleanup when only one token type is present', async () => {
+ setupOAuthServerFound();
+ mockGetTokens.mockResolvedValue({ access_token: 'only-access' });
+ mockRevokeOAuthToken.mockResolvedValue(undefined);
+ mockDeleteUserTokens.mockResolvedValue(undefined);
+ mockDeleteFlow.mockResolvedValue(undefined);
+
+ await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
+
+ expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(1);
+ expect(mockRevokeOAuthToken.mock.calls[0][2]).toBe('access');
+ expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
+ expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
+ });
+
+ test('still runs cleanup even when both revocation calls fail', async () => {
+ setupOAuthServerFound();
+ mockGetTokens.mockResolvedValue({
+ access_token: 'a',
+ refresh_token: 'r',
+ });
+ mockRevokeOAuthToken.mockRejectedValue(new Error('network down'));
+ mockDeleteUserTokens.mockResolvedValue(undefined);
+ mockDeleteFlow.mockResolvedValue(undefined);
+
+ await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();
+
+ expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(2);
+ expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
+ expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
+ expect(mockLoggerError).toHaveBeenCalled();
+ });
+});
diff --git a/api/server/controllers/__tests__/mcp.servers.spec.js b/api/server/controllers/__tests__/mcp.servers.spec.js
new file mode 100644
index 0000000..e8f3f6a
--- /dev/null
+++ b/api/server/controllers/__tests__/mcp.servers.spec.js
@@ -0,0 +1,258 @@
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { SystemCapabilities } = require('@librechat/data-schemas');
+const {
+ SystemRoles,
+ ResourceType,
+ AccessRoleIds,
+ PrincipalType,
+} = require('librechat-data-provider');
+
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ getTransactionSupport: jest.fn().mockResolvedValue(false),
+}));
+
+jest.mock('~/server/services/GraphApiService', () => ({
+ entraIdPrincipalFeatureEnabled: jest.fn().mockReturnValue(false),
+ getUserOwnedEntraGroups: jest.fn().mockResolvedValue([]),
+ getUserEntraGroups: jest.fn().mockResolvedValue([]),
+ getEntraGroupDetailsBatch: jest.fn().mockResolvedValue([]),
+ getGroupMembers: jest.fn().mockResolvedValue([]),
+ getGroupOwners: jest.fn().mockResolvedValue([]),
+}));
+
+const mockRegistryInstance = {
+ getServerConfig: jest.fn(),
+};
+
+jest.mock('~/config', () => ({
+ logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
+ getMCPManager: jest.fn(),
+ getMCPServersRegistry: jest.fn(() => mockRegistryInstance),
+}));
+
+const mockResolveAllMcpConfigs = jest.fn();
+jest.mock('~/server/services/MCP', () => ({
+ resolveConfigServers: jest.fn().mockResolvedValue({}),
+ resolveMcpConfigNames: jest.fn().mockResolvedValue([]),
+ resolveAllMcpConfigs: (...args) => mockResolveAllMcpConfigs(...args),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ cacheMCPServerTools: jest.fn(),
+ getMCPServerTools: jest.fn(),
+}));
+
+const { getMCPServersList, getMCPServerById } = require('~/server/controllers/mcp');
+const { grantPermission } = require('~/server/services/PermissionService');
+const { seedDefaultRoles } = require('~/models');
+
+let mongoServer;
+let SystemGrant;
+let AclEntry;
+let User;
+
+const yamlConfig = {
+ type: 'streamable-http',
+ url: 'https://internal.example.com/mcp',
+ title: 'YAML Server',
+ source: 'yaml',
+ oauth: {
+ client_id: 'client-id',
+ authorization_url: 'https://internal.example.com/auth',
+ token_url: 'https://internal.example.com/token',
+ },
+};
+
+const createRes = () => {
+ const res = {};
+ res.status = jest.fn(() => res);
+ res.json = jest.fn(() => res);
+ return res;
+};
+
+const createDbConfig = (dbId) => ({
+ type: 'streamable-http',
+ url: 'https://user.example.com/mcp',
+ title: 'DB Server',
+ source: 'user',
+ dbId: String(dbId),
+});
+
+beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ await mongoose.connect(mongoServer.getUri());
+
+ const { createModels } = jest.requireActual('@librechat/data-schemas');
+ createModels(mongoose);
+ const dbModels = require('~/db/models');
+ Object.assign(mongoose.models, dbModels);
+ SystemGrant = dbModels.SystemGrant;
+ AclEntry = dbModels.AclEntry;
+ User = dbModels.User;
+
+ await seedDefaultRoles();
+});
+
+afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+});
+
+let existsSpy;
+
+beforeEach(async () => {
+ await SystemGrant.deleteMany({});
+ await AclEntry.deleteMany({});
+ await User.deleteMany({});
+ mockResolveAllMcpConfigs.mockReset();
+ mockRegistryInstance.getServerConfig.mockReset();
+ existsSpy = jest.spyOn(SystemGrant, 'exists');
+});
+
+afterEach(() => {
+ existsSpy.mockRestore();
+});
+
+const seedManageMcpGrant = async (role = SystemRoles.ADMIN) => {
+ await SystemGrant.create({
+ principalType: PrincipalType.ROLE,
+ principalId: role,
+ capability: SystemCapabilities.MANAGE_MCP_SERVERS,
+ grantedAt: new Date(),
+ });
+};
+
+const createUser = async (role = SystemRoles.USER) => {
+ const user = await User.create({
+ name: 'Test User',
+ email: `user-${new mongoose.Types.ObjectId().toString()}@example.com`,
+ provider: 'local',
+ role,
+ });
+ return { id: user._id.toString(), role, idOnTheSource: null };
+};
+
+describe('getMCPServersList', () => {
+ it('skips the capability probe when no server is DB-backed', async () => {
+ await seedManageMcpGrant();
+ const reqUser = await createUser(SystemRoles.ADMIN);
+ mockResolveAllMcpConfigs.mockResolvedValue({ yamlServer: { ...yamlConfig } });
+
+ const res = createRes();
+ await getMCPServersList({ user: reqUser }, res);
+
+ expect(existsSpy).not.toHaveBeenCalled();
+ const payload = res.json.mock.calls[0][0];
+ expect(payload.yamlServer.title).toBe('YAML Server');
+ expect(payload.yamlServer.url).toBeUndefined();
+ expect(payload.yamlServer.oauth.authorization_url).toBeUndefined();
+ });
+
+ it('skips the probe entirely for an empty server map', async () => {
+ const reqUser = await createUser();
+ mockResolveAllMcpConfigs.mockResolvedValue({});
+
+ const res = createRes();
+ await getMCPServersList({ user: reqUser }, res);
+
+ expect(existsSpy).not.toHaveBeenCalled();
+ expect(res.json).toHaveBeenCalledWith({});
+ });
+
+ it('applies the capability bypass to all servers when a DB-backed server is present', async () => {
+ await seedManageMcpGrant();
+ const reqUser = await createUser(SystemRoles.ADMIN);
+ const dbId = new mongoose.Types.ObjectId();
+ mockResolveAllMcpConfigs.mockResolvedValue({
+ dbServer: createDbConfig(dbId),
+ yamlServer: { ...yamlConfig },
+ });
+
+ const res = createRes();
+ await getMCPServersList({ user: reqUser }, res);
+
+ expect(existsSpy).toHaveBeenCalledTimes(1);
+ const payload = res.json.mock.calls[0][0];
+ expect(payload.dbServer.url).toBe('https://user.example.com/mcp');
+ expect(payload.yamlServer.url).toBe('https://internal.example.com/mcp');
+ });
+
+ it('falls back to ACL EDIT for DB-backed servers without the capability', async () => {
+ const reqUser = await createUser();
+ const dbId = new mongoose.Types.ObjectId();
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: reqUser.id,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: dbId,
+ accessRoleId: AccessRoleIds.MCPSERVER_EDITOR,
+ grantedBy: reqUser.id,
+ });
+ mockResolveAllMcpConfigs.mockResolvedValue({
+ dbServer: createDbConfig(dbId),
+ yamlServer: { ...yamlConfig },
+ });
+
+ const res = createRes();
+ await getMCPServersList({ user: reqUser }, res);
+
+ expect(existsSpy).toHaveBeenCalledTimes(1);
+ const payload = res.json.mock.calls[0][0];
+ expect(payload.dbServer.url).toBe('https://user.example.com/mcp');
+ expect(payload.yamlServer.url).toBeUndefined();
+ });
+
+ it('leaves DB-backed servers redacted for viewer-only ACL', async () => {
+ const reqUser = await createUser();
+ const dbId = new mongoose.Types.ObjectId();
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: reqUser.id,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: dbId,
+ accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
+ grantedBy: reqUser.id,
+ });
+ mockResolveAllMcpConfigs.mockResolvedValue({ dbServer: createDbConfig(dbId) });
+
+ const res = createRes();
+ await getMCPServersList({ user: reqUser }, res);
+
+ expect(existsSpy).toHaveBeenCalledTimes(1);
+ const payload = res.json.mock.calls[0][0];
+ expect(payload.dbServer.title).toBe('DB Server');
+ expect(payload.dbServer.url).toBeUndefined();
+ });
+});
+
+describe('getMCPServerById', () => {
+ it('still runs the capability probe for YAML servers on the detail route', async () => {
+ await seedManageMcpGrant();
+ const reqUser = await createUser(SystemRoles.ADMIN);
+ mockRegistryInstance.getServerConfig.mockResolvedValue({ ...yamlConfig });
+
+ const res = createRes();
+ await getMCPServerById({ user: reqUser, params: { serverName: 'yamlServer' } }, res);
+
+ expect(existsSpy).toHaveBeenCalledTimes(1);
+ expect(res.status).toHaveBeenCalledWith(200);
+ const payload = res.json.mock.calls[0][0];
+ expect(payload.url).toBe('https://internal.example.com/mcp');
+ expect(payload.oauth.authorization_url).toBe('https://internal.example.com/auth');
+ });
+
+ it('redacts YAML server details for users without the capability', async () => {
+ const reqUser = await createUser();
+ mockRegistryInstance.getServerConfig.mockResolvedValue({ ...yamlConfig });
+
+ const res = createRes();
+ await getMCPServerById({ user: reqUser, params: { serverName: 'yamlServer' } }, res);
+
+ expect(existsSpy).toHaveBeenCalledTimes(1);
+ const payload = res.json.mock.calls[0][0];
+ expect(payload.url).toBeUndefined();
+ expect(payload.oauth.authorization_url).toBeUndefined();
+ });
+});
diff --git a/api/server/controllers/__tests__/tools.verifyToolAuth.spec.js b/api/server/controllers/__tests__/tools.verifyToolAuth.spec.js
new file mode 100644
index 0000000..0396502
--- /dev/null
+++ b/api/server/controllers/__tests__/tools.verifyToolAuth.spec.js
@@ -0,0 +1,102 @@
+jest.mock('@librechat/data-schemas', () => ({
+ logger: { debug: jest.fn(), error: jest.fn(), warn: jest.fn() },
+}));
+
+jest.mock('@librechat/api', () => ({
+ checkAccess: jest.fn(),
+ loadWebSearchAuth: jest.fn(),
+}));
+
+jest.mock('~/models', () => ({
+ getRoleByName: jest.fn(),
+ createToolCall: jest.fn(),
+ getToolCallsByConvo: jest.fn(),
+ getMessage: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/process', () => ({
+ processFileURL: jest.fn(),
+ uploadImageBuffer: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/Code/process', () => ({
+ processCodeOutput: jest.fn(),
+}));
+
+jest.mock('~/server/services/Tools/credentials', () => ({
+ loadAuthValues: jest.fn(),
+}));
+
+jest.mock('~/app/clients/tools/util', () => ({
+ loadTools: jest.fn(),
+}));
+
+const { Tools, AuthType } = require('librechat-data-provider');
+const { verifyToolAuth } = require('../tools');
+
+/**
+ * Phase 8 behavioral pin: `verifyToolAuth(execute_code)` unconditionally
+ * returns system-authenticated. Sandbox auth moved server-side into the
+ * agents library, so the per-user `CODE_API_KEY` check that previously
+ * gated this endpoint is gone. The deployment contract is: if the
+ * admin enabled the `execute_code` capability, the sandbox is
+ * reachable. This endpoint does not probe reachability (would be too
+ * expensive per UI-gate query); failures surface at execution time.
+ *
+ * A regression where someone re-adds an auth check here would
+ * resurrect the per-user key-entry dialog on the client, which Phase 8
+ * explicitly removed. Pin the contract.
+ */
+describe('verifyToolAuth — execute_code system-auth contract', () => {
+ const makeReq = (toolId) => ({
+ params: { toolId },
+ user: { id: 'user-1' },
+ config: {},
+ });
+
+ const makeRes = () => {
+ const res = {};
+ res.status = jest.fn().mockReturnValue(res);
+ res.json = jest.fn().mockReturnValue(res);
+ return res;
+ };
+
+ it('returns authenticated: true with SYSTEM_DEFINED for execute_code', async () => {
+ const res = makeRes();
+ await verifyToolAuth(makeReq(Tools.execute_code), res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith({
+ authenticated: true,
+ message: AuthType.SYSTEM_DEFINED,
+ });
+ });
+
+ it('returns 404 for unknown tool ids (not in directCallableTools)', async () => {
+ const res = makeRes();
+ await verifyToolAuth(makeReq('not_a_real_tool'), res);
+
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({ message: 'Tool not found' });
+ });
+
+ it('does NOT invoke loadAuthValues for execute_code (no per-user credential check)', async () => {
+ /* Regression guard: a future refactor that threads per-user auth back
+ in would resurface the key-entry dialog on the client. Pin that
+ the auth path is never consulted. */
+ const { loadAuthValues } = require('~/server/services/Tools/credentials');
+ loadAuthValues.mockClear();
+
+ await verifyToolAuth(makeReq(Tools.execute_code), makeRes());
+
+ expect(loadAuthValues).not.toHaveBeenCalled();
+ });
+
+ it('does NOT reference AuthType.USER_PROVIDED in the response (Phase 8 removed the path)', async () => {
+ const res = makeRes();
+ await verifyToolAuth(makeReq(Tools.execute_code), res);
+
+ const payload = res.json.mock.calls[0][0];
+ expect(payload.message).not.toBe(AuthType.USER_PROVIDED);
+ });
+});
diff --git a/api/server/controllers/agents/__tests__/askUserQuestion.e2e.spec.js b/api/server/controllers/agents/__tests__/askUserQuestion.e2e.spec.js
new file mode 100644
index 0000000..1976ae6
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/askUserQuestion.e2e.spec.js
@@ -0,0 +1,489 @@
+/**
+ * Full-wiring ask_user_question lifecycle e2e.
+ *
+ * Companion to `hitlCheckpoint.e2e.spec.js`, same REAL components: the
+ * `@librechat/agents` Run (FakeChatModel scripted to call the ask tool), the
+ * LazyMongoSaver over mongodb-memory-server, the GenerationJobManager, and the
+ * `/agents/chat/resume` controller via supertest. The seam under test here is
+ * different, though: the interrupt is raised INSIDE a tool body (the tool's
+ * func calls the SDK's `askUserQuestion()` helper, which wraps LangGraph
+ * `interrupt()`), not by the PreToolUse approval gate — and the run carries a
+ * checkpointer but NO `humanInTheLoop` switch and NO hooks, proving the
+ * question flow works with the approval policy fully disabled.
+ */
+const express = require('express');
+const request = require('supertest');
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { z } = require('zod');
+const { tool } = require('@langchain/core/tools');
+const { HumanMessage } = require('@langchain/core/messages');
+const { Run, Providers, FakeChatModel, askUserQuestion } = require('@librechat/agents');
+
+const mockLogger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() };
+
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ logger: mockLogger,
+}));
+
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ checkAndIncrementPendingRequest: jest.fn(async () => ({ allowed: true })),
+ decrementPendingRequest: jest.fn(async () => {}),
+}));
+
+jest.mock('~/models', () => ({
+ saveMessage: jest.fn(async (req, message) => message),
+ getConvo: jest.fn(async () => null),
+ getMessages: jest.fn(async () => []),
+}));
+
+jest.mock('~/server/cleanup', () => ({
+ disposeClient: jest.fn(),
+}));
+
+jest.mock('~/server/services/MCPRequestContext', () => ({
+ getMCPRequestContext: jest.fn(() => null),
+ cleanupMCPRequestContextForReq: jest.fn(),
+}));
+
+// Import after mocks — these are the REAL implementations.
+const {
+ GenerationJobManager,
+ createStreamServices,
+ buildPendingAction,
+ getAgentCheckpointer,
+ deleteAgentCheckpoint,
+ __resetCheckpointerForTests,
+} = require('@librechat/api');
+const ResumeAgentController = require('~/server/controllers/agents/resume');
+
+const USER_ID = 'ask-e2e-user';
+const MONGO_CFG = { type: 'mongo', ttl: 3600 };
+const ASK_TOOL = 'ask_user_question';
+
+/**
+ * Body-run counter + captured resolution. The body executes TWICE per answered
+ * question by LangGraph contract (pass 1 runs until `interrupt()` throws; the
+ * resume pass re-runs the body from the top and `askUserQuestion()` returns the
+ * host's answer), so `bodyRuns` proves the re-entry semantics and
+ * `resolvedAnswers` proves the answer round-trip.
+ */
+let bodyRuns = 0;
+let resolvedAnswers = [];
+const askTool = tool(
+ async (input) => {
+ bodyRuns += 1;
+ const { answer } = askUserQuestion(input);
+ resolvedAnswers.push(answer);
+ return answer;
+ },
+ {
+ name: ASK_TOOL,
+ description: 'Ask the user a clarifying question and wait for their answer.',
+ schema: z.object({
+ question: z.string(),
+ description: z.string().optional(),
+ options: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
+ }),
+ },
+);
+
+/**
+ * Build a REAL run shaped like production `createRun` for the ask-only case:
+ * durable checkpointer attached, `eagerEventToolExecution` on with the ask
+ * tool excluded (mirrors the planned run.ts wiring) — and, deliberately, NO
+ * `humanInTheLoop` and NO hooks.
+ */
+async function buildAskRun({ saver, responses, toolCalls, runId }) {
+ const run = await Run.create({
+ runId,
+ graphConfig: {
+ type: 'standard',
+ llmConfig: {
+ provider: Providers.OPENAI,
+ model: 'gpt-4o-mini',
+ streaming: true,
+ streamUsage: false,
+ },
+ instructions: 'You are a helpful assistant.',
+ tools: [askTool],
+ compileOptions: { checkpointer: saver },
+ },
+ returnContent: true,
+ customHandlers: {},
+ tokenCounter: (text) => String(text ?? '').length,
+ indexTokenCountMap: {},
+ eagerEventToolExecution: { enabled: true, excludeToolNames: [ASK_TOOL] },
+ });
+ run.Graph.overrideModel = new FakeChatModel({ responses, toolCalls });
+ return run;
+}
+
+/**
+ * Build a REAL run in the PRODUCTION shape: the agents endpoint loads tools
+ * definitions-only, so the run is EVENT-DRIVEN (`toolDefinitions` non-empty flips
+ * the SDK ToolNode to event dispatch) and the ask tool rides `graphTools` — the
+ * SDK's in-graph direct-tool seam (agents#289, > 3.2.57) — because an event-
+ * dispatched tool body executes in the host handler outside the Pregel task
+ * frame, where `interrupt()` throws instead of pausing. This is the mode
+ * `createRun` produces via `buildAgentInput`; the traditional-mode harness above
+ * covers runs with zero toolDefinitions.
+ */
+async function buildAskRunEventMode({ saver, responses, toolCalls, runId }) {
+ const run = await Run.create({
+ runId,
+ graphConfig: {
+ type: 'standard',
+ agents: [
+ {
+ agentId: 'agent-ask-event',
+ provider: Providers.OPENAI,
+ clientOptions: { model: 'gpt-4o-mini', streaming: true, streamUsage: false },
+ instructions: 'You are a helpful assistant.',
+ maxContextTokens: 8000,
+ toolDefinitions: [{ name: 'dummy_event_tool', description: 'host-executed event tool' }],
+ graphTools: [askTool],
+ },
+ ],
+ compileOptions: { checkpointer: saver },
+ },
+ returnContent: true,
+ customHandlers: {},
+ tokenCounter: (text) => String(text ?? '').length,
+ indexTokenCountMap: {},
+ eagerEventToolExecution: { enabled: true, excludeToolNames: [ASK_TOOL] },
+ });
+ run.Graph.overrideModel = new FakeChatModel({ responses, toolCalls });
+ return run;
+}
+
+const runConfig = (conversationId) => ({
+ runName: 'AgentRun',
+ configurable: { thread_id: conversationId, user_id: USER_ID },
+ streamMode: 'values',
+ version: 'v2',
+});
+
+/** Poll until `predicate` returns true (the resume continuation is fire-and-forget). */
+async function waitFor(predicate, { timeoutMs = 10_000, intervalMs = 50 } = {}) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (await predicate()) {
+ return;
+ }
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
+ }
+ throw new Error('waitFor: condition not met within timeout');
+}
+
+async function checkpointCounts(conversationId) {
+ const db = mongoose.connection.db;
+ return {
+ checkpoints: await db
+ .collection('agent_checkpoints')
+ .countDocuments({ thread_id: conversationId }),
+ writes: await db
+ .collection('agent_checkpoint_writes')
+ .countDocuments({ thread_id: conversationId }),
+ };
+}
+
+let mongoServer;
+let saver;
+
+beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ await mongoose.connect(mongoServer.getUri());
+ __resetCheckpointerForTests();
+ saver = await getAgentCheckpointer(MONGO_CFG);
+
+ GenerationJobManager.configure({ ...createStreamServices(), cleanupOnComplete: false });
+ GenerationJobManager.initialize();
+ GenerationJobManager.setApprovalExpiredHandler(async (conversationId) => {
+ await deleteAgentCheckpoint(conversationId, MONGO_CFG);
+ });
+}, 60000);
+
+afterAll(async () => {
+ GenerationJobManager.setApprovalExpiredHandler(null);
+ await GenerationJobManager.destroy();
+ await mongoose.disconnect();
+ await mongoServer.stop();
+});
+
+beforeEach(() => {
+ bodyRuns = 0;
+ resolvedAnswers = [];
+ jest.clearAllMocks();
+});
+
+describe('ask_user_question lifecycle (full wiring, approval policy disabled)', () => {
+ jest.setTimeout(30000);
+
+ test('a tool-body interrupt pauses durably and the REAL /resume controller delivers the answer as the tool result', async () => {
+ const conversationId = `ask-e2e-resume-${Date.now()}`;
+ const responseMessageId = 'resp-ask-1';
+
+ // --- Turn 1: the model calls the ask tool → interrupt() from inside the tool body. ---
+ const run = await buildAskRun({
+ saver,
+ responses: ['Let me check with you.'],
+ toolCalls: [
+ {
+ name: ASK_TOOL,
+ args: {
+ question: 'Which environment should I deploy to?',
+ options: [
+ { label: 'Staging', value: 'staging' },
+ { label: 'Production', value: 'production' },
+ ],
+ },
+ id: 'tc_ask_1',
+ type: 'tool_call',
+ },
+ ],
+ runId: responseMessageId,
+ });
+ await run.processStream(
+ { messages: [new HumanMessage('deploy the app')] },
+ runConfig(conversationId),
+ );
+
+ const interrupt = run.getInterrupt();
+ expect(interrupt?.payload?.type).toBe('ask_user_question');
+ expect(interrupt.payload.question).toEqual({
+ question: 'Which environment should I deploy to?',
+ options: [
+ { label: 'Staging', value: 'staging' },
+ { label: 'Production', value: 'production' },
+ ],
+ });
+ expect(bodyRuns).toBe(1); // body entered once; interrupt() threw before any answer
+ expect(resolvedAnswers).toEqual([]);
+ const paused = await checkpointCounts(conversationId);
+ expect(paused.checkpoints).toBeGreaterThan(0); // the interrupt checkpoint is durable
+
+ // --- Pause bookkeeping (mirrors AgentClient.handleRunInterrupt). ---
+ await GenerationJobManager.createJob(conversationId, USER_ID, conversationId);
+ await GenerationJobManager.updateMetadata(conversationId, {
+ endpoint: 'agents',
+ agent_id: 'agent-ask-e2e',
+ responseMessageId,
+ });
+ const pendingAction = buildPendingAction(interrupt.payload, {
+ streamId: conversationId,
+ conversationId,
+ runId: responseMessageId,
+ responseMessageId,
+ ttlMs: 60_000,
+ });
+ expect(await GenerationJobManager.approvals.pause(conversationId, pendingAction)).toBe(true);
+
+ // --- Turn 2: answer through the REAL controller; the thin client rebuilds a REAL run. ---
+ const thinClient = {
+ contentParts: [],
+ artifactPromises: [],
+ conversationId,
+ responseMessageId,
+ pendingApproval: null,
+ async resumeCompletion({ resumeValue, abortController }) {
+ const resumed = await buildAskRun({
+ saver,
+ responses: ['Deploying to staging.'],
+ runId: responseMessageId,
+ });
+ await resumed.resume(resumeValue, {
+ ...runConfig(conversationId),
+ signal: (abortController ?? new AbortController()).signal,
+ });
+ const reInterrupt = resumed.getInterrupt?.();
+ if (reInterrupt?.payload) {
+ this.pendingApproval = reInterrupt.payload;
+ }
+ this.contentParts.push({ type: 'text', text: 'Deploying to staging.' });
+ return resumed;
+ },
+ };
+ const initializeClient = jest.fn(async () => ({ client: thinClient }));
+ const addTitle = jest.fn();
+
+ const app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ req.user = { id: USER_ID };
+ req.config = { endpoints: { agents: { checkpointer: MONGO_CFG } }, interfaceConfig: {} };
+ next();
+ });
+ app.post('/api/agents/chat/resume', (req, res, next) =>
+ ResumeAgentController(req, res, next, initializeClient, addTitle),
+ );
+
+ const response = await request(app).post('/api/agents/chat/resume').send({
+ conversationId,
+ actionId: pendingAction.actionId,
+ agent_id: 'agent-ask-e2e',
+ endpoint: 'agents',
+ answer: 'staging',
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.status).toBe('resuming');
+ await waitFor(async () => {
+ const liveJob = await GenerationJobManager.getJob(conversationId);
+ return liveJob?.status !== 'requires_action' && liveJob?.status !== 'running';
+ });
+
+ expect(initializeClient).toHaveBeenCalledTimes(1);
+ // Resume pass re-ran the body from the top; askUserQuestion() returned the answer.
+ expect(bodyRuns).toBe(2);
+ expect(resolvedAnswers).toEqual(['staging']);
+ expect(thinClient.pendingApproval).toBeNull(); // no second question — turn completed
+
+ // Terminal state: the checkpoint was pruned by the REAL finalize path.
+ await waitFor(async () => (await checkpointCounts(conversationId)).checkpoints === 0);
+ expect(await checkpointCounts(conversationId)).toEqual({ checkpoints: 0, writes: 0 });
+ });
+
+ test('EVENT-DRIVEN mode (production shape): the graphTools ask tool pauses and resumes over the REAL /resume controller', async () => {
+ const conversationId = `ask-e2e-event-${Date.now()}`;
+ const responseMessageId = 'resp-ask-event-1';
+
+ const run = await buildAskRunEventMode({
+ saver,
+ responses: ['Let me check with you.'],
+ toolCalls: [
+ {
+ name: ASK_TOOL,
+ args: { question: 'Proceed with the migration?' },
+ id: 'tc_ask_ev1',
+ type: 'tool_call',
+ },
+ ],
+ runId: responseMessageId,
+ });
+ await run.processStream(
+ { messages: [new HumanMessage('run the migration')] },
+ runConfig(conversationId),
+ );
+
+ const interrupt = run.getInterrupt();
+ expect(interrupt?.payload?.type).toBe('ask_user_question');
+ expect(interrupt.payload.question).toEqual({ question: 'Proceed with the migration?' });
+ expect(bodyRuns).toBe(1);
+ expect((await checkpointCounts(conversationId)).checkpoints).toBeGreaterThan(0);
+
+ await GenerationJobManager.createJob(conversationId, USER_ID, conversationId);
+ await GenerationJobManager.updateMetadata(conversationId, {
+ endpoint: 'agents',
+ agent_id: 'agent-ask-e2e',
+ responseMessageId,
+ });
+ const pendingAction = buildPendingAction(interrupt.payload, {
+ streamId: conversationId,
+ conversationId,
+ runId: responseMessageId,
+ responseMessageId,
+ ttlMs: 60_000,
+ });
+ expect(await GenerationJobManager.approvals.pause(conversationId, pendingAction)).toBe(true);
+
+ const thinClient = {
+ contentParts: [],
+ artifactPromises: [],
+ conversationId,
+ responseMessageId,
+ pendingApproval: null,
+ async resumeCompletion({ resumeValue, abortController }) {
+ const resumed = await buildAskRunEventMode({
+ saver,
+ responses: ['Migration underway.'],
+ runId: responseMessageId,
+ });
+ await resumed.resume(resumeValue, {
+ ...runConfig(conversationId),
+ signal: (abortController ?? new AbortController()).signal,
+ });
+ this.contentParts.push({ type: 'text', text: 'Migration underway.' });
+ return resumed;
+ },
+ };
+ const initializeClient = jest.fn(async () => ({ client: thinClient }));
+
+ const app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ req.user = { id: USER_ID };
+ req.config = { endpoints: { agents: { checkpointer: MONGO_CFG } }, interfaceConfig: {} };
+ next();
+ });
+ app.post('/api/agents/chat/resume', (req, res, next) =>
+ ResumeAgentController(req, res, next, initializeClient, jest.fn()),
+ );
+
+ const response = await request(app).post('/api/agents/chat/resume').send({
+ conversationId,
+ actionId: pendingAction.actionId,
+ agent_id: 'agent-ask-e2e',
+ endpoint: 'agents',
+ answer: 'yes, proceed',
+ });
+
+ expect(response.status).toBe(200);
+ await waitFor(async () => {
+ const liveJob = await GenerationJobManager.getJob(conversationId);
+ return liveJob?.status !== 'requires_action' && liveJob?.status !== 'running';
+ });
+
+ expect(bodyRuns).toBe(2);
+ expect(resolvedAnswers).toEqual(['yes, proceed']);
+ await waitFor(async () => (await checkpointCounts(conversationId)).checkpoints === 0);
+ });
+
+ test('a second question raised after resume re-pauses with a fresh ask_user_question interrupt', async () => {
+ const conversationId = `ask-e2e-seq-${Date.now()}`;
+
+ const run = await buildAskRun({
+ saver,
+ responses: ['First question coming.'],
+ toolCalls: [
+ {
+ name: ASK_TOOL,
+ args: { question: 'Pick a color?' },
+ id: 'tc_ask_q1',
+ type: 'tool_call',
+ },
+ ],
+ runId: 'resp-ask-seq',
+ });
+ await run.processStream({ messages: [new HumanMessage('start')] }, runConfig(conversationId));
+ expect(run.getInterrupt()?.payload?.type).toBe('ask_user_question');
+ expect(bodyRuns).toBe(1);
+
+ // Resume with the first answer; the NEXT model turn asks a second question.
+ const resumed = await buildAskRun({
+ saver,
+ responses: ['And one more thing.'],
+ toolCalls: [
+ {
+ name: ASK_TOOL,
+ args: { question: 'Pick a size?' },
+ id: 'tc_ask_q2',
+ type: 'tool_call',
+ },
+ ],
+ runId: 'resp-ask-seq',
+ });
+ await resumed.resume({ answer: 'blue' }, runConfig(conversationId));
+
+ expect(resolvedAnswers).toEqual(['blue']);
+ const second = resumed.getInterrupt();
+ expect(second?.payload?.type).toBe('ask_user_question');
+ expect(second.payload.question).toEqual({ question: 'Pick a size?' });
+ // q1 body ran twice (pass + resume); q2 body entered once and interrupted.
+ expect(bodyRuns).toBe(3);
+
+ await deleteAgentCheckpoint(conversationId, MONGO_CFG);
+ });
+});
diff --git a/api/server/controllers/agents/__tests__/callbacks.spec.js b/api/server/controllers/agents/__tests__/callbacks.spec.js
new file mode 100644
index 0000000..20fcf54
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/callbacks.spec.js
@@ -0,0 +1,685 @@
+const { Tools } = require('librechat-data-provider');
+
+// Mock all dependencies before requiring the module
+jest.mock('nanoid', () => ({
+ nanoid: jest.fn(() => 'mock-id'),
+}));
+
+jest.mock('@librechat/api', () => ({
+ sendEvent: jest.fn(),
+ HOST_FILE_AUTHORING_ARTIFACT_KEY: '__librechat_file_authoring',
+ isCodeSessionToolName: jest.fn((name) =>
+ ['execute_code', 'bash_tool', 'read_file'].includes(name),
+ ),
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: {
+ error: jest.fn(),
+ },
+}));
+
+jest.mock('@librechat/agents', () => ({
+ ...jest.requireActual('@librechat/agents'),
+ getMessageId: jest.fn(),
+ ToolEndHandler: jest.fn(),
+ handleToolCalls: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/Citations', () => ({
+ processFileCitations: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/Code/process', () => ({
+ processCodeOutput: jest.fn(),
+ /* `runPreviewFinalize` is the runtime pairing for `finalize` (defined
+ * alongside processCodeOutput in process.js). The callback wires
+ * the deferred render through it; reproduce the basic happy-path here so the
+ * SSE-emit assertions still work. The catch/defensive-updateFile
+ * branch is unit-tested directly against the real helper in
+ * process.spec.js — exercising it here would add test coupling
+ * without coverage benefit. */
+ runPreviewFinalize: ({ finalize, onResolved }) => {
+ if (typeof finalize !== 'function') {
+ return;
+ }
+ finalize()
+ .then((updated) => {
+ if (!updated || !onResolved) {
+ return;
+ }
+ onResolved(updated);
+ })
+ .catch(() => {
+ /* swallowed in the mock — see process.spec.js for catch coverage */
+ });
+ },
+}));
+
+jest.mock('~/server/services/Tools/credentials', () => ({
+ loadAuthValues: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/process', () => ({
+ saveBase64Image: jest.fn(),
+}));
+
+describe('createToolEndCallback', () => {
+ let req, res, artifactPromises, createToolEndCallback;
+ let logger;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Get the mocked logger
+ logger = require('@librechat/data-schemas').logger;
+
+ // Now require the module after all mocks are set up
+ const callbacks = require('../callbacks');
+ createToolEndCallback = callbacks.createToolEndCallback;
+
+ req = {
+ user: { id: 'user123' },
+ };
+ res = {
+ headersSent: false,
+ write: jest.fn(),
+ };
+ artifactPromises = [];
+ });
+
+ describe('ui_resources artifact handling', () => {
+ it('should process ui_resources artifact and return attachment when headers not sent', async () => {
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ const output = {
+ tool_call_id: 'tool123',
+ artifact: {
+ [Tools.ui_resources]: {
+ data: [
+ { type: 'button', label: 'Click me' },
+ { type: 'input', placeholder: 'Enter text' },
+ ],
+ },
+ },
+ };
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback({ output }, metadata);
+
+ // Wait for all promises to resolve
+ const results = await Promise.all(artifactPromises);
+
+ // When headers are not sent, it returns attachment without writing
+ expect(res.write).not.toHaveBeenCalled();
+
+ const attachment = results[0];
+ expect(attachment).toEqual({
+ type: Tools.ui_resources,
+ messageId: 'run456',
+ toolCallId: 'tool123',
+ conversationId: 'thread789',
+ [Tools.ui_resources]: [
+ { type: 'button', label: 'Click me' },
+ { type: 'input', placeholder: 'Enter text' },
+ ],
+ });
+ });
+
+ it('should write to response when headers are already sent', async () => {
+ res.headersSent = true;
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ const output = {
+ tool_call_id: 'tool123',
+ artifact: {
+ [Tools.ui_resources]: {
+ data: [{ type: 'carousel', items: [] }],
+ },
+ },
+ };
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback({ output }, metadata);
+ const results = await Promise.all(artifactPromises);
+
+ expect(res.write).toHaveBeenCalled();
+ expect(results[0]).toEqual({
+ type: Tools.ui_resources,
+ messageId: 'run456',
+ toolCallId: 'tool123',
+ conversationId: 'thread789',
+ [Tools.ui_resources]: [{ type: 'carousel', items: [] }],
+ });
+ });
+
+ it('should handle errors when processing ui_resources', async () => {
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ // Mock res.write to throw an error
+ res.headersSent = true;
+ res.write.mockImplementation(() => {
+ throw new Error('Write failed');
+ });
+
+ const output = {
+ tool_call_id: 'tool123',
+ artifact: {
+ [Tools.ui_resources]: {
+ data: [{ type: 'test' }],
+ },
+ },
+ };
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback({ output }, metadata);
+ const results = await Promise.all(artifactPromises);
+
+ expect(logger.error).toHaveBeenCalledWith(
+ 'Error processing artifact content:',
+ expect.any(Error),
+ );
+ expect(results[0]).toBeNull();
+ });
+
+ it('should handle multiple artifacts including ui_resources', async () => {
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ const output = {
+ tool_call_id: 'tool123',
+ artifact: {
+ [Tools.ui_resources]: {
+ data: [{ type: 'chart', data: [] }],
+ },
+ [Tools.web_search]: {
+ results: ['result1', 'result2'],
+ },
+ },
+ };
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback({ output }, metadata);
+ const results = await Promise.all(artifactPromises);
+
+ // Both ui_resources and web_search should be processed
+ expect(artifactPromises).toHaveLength(2);
+ expect(results).toHaveLength(2);
+
+ // Check ui_resources attachment
+ const uiResourceAttachment = results.find((r) => r?.type === Tools.ui_resources);
+ expect(uiResourceAttachment).toBeTruthy();
+ expect(uiResourceAttachment[Tools.ui_resources]).toEqual([{ type: 'chart', data: [] }]);
+
+ // Check web_search attachment
+ const webSearchAttachment = results.find((r) => r?.type === Tools.web_search);
+ expect(webSearchAttachment).toBeTruthy();
+ expect(webSearchAttachment[Tools.web_search]).toEqual({
+ results: ['result1', 'result2'],
+ });
+ });
+
+ it('should not process artifacts when output has no artifacts', async () => {
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ const output = {
+ tool_call_id: 'tool123',
+ content: 'Some regular content',
+ // No artifact property
+ };
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback({ output }, metadata);
+
+ expect(artifactPromises).toHaveLength(0);
+ expect(res.write).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle empty ui_resources data object', async () => {
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ const output = {
+ tool_call_id: 'tool123',
+ artifact: {
+ [Tools.ui_resources]: {
+ data: [],
+ },
+ },
+ };
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback({ output }, metadata);
+ const results = await Promise.all(artifactPromises);
+
+ expect(results[0]).toEqual({
+ type: Tools.ui_resources,
+ messageId: 'run456',
+ toolCallId: 'tool123',
+ conversationId: 'thread789',
+ [Tools.ui_resources]: [],
+ });
+ });
+
+ it('should handle ui_resources with complex nested data', async () => {
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ const complexData = {
+ 0: {
+ type: 'form',
+ fields: [
+ { name: 'field1', type: 'text', required: true },
+ { name: 'field2', type: 'select', options: ['a', 'b', 'c'] },
+ ],
+ nested: {
+ deep: {
+ value: 123,
+ array: [1, 2, 3],
+ },
+ },
+ },
+ };
+
+ const output = {
+ tool_call_id: 'tool123',
+ artifact: {
+ [Tools.ui_resources]: {
+ data: complexData,
+ },
+ },
+ };
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback({ output }, metadata);
+ const results = await Promise.all(artifactPromises);
+
+ expect(results[0][Tools.ui_resources]).toEqual(complexData);
+ });
+
+ it('should handle when output is undefined', async () => {
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback({ output: undefined }, metadata);
+
+ expect(artifactPromises).toHaveLength(0);
+ expect(res.write).not.toHaveBeenCalled();
+ });
+
+ it('should handle when data parameter is undefined', async () => {
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback(undefined, metadata);
+
+ expect(artifactPromises).toHaveLength(0);
+ expect(res.write).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('code execution deferred-preview emit', () => {
+ /* The deferred-preview code-execution flow emits the attachment twice over
+ * SSE: the initial emit with `status: 'pending'` and the current run's
+ * messageId, the deferred render with the resolved record. The preview update emit
+ * must use the CURRENT run's messageId (not the persisted DB one)
+ * because `processCodeOutput` intentionally preserves the original
+ * `messageId` on cross-turn filename reuse — `getCodeGeneratedFiles`
+ * needs that for prior-turn priming.
+ *
+ * Codex P1 review on PR #12957: shipping `updated.messageId`
+ * straight from the DB record routed preview-update patches to the wrong
+ * message slot, leaving the current turn's pending chip stuck. */
+
+ const { processCodeOutput } = require('~/server/services/Files/Code/process');
+
+ function makeCodeExecutionEvent({
+ runId,
+ threadId,
+ toolCallId,
+ fileId,
+ name,
+ toolName = 'execute_code',
+ hostFileAuthoring = false,
+ }) {
+ return {
+ output: {
+ name: toolName,
+ tool_call_id: toolCallId,
+ artifact: {
+ ...(hostFileAuthoring ? { __librechat_file_authoring: true } : {}),
+ session_id: 'sess-1',
+ files: [{ id: fileId, name, session_id: 'sess-1' }],
+ },
+ },
+ metadata: { run_id: runId, thread_id: threadId },
+ };
+ }
+
+ /** Parse the SSE frame `res.write` produces back to a payload object. */
+ function parseSseAttachment(call) {
+ const frame = call[0];
+ const dataLine = frame.split('\n').find((l) => l.startsWith('data: '));
+ return JSON.parse(dataLine.slice('data: '.length));
+ }
+
+ it('the preview update emit uses the current run messageId, not the persisted DB messageId (cross-turn filename reuse)', async () => {
+ /* Simulate turn-2 reusing `output.csv` from turn-1. The DB record
+ * surfaced by `updateFile` carries the original `turn-1-msg`
+ * messageId; the runtime emit must rewrite to `turn-2-msg`. */
+ res.headersSent = true;
+ const finalize = jest.fn().mockResolvedValue({
+ file_id: 'fid-shared',
+ filename: 'output.csv',
+ filepath: '/uploads/output.csv',
+ type: 'text/csv',
+ conversationId: 'thread789',
+ messageId: 'turn-1-original-msg', // persisted DB id (older turn)
+ status: 'ready',
+ text: '',
+ textFormat: 'html',
+ });
+ processCodeOutput.mockResolvedValue({
+ file: {
+ file_id: 'fid-shared',
+ filename: 'output.csv',
+ filepath: '/uploads/output.csv',
+ type: 'text/csv',
+ conversationId: 'thread789',
+ messageId: 'turn-2-current-run', // runtime overlay (current turn)
+ toolCallId: 'tool-2',
+ status: 'pending',
+ text: null,
+ textFormat: null,
+ },
+ finalize,
+ });
+
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+ const event = makeCodeExecutionEvent({
+ runId: 'turn-2-current-run',
+ threadId: 'thread789',
+ toolCallId: 'tool-2',
+ fileId: 'fid-shared',
+ name: 'output.csv',
+ });
+ await toolEndCallback({ output: event.output }, event.metadata);
+ await Promise.all(artifactPromises);
+ // Wait one more tick so the fire-and-forget finalize() chain settles.
+ await new Promise((resolve) => setImmediate(resolve));
+
+ // Two SSE writes: the initial emit (pending) and the deferred render (ready).
+ expect(res.write).toHaveBeenCalledTimes(2);
+ const phase1 = parseSseAttachment(res.write.mock.calls[0]);
+ const phase2 = parseSseAttachment(res.write.mock.calls[1]);
+
+ // Initial emit already used the runtime messageId (sourced from result.file).
+ expect(phase1.messageId).toBe('turn-2-current-run');
+ expect(phase1.status).toBe('pending');
+
+ /* The preview update MUST also route to the current run's messageId so the
+ * frontend's `useAttachmentHandler` upserts under the same
+ * messageAttachmentsMap slot as the initial emit. Routing to
+ * `turn-1-original-msg` would land the patch on a stale message
+ * and leave turn-2's pending chip stuck. */
+ expect(phase2.messageId).toBe('turn-2-current-run');
+ expect(phase2.file_id).toBe('fid-shared');
+ expect(phase2.status).toBe('ready');
+ expect(phase2.text).toBe('');
+ expect(phase2.toolCallId).toBe('tool-2');
+ /* Wire-shape parity with the initial emit: preview update emits the full updated
+ * record so the client doesn't see one shape on the initial emit and a
+ * narrower projection on the deferred render. (Codex audit on PR #12957
+ * Finding 1.) */
+ expect(phase2.filename).toBe('output.csv');
+ expect(phase2.filepath).toBe('/uploads/output.csv');
+ expect(phase2.type).toBe('text/csv');
+ expect(phase2.conversationId).toBe('thread789');
+ expect(phase2.textFormat).toBe('html');
+ });
+
+ it('the preview update emit is skipped when finalize resolves to null (no DB update happened)', async () => {
+ res.headersSent = true;
+ processCodeOutput.mockResolvedValue({
+ file: {
+ file_id: 'fid-1',
+ filename: 'data.xlsx',
+ filepath: '/uploads/data.xlsx',
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ messageId: 'run-1',
+ toolCallId: 'tool-1',
+ status: 'pending',
+ },
+ finalize: jest.fn().mockResolvedValue(null),
+ });
+
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+ const event = makeCodeExecutionEvent({
+ runId: 'run-1',
+ threadId: 'thread-1',
+ toolCallId: 'tool-1',
+ fileId: 'fid-1',
+ name: 'data.xlsx',
+ });
+ await toolEndCallback({ output: event.output }, event.metadata);
+ await Promise.all(artifactPromises);
+ await new Promise((resolve) => setImmediate(resolve));
+
+ // Only the initial emit fired; preview update noop'd because finalize returned null.
+ expect(res.write).toHaveBeenCalledTimes(1);
+ });
+
+ it('the preview update emit is skipped when the response stream has already closed', async () => {
+ res.headersSent = true;
+ /* Hand-rolled deferred so we can hold finalize() open until
+ * AFTER setting `res.writableEnded = true`. Otherwise the mock
+ * resolves synchronously, the .then() runs in the same microtask
+ * queue as the artifactPromises await, and writableEnded is set
+ * too late. */
+ let resolveFinalize;
+ const finalizeDeferred = new Promise((resolve) => {
+ resolveFinalize = resolve;
+ });
+ processCodeOutput.mockResolvedValue({
+ file: {
+ file_id: 'fid-1',
+ filename: 'data.xlsx',
+ filepath: '/uploads/data.xlsx',
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ messageId: 'run-1',
+ toolCallId: 'tool-1',
+ status: 'pending',
+ },
+ finalize: jest.fn().mockReturnValue(finalizeDeferred),
+ });
+
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+ const event = makeCodeExecutionEvent({
+ runId: 'run-1',
+ threadId: 'thread-1',
+ toolCallId: 'tool-1',
+ fileId: 'fid-1',
+ name: 'data.xlsx',
+ });
+ await toolEndCallback({ output: event.output }, event.metadata);
+ await Promise.all(artifactPromises);
+ // Simulate the response closing AFTER the initial emit fires but BEFORE
+ // the deferred render lands. The frontend's polling path will catch the
+ // resolved record on its next tick.
+ res.writableEnded = true;
+ // Now resolve finalize and let the .then() chain run.
+ resolveFinalize({
+ file_id: 'fid-1',
+ filename: 'data.xlsx',
+ messageId: 'run-1',
+ status: 'ready',
+ text: ' ',
+ textFormat: 'html',
+ });
+ await new Promise((resolve) => setImmediate(resolve));
+
+ // Initial emit wrote; preview update noop'd because writableEnded.
+ expect(res.write).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call finalize for a non-office file (no preview expected)', async () => {
+ res.headersSent = true;
+ processCodeOutput.mockResolvedValue({
+ file: {
+ file_id: 'fid-txt',
+ filename: 'note.txt',
+ filepath: '/uploads/note.txt',
+ type: 'text/plain',
+ messageId: 'run-1',
+ toolCallId: 'tool-1',
+ // No status — non-office files skip the deferred render entirely.
+ },
+ // No finalize key — caller should not call anything.
+ });
+
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+ const event = makeCodeExecutionEvent({
+ runId: 'run-1',
+ threadId: 'thread-1',
+ toolCallId: 'tool-1',
+ fileId: 'fid-txt',
+ name: 'note.txt',
+ });
+ await toolEndCallback({ output: event.output }, event.metadata);
+ await Promise.all(artifactPromises);
+ await new Promise((resolve) => setImmediate(resolve));
+
+ expect(res.write).toHaveBeenCalledTimes(1);
+ });
+
+ it('processes create_file sandbox artifacts like code execution outputs', async () => {
+ res.headersSent = true;
+ processCodeOutput.mockResolvedValue({
+ file: {
+ file_id: 'fid-created',
+ filename: 'created.txt',
+ filepath: '/uploads/created.txt',
+ type: 'text/plain',
+ conversationId: 'thread789',
+ messageId: 'run-create',
+ toolCallId: 'tool-create',
+ status: 'ready',
+ },
+ });
+
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+ const event = makeCodeExecutionEvent({
+ runId: 'run-create',
+ threadId: 'thread789',
+ toolCallId: 'tool-create',
+ fileId: 'fid-created',
+ name: 'created.txt',
+ toolName: 'create_file',
+ hostFileAuthoring: true,
+ });
+ await toolEndCallback({ output: event.output }, event.metadata);
+ await Promise.all(artifactPromises);
+
+ expect(processCodeOutput).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'fid-created',
+ name: 'created.txt',
+ messageId: 'run-create',
+ toolCallId: 'tool-create',
+ conversationId: 'thread789',
+ }),
+ );
+ expect(res.write).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not process arbitrary user tool artifacts named create_file as code outputs', async () => {
+ res.headersSent = true;
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+ const event = makeCodeExecutionEvent({
+ runId: 'run-user-create',
+ threadId: 'thread789',
+ toolCallId: 'tool-user-create',
+ fileId: 'fid-user-created',
+ name: 'created.txt',
+ toolName: 'create_file',
+ });
+
+ await toolEndCallback({ output: event.output }, event.metadata);
+ await Promise.all(artifactPromises);
+
+ expect(processCodeOutput).not.toHaveBeenCalled();
+ expect(res.write).not.toHaveBeenCalled();
+ });
+ });
+});
+
+describe('isStreamWritable', () => {
+ /* Direct parametric coverage of the predicate that gates SSE writes
+ * in both the chat-completions and Open Responses callbacks. The
+ * existing deferred-preview tests cover this indirectly via the
+ * `writeAttachmentUpdate` writableEnded path; these tests pin down
+ * each individual branch so a future modification (e.g. adding a
+ * new condition) can't silently regress.
+ * (Comprehensive review NIT on PR #12957.) */
+ const { isStreamWritable } = require('../callbacks');
+
+ it('returns true when streamId is truthy regardless of res state', () => {
+ /* Resumable mode writes go to the job emitter; res state is
+ * irrelevant. Even a closed res with no headers should not block. */
+ expect(isStreamWritable(null, 'stream-1')).toBe(true);
+ expect(isStreamWritable({ headersSent: false, writableEnded: true }, 'stream-1')).toBe(true);
+ expect(isStreamWritable(undefined, 'stream-1')).toBe(true);
+ });
+
+ it('returns false when streamId is falsy and res is null/undefined', () => {
+ expect(isStreamWritable(null, null)).toBe(false);
+ expect(isStreamWritable(undefined, null)).toBe(false);
+ });
+
+ it('returns false when headers have not been sent yet', () => {
+ expect(isStreamWritable({ headersSent: false, writableEnded: false }, null)).toBe(false);
+ });
+
+ it('returns false when the stream has already ended', () => {
+ expect(isStreamWritable({ headersSent: true, writableEnded: true }, null)).toBe(false);
+ });
+
+ it('returns true on the happy path: headers sent, not ended, no streamId', () => {
+ expect(isStreamWritable({ headersSent: true, writableEnded: false }, null)).toBe(true);
+ });
+});
diff --git a/api/server/controllers/agents/__tests__/client.contextMetadata.spec.js b/api/server/controllers/agents/__tests__/client.contextMetadata.spec.js
new file mode 100644
index 0000000..6df38ef
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/client.contextMetadata.spec.js
@@ -0,0 +1,139 @@
+const AgentClient = require('../client');
+
+/** Minimal post-(maybe-)summary snapshot. baseUsed = maxContextTokens(1000) -
+ * remainingContextTokens(700) = 300, so the marker (summaryUsedTokens) is 300. */
+const snapshot = (summaryTokens) => ({
+ runId: 'run-1',
+ agentId: 'agent-1',
+ breakdown: {
+ maxContextTokens: 1000,
+ instructionTokens: 50,
+ systemMessageTokens: 50,
+ dynamicInstructionTokens: 0,
+ toolSchemaTokens: 0,
+ summaryTokens,
+ toolCount: 0,
+ messageCount: 1,
+ messageTokens: 20,
+ availableForMessages: 900,
+ },
+ contextBudget: 1000,
+ remainingContextTokens: 700,
+ prePruneContextTokens: 300,
+ effectiveInstructionTokens: 50,
+ calibrationRatio: 1,
+});
+
+const primary = { input_tokens: 10, output_tokens: 5, total_tokens: 15 };
+const summarizationUsage = { ...primary, usage_type: 'summarization' };
+const primaryFor = (runId, output_tokens) => ({
+ input_tokens: 10,
+ output_tokens,
+ total_tokens: 10 + output_tokens,
+ provider: 'openAI',
+ runId,
+});
+
+function buildMeta({ snap, latestUsageIndex, usageEvents }) {
+ const self = {
+ collectedThoughtSignatures: null,
+ usageEmitSink: usageEvents,
+ contextUsageSink: snap
+ ? { latest: snap, count: 1, latestUsageIndex }
+ : { latest: null, count: 0 },
+ };
+ return AgentClient.prototype.buildResponseMetadata.call(self);
+}
+
+describe('AgentClient.buildResponseMetadata — snapshot persistence + summary marker', () => {
+ it('persists the snapshot when a primary usage follows it (normal turn)', () => {
+ const meta = buildMeta({ snap: snapshot(0), latestUsageIndex: 0, usageEvents: [primary] });
+ expect(meta.contextUsage).toBeDefined();
+ expect(meta.summaryUsedTokens).toBeUndefined();
+ });
+
+ it('persists the post-summary snapshot when the only pre-primary usage is the summarization', () => {
+ /** A summarized turn: the summarization usage precedes the post-summary
+ * snapshot (index 1), then the model's primary usage follows it. The old
+ * count guard miscounted and dropped this; the new guard keeps it. The
+ * marker subtracts the summarization output (5): the generated summary is in
+ * the snapshot baseline (summaryTokens) AND the response tokenCount, so
+ * 300 − 5 = 295 keeps the client estimate from counting it twice. */
+ const meta = buildMeta({
+ snap: snapshot(80),
+ latestUsageIndex: 1,
+ usageEvents: [summarizationUsage, primary],
+ });
+ expect(meta.contextUsage).toBeDefined();
+ expect(meta.summaryUsedTokens).toBe(295);
+ });
+
+ it('still emits the summary marker when the final call emitted no usage', () => {
+ /** Interrupted summarized turn: no primary usage follows the latest snapshot,
+ * so the snapshot is (correctly) not persisted — but the coarse marker
+ * survives so the client estimate still caps the discarded history. The
+ * summarization output (5) is subtracted (300 − 5 = 295). */
+ const meta = buildMeta({
+ snap: snapshot(80),
+ latestUsageIndex: 1,
+ usageEvents: [summarizationUsage],
+ });
+ expect(meta.contextUsage).toBeUndefined();
+ expect(meta.summaryUsedTokens).toBe(295);
+ });
+
+ it('drops the snapshot and emits no marker when the final call had no usage and no summary', () => {
+ const meta = buildMeta({ snap: snapshot(0), latestUsageIndex: 1, usageEvents: [primary] });
+ expect(meta.contextUsage).toBeUndefined();
+ expect(meta.summaryUsedTokens).toBeUndefined();
+ });
+
+ it('does not persist the snapshot when only a parallel run produced post-snapshot usage', () => {
+ /** A snapshot (run-1) → B snapshot (run-1 is latest) but the only following
+ * usage belongs to a sibling run (run-2). The guard must NOT persist run-1's
+ * snapshot with run-2's output — it falls back to the per-message estimate. */
+ const meta = buildMeta({
+ snap: snapshot(0),
+ latestUsageIndex: 0,
+ usageEvents: [primaryFor('run-2', 99)],
+ });
+ expect(meta.contextUsage).toBeUndefined();
+ });
+
+ it('persists with the snapshot run output when its own primary usage follows', () => {
+ const meta = buildMeta({
+ snap: snapshot(0),
+ latestUsageIndex: 0,
+ usageEvents: [primaryFor('run-2', 99), primaryFor('run-1', 7)],
+ });
+ expect(meta.contextUsage).toBeDefined();
+ expect(meta.contextUsage.completedOutputTokens).toBe(7);
+ });
+
+ it('subtracts earlier tool-loop output from the summary marker (interrupted turn)', () => {
+ /** Multi-call summarized turn stopped before the final usage: the earlier
+ * call (output 40) is baked into baseUsed (300), so the marker is 300 − 40 =
+ * 260. No primary follows the snapshot, so the full snapshot is not persisted
+ * and the client uses this marker — which must not double-count the 40 that
+ * the response tokenCount also carries. */
+ const meta = buildMeta({
+ snap: snapshot(80),
+ latestUsageIndex: 1,
+ usageEvents: [primaryFor('run-1', 40)],
+ });
+ expect(meta.contextUsage).toBeUndefined();
+ expect(meta.summaryUsedTokens).toBe(260);
+ });
+
+ it('subtracts only this run’s earlier output, not a parallel run’s', () => {
+ const meta = buildMeta({
+ snap: snapshot(80),
+ latestUsageIndex: 2,
+ usageEvents: [primaryFor('run-2', 999), primaryFor('run-1', 40), primaryFor('run-1', 5)],
+ });
+ /** baseUsed 300 − run-1's earlier 40 = 260; run-2's 999 is ignored. */
+ expect(meta.summaryUsedTokens).toBe(260);
+ /** run-1's own primary follows the snapshot → snapshot persisted with output 5. */
+ expect(meta.contextUsage.completedOutputTokens).toBe(5);
+ });
+});
diff --git a/api/server/controllers/agents/__tests__/client.memory.spec.js b/api/server/controllers/agents/__tests__/client.memory.spec.js
new file mode 100644
index 0000000..de8eeb2
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/client.memory.spec.js
@@ -0,0 +1,74 @@
+const { EModelEndpoint, AgentCapabilities } = require('librechat-data-provider');
+
+/**
+ * Pins the capability-flag derivation that `AgentClient::useMemory` uses when
+ * it calls `initializeAgent` for the memory-extraction agent. The expression
+ * is trivial but lives in a controller path that's otherwise hard to unit-
+ * test, so a focused regression guard at the pure-logic layer ensures any
+ * drift in config-key names (`agents`, `capabilities`) or capability enum
+ * values (`execute_code`) surfaces here instead of silently stripping
+ * `bash_tool` + `read_file` from memory agents in production.
+ *
+ * The expression mirrored below is the one in
+ * `api/server/controllers/agents/client.js::useMemory`:
+ *
+ * new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities)
+ * .has(AgentCapabilities.execute_code)
+ */
+function deriveMemoryCodeEnvAvailable(appConfig) {
+ return new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities).has(
+ AgentCapabilities.execute_code,
+ );
+}
+
+describe('AgentClient::useMemory — codeEnvAvailable derivation', () => {
+ it('returns true when appConfig lists execute_code under the agents endpoint capabilities', () => {
+ expect(
+ deriveMemoryCodeEnvAvailable({
+ endpoints: {
+ [EModelEndpoint.agents]: {
+ capabilities: [AgentCapabilities.execute_code, AgentCapabilities.file_search],
+ },
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it('returns false when the agents endpoint omits execute_code', () => {
+ expect(
+ deriveMemoryCodeEnvAvailable({
+ endpoints: {
+ [EModelEndpoint.agents]: {
+ capabilities: [AgentCapabilities.file_search, AgentCapabilities.web_search],
+ },
+ },
+ }),
+ ).toBe(false);
+ });
+
+ it('returns false when the capabilities array is absent', () => {
+ expect(deriveMemoryCodeEnvAvailable({ endpoints: { [EModelEndpoint.agents]: {} } })).toBe(
+ false,
+ );
+ });
+
+ it('returns false when the agents endpoint config is absent', () => {
+ expect(deriveMemoryCodeEnvAvailable({ endpoints: {} })).toBe(false);
+ });
+
+ it('returns false when appConfig is null / undefined', () => {
+ /* Defensive — `req.config` can be unset in edge-case test harnesses and
+ ephemeral-agent flows; the memory path must not throw on access. */
+ expect(deriveMemoryCodeEnvAvailable(null)).toBe(false);
+ expect(deriveMemoryCodeEnvAvailable(undefined)).toBe(false);
+ });
+
+ it('matches the literal string "execute_code" — catches enum rename drift', () => {
+ /* Pins the capability enum value so a rename of `AgentCapabilities.execute_code`
+ that doesn't propagate to the controllers surfaces here. If this test breaks,
+ update the underlying expression in `useMemory` and the helpers in
+ `initialize.js` / `openai.js` / `responses.js` to match. */
+ expect(AgentCapabilities.execute_code).toBe('execute_code');
+ expect(EModelEndpoint.agents).toBe('agents');
+ });
+});
diff --git a/api/server/controllers/agents/__tests__/hitlCheckpoint.e2e.spec.js b/api/server/controllers/agents/__tests__/hitlCheckpoint.e2e.spec.js
new file mode 100644
index 0000000..29e594b
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/hitlCheckpoint.e2e.spec.js
@@ -0,0 +1,347 @@
+/**
+ * Full-wiring HITL checkpoint lifecycle e2e.
+ *
+ * Every HITL-specific component here is REAL: the `@librechat/agents` Run (driven by the
+ * SDK's FakeChatModel scripted to call a gated tool), the PreToolUse approval hook +
+ * `humanInTheLoop` wiring, the LazyMongoSaver over mongodb-memory-server, the
+ * GenerationJobManager (in-memory services), and the `/agents/chat/resume` controller via
+ * supertest. Only LibreChat's persistence adapters (`~/models`), request cleanup, and the
+ * concurrency gate are mocked. This is the cross-layer seam none of the unit suites cover:
+ * pause → durable checkpoint → HTTP approval → rebuilt-run resume → finalize prune.
+ */
+const express = require('express');
+const request = require('supertest');
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { z } = require('zod');
+const { tool } = require('@langchain/core/tools');
+const { HumanMessage } = require('@langchain/core/messages');
+const { Run, Providers, FakeChatModel } = require('@librechat/agents');
+
+const mockLogger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() };
+
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ logger: mockLogger,
+}));
+
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ checkAndIncrementPendingRequest: jest.fn(async () => ({ allowed: true })),
+ decrementPendingRequest: jest.fn(async () => {}),
+}));
+
+jest.mock('~/models', () => ({
+ saveMessage: jest.fn(async (req, message) => message),
+ getConvo: jest.fn(async () => null),
+ getMessages: jest.fn(async () => []),
+}));
+
+jest.mock('~/server/cleanup', () => ({
+ disposeClient: jest.fn(),
+}));
+
+jest.mock('~/server/services/MCPRequestContext', () => ({
+ getMCPRequestContext: jest.fn(() => null),
+ cleanupMCPRequestContextForReq: jest.fn(),
+}));
+
+// Import after mocks — these are the REAL implementations.
+const {
+ GenerationJobManager,
+ createStreamServices,
+ buildPendingAction,
+ getAgentCheckpointer,
+ deleteAgentCheckpoint,
+ buildHITLRunWiring,
+ resolveToolApprovalPolicy,
+ __resetCheckpointerForTests,
+} = require('@librechat/api');
+const ResumeAgentController = require('~/server/controllers/agents/resume');
+
+const USER_ID = 'hitl-e2e-user';
+const MONGO_CFG = { type: 'mongo', ttl: 3600 };
+const GATED_TOOL = 'guarded_echo';
+
+/** Side-effect counter: proves the gated tool runs exactly once across pause+resume. */
+let toolExecutions = 0;
+const guardedTool = tool(async ({ text }) => `echo:${text}`, {
+ name: GATED_TOOL,
+ description: 'Echoes text back, but requires human approval first.',
+ schema: z.object({ text: z.string() }),
+});
+guardedTool.func = async ({ text }) => {
+ toolExecutions += 1;
+ return `echo:${text}`;
+};
+
+/** Build a REAL run with the HITL wiring + durable checkpointer attached (mirrors createRun). */
+async function buildHitlRun({ saver, conversationId, responses, toolCalls, runId }) {
+ const hitl = buildHITLRunWiring(
+ resolveToolApprovalPolicy({ endpoint: { enabled: true, ask: [GATED_TOOL] } }),
+ { userId: USER_ID, conversationId, appConfig: {} },
+ );
+ const run = await Run.create({
+ runId,
+ graphConfig: {
+ type: 'standard',
+ llmConfig: {
+ provider: Providers.OPENAI,
+ model: 'gpt-4o-mini',
+ streaming: true,
+ streamUsage: false,
+ },
+ instructions: 'You are a helpful assistant.',
+ tools: [guardedTool],
+ compileOptions: { checkpointer: saver },
+ },
+ returnContent: true,
+ customHandlers: {},
+ tokenCounter: (text) => String(text ?? '').length,
+ indexTokenCountMap: {},
+ ...(hitl && { humanInTheLoop: hitl.humanInTheLoop, hooks: hitl.hooks }),
+ });
+ run.Graph.overrideModel = new FakeChatModel({ responses, toolCalls });
+ return run;
+}
+
+const runConfig = (conversationId) => ({
+ runName: 'AgentRun',
+ configurable: { thread_id: conversationId, user_id: USER_ID },
+ streamMode: 'values',
+ version: 'v2',
+});
+
+/** Poll until `predicate` returns true (the resume continuation is fire-and-forget). */
+async function waitFor(predicate, { timeoutMs = 10_000, intervalMs = 50 } = {}) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (await predicate()) {
+ return;
+ }
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
+ }
+ throw new Error('waitFor: condition not met within timeout');
+}
+
+async function checkpointCounts(conversationId) {
+ const db = mongoose.connection.db;
+ return {
+ checkpoints: await db
+ .collection('agent_checkpoints')
+ .countDocuments({ thread_id: conversationId }),
+ writes: await db
+ .collection('agent_checkpoint_writes')
+ .countDocuments({ thread_id: conversationId }),
+ };
+}
+
+let mongoServer;
+let saver;
+
+beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ await mongoose.connect(mongoServer.getUri());
+ __resetCheckpointerForTests();
+ saver = await getAgentCheckpointer(MONGO_CFG);
+
+ GenerationJobManager.configure({ ...createStreamServices(), cleanupOnComplete: false });
+ GenerationJobManager.initialize();
+ // Mirrors api/server/index.js: expiry prunes the paused run's durable checkpoint.
+ GenerationJobManager.setApprovalExpiredHandler(async (conversationId) => {
+ await deleteAgentCheckpoint(conversationId, MONGO_CFG);
+ });
+}, 60000);
+
+afterAll(async () => {
+ GenerationJobManager.setApprovalExpiredHandler(null);
+ await GenerationJobManager.destroy();
+ await mongoose.disconnect();
+ await mongoServer.stop();
+});
+
+beforeEach(() => {
+ toolExecutions = 0;
+ jest.clearAllMocks();
+});
+
+describe('HITL checkpoint lifecycle (full wiring)', () => {
+ jest.setTimeout(30000);
+
+ test('a clean turn (no tool gating triggered) persists NOTHING durable', async () => {
+ const conversationId = `e2e-clean-${Date.now()}`;
+ const run = await buildHitlRun({
+ saver,
+ conversationId,
+ responses: ['Hello there!'],
+ runId: 'resp-clean',
+ });
+ await run.processStream({ messages: [new HumanMessage('hi')] }, runConfig(conversationId));
+
+ expect(run.getInterrupt?.()).toBeFalsy();
+ expect(await checkpointCounts(conversationId)).toEqual({ checkpoints: 0, writes: 0 });
+ });
+
+ test('a turn that ERRORS before pausing persists NOTHING durable', async () => {
+ const conversationId = `e2e-error-${Date.now()}`;
+ const run = await buildHitlRun({
+ saver,
+ conversationId,
+ responses: ['unused'],
+ runId: 'resp-error',
+ });
+ class BoomModel extends FakeChatModel {
+ // eslint-disable-next-line require-yield
+ async *_streamResponseChunks() {
+ throw new Error('model boom');
+ }
+ }
+ run.Graph.overrideModel = new BoomModel({ responses: ['unused'] });
+
+ await expect(
+ run.processStream({ messages: [new HumanMessage('hi')] }, runConfig(conversationId)),
+ ).rejects.toThrow('model boom');
+
+ expect(await checkpointCounts(conversationId)).toEqual({ checkpoints: 0, writes: 0 });
+ });
+
+ test('pause → approve over the REAL /resume controller → tool runs once → checkpoint pruned', async () => {
+ const conversationId = `e2e-resume-${Date.now()}`;
+ const responseMessageId = 'resp-pause-1';
+
+ // --- Turn 1: the model calls the gated tool → PreToolUse 'ask' → interrupt. ---
+ const run = await buildHitlRun({
+ saver,
+ conversationId,
+ responses: ['Let me run that.'],
+ toolCalls: [{ name: GATED_TOOL, args: { text: 'hi' }, id: 'tc_1', type: 'tool_call' }],
+ runId: responseMessageId,
+ });
+ await run.processStream(
+ { messages: [new HumanMessage('run the guarded tool')] },
+ runConfig(conversationId),
+ );
+
+ const interrupt = run.getInterrupt();
+ expect(interrupt?.payload?.type).toBe('tool_approval');
+ expect(toolExecutions).toBe(0); // gated — must NOT have run pre-approval
+ const paused = await checkpointCounts(conversationId);
+ expect(paused.checkpoints).toBeGreaterThan(0); // the interrupt checkpoint is durable
+
+ // --- Pause bookkeeping (mirrors AgentClient.handleRunInterrupt). ---
+ const job = await GenerationJobManager.createJob(conversationId, USER_ID, conversationId);
+ await GenerationJobManager.updateMetadata(conversationId, {
+ endpoint: 'agents',
+ agent_id: 'agent-e2e',
+ responseMessageId,
+ });
+ const pendingAction = buildPendingAction(interrupt.payload, {
+ streamId: conversationId,
+ conversationId,
+ runId: responseMessageId,
+ responseMessageId,
+ ttlMs: 60_000,
+ });
+ expect(await GenerationJobManager.approvals.pause(conversationId, pendingAction)).toBe(true);
+
+ // --- Turn 2: approve through the REAL controller; the thin client rebuilds a REAL run. ---
+ const thinClient = {
+ contentParts: [],
+ artifactPromises: [],
+ conversationId,
+ responseMessageId,
+ pendingApproval: null,
+ async resumeCompletion({ resumeValue, abortController }) {
+ const resumed = await buildHitlRun({
+ saver,
+ conversationId,
+ responses: ['Done after approval.'],
+ runId: responseMessageId,
+ });
+ await resumed.resume(resumeValue, {
+ ...runConfig(conversationId),
+ signal: (abortController ?? new AbortController()).signal,
+ });
+ const reInterrupt = resumed.getInterrupt?.();
+ if (reInterrupt?.payload) {
+ this.pendingApproval = reInterrupt.payload;
+ }
+ this.contentParts.push({ type: 'text', text: 'Done after approval.' });
+ return resumed;
+ },
+ };
+ const initializeClient = jest.fn(async () => ({ client: thinClient }));
+ const addTitle = jest.fn();
+
+ const app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ req.user = { id: USER_ID };
+ req.config = { endpoints: { agents: { checkpointer: MONGO_CFG } }, interfaceConfig: {} };
+ next();
+ });
+ app.post('/api/agents/chat/resume', (req, res, next) =>
+ ResumeAgentController(req, res, next, initializeClient, addTitle),
+ );
+
+ const response = await request(app)
+ .post('/api/agents/chat/resume')
+ .send({
+ conversationId,
+ actionId: pendingAction.actionId,
+ agent_id: 'agent-e2e',
+ endpoint: 'agents',
+ decisions: [{ tool_call_id: 'tc_1', decision: 'approve' }],
+ });
+
+ // The controller ACKs immediately ({ status: 'resuming' }) and drives the resumed run
+ // asynchronously — wait for the terminal side effects before asserting.
+ expect(response.status).toBe(200);
+ expect(response.body.status).toBe('resuming');
+ await waitFor(async () => {
+ const liveJob = await GenerationJobManager.getJob(conversationId);
+ return liveJob?.status !== 'requires_action' && liveJob?.status !== 'running';
+ });
+
+ expect(initializeClient).toHaveBeenCalledTimes(1);
+ expect(toolExecutions).toBe(1); // approved tool ran exactly ONCE across pause+resume
+
+ // Terminal state: the checkpoint was pruned by the REAL finalize path.
+ await waitFor(async () => (await checkpointCounts(conversationId)).checkpoints === 0);
+ expect(await checkpointCounts(conversationId)).toEqual({ checkpoints: 0, writes: 0 });
+
+ expect(job).toBeDefined();
+ });
+
+ test('an abandoned pause is pruned eagerly on approval EXPIRY (not left to the TTL)', async () => {
+ const conversationId = `e2e-expiry-${Date.now()}`;
+ const run = await buildHitlRun({
+ saver,
+ conversationId,
+ responses: ['Let me run that.'],
+ toolCalls: [{ name: GATED_TOOL, args: { text: 'x' }, id: 'tc_exp', type: 'tool_call' }],
+ runId: 'resp-expire',
+ });
+ await run.processStream({ messages: [new HumanMessage('run it')] }, runConfig(conversationId));
+ const interrupt = run.getInterrupt();
+ expect((await checkpointCounts(conversationId)).checkpoints).toBeGreaterThan(0);
+
+ await GenerationJobManager.createJob(conversationId, USER_ID, conversationId);
+ const pendingAction = buildPendingAction(interrupt.payload, {
+ streamId: conversationId,
+ conversationId,
+ runId: 'resp-expire',
+ responseMessageId: 'resp-expire',
+ ttlMs: 60_000,
+ });
+ await GenerationJobManager.approvals.pause(conversationId, pendingAction);
+
+ // The sweeper/stale-submit path: expiry fires the registered checkpoint prune.
+ expect(await GenerationJobManager.expireApproval(conversationId, pendingAction.actionId)).toBe(
+ true,
+ );
+
+ expect(await GenerationJobManager.getJobStatus(conversationId)).toBe('aborted');
+ expect(await checkpointCounts(conversationId)).toEqual({ checkpoints: 0, writes: 0 });
+ });
+});
diff --git a/api/server/controllers/agents/__tests__/jobReplacement.spec.js b/api/server/controllers/agents/__tests__/jobReplacement.spec.js
new file mode 100644
index 0000000..ec70b66
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/jobReplacement.spec.js
@@ -0,0 +1,597 @@
+/**
+ * Tests for job replacement detection in ResumableAgentController
+ *
+ * Tests the following fixes from PR #11462:
+ * 1. Job creation timestamp tracking
+ * 2. Stale job detection and event skipping
+ * 3. Response message saving before final event emission
+ */
+
+const mockLogger = {
+ debug: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ info: jest.fn(),
+};
+
+const mockGenerationJobManager = {
+ createJob: jest.fn(),
+ getJob: jest.fn(),
+ emitDone: jest.fn(),
+ emitChunk: jest.fn(),
+ completeJob: jest.fn(),
+ updateMetadata: jest.fn(),
+ setContentParts: jest.fn(),
+ subscribe: jest.fn(),
+};
+
+const mockSaveMessage = jest.fn();
+const mockDecrementPendingRequest = jest.fn();
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: mockLogger,
+}));
+
+jest.mock('@librechat/api', () => ({
+ isEnabled: jest.fn().mockReturnValue(false),
+ GenerationJobManager: mockGenerationJobManager,
+ getReferencedQuotes: jest.fn((quotes) => {
+ if (!Array.isArray(quotes)) {
+ return null;
+ }
+ const normalized = quotes
+ .filter((quote) => typeof quote === 'string' && quote.trim().length > 0)
+ .map((quote) => quote.trim());
+ return normalized.length > 0 ? normalized : null;
+ }),
+ checkAndIncrementPendingRequest: jest.fn().mockResolvedValue({ allowed: true }),
+ decrementPendingRequest: (...args) => mockDecrementPendingRequest(...args),
+ getViolationInfo: jest.fn(),
+ sanitizeMessageForTransmit: jest.fn((msg) => msg),
+ sanitizeFileForTransmit: jest.fn((file) => file),
+ Constants: { NO_PARENT: '00000000-0000-0000-0000-000000000000' },
+}));
+
+jest.mock('~/models', () => ({
+ saveMessage: (...args) => mockSaveMessage(...args),
+}));
+
+describe('Job Replacement Detection', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('Job Creation Timestamp Tracking', () => {
+ it('should capture createdAt when job is created', async () => {
+ const streamId = 'test-stream-123';
+ const createdAt = Date.now();
+
+ mockGenerationJobManager.createJob.mockResolvedValue({
+ createdAt,
+ readyPromise: Promise.resolve(),
+ abortController: new AbortController(),
+ emitter: { on: jest.fn() },
+ });
+
+ const job = await mockGenerationJobManager.createJob(streamId, 'user-123', streamId);
+
+ expect(job.createdAt).toBe(createdAt);
+ });
+ });
+
+ describe('Job Replacement Detection Logic', () => {
+ /**
+ * Simulates the job replacement detection logic from request.js
+ * This is extracted for unit testing since the full controller is complex
+ */
+ const detectJobReplacement = async (streamId, originalCreatedAt) => {
+ const currentJob = await mockGenerationJobManager.getJob(streamId);
+ return !currentJob || currentJob.createdAt !== originalCreatedAt;
+ };
+
+ it('should detect when job was replaced (different createdAt)', async () => {
+ const streamId = 'test-stream-123';
+ const originalCreatedAt = 1000;
+ const newCreatedAt = 2000;
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ createdAt: newCreatedAt,
+ });
+
+ const wasReplaced = await detectJobReplacement(streamId, originalCreatedAt);
+
+ expect(wasReplaced).toBe(true);
+ });
+
+ it('should detect when job was deleted', async () => {
+ const streamId = 'test-stream-123';
+ const originalCreatedAt = 1000;
+
+ mockGenerationJobManager.getJob.mockResolvedValue(null);
+
+ const wasReplaced = await detectJobReplacement(streamId, originalCreatedAt);
+
+ expect(wasReplaced).toBe(true);
+ });
+
+ it('should not detect replacement when same job (same createdAt)', async () => {
+ const streamId = 'test-stream-123';
+ const originalCreatedAt = 1000;
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ createdAt: originalCreatedAt,
+ });
+
+ const wasReplaced = await detectJobReplacement(streamId, originalCreatedAt);
+
+ expect(wasReplaced).toBe(false);
+ });
+ });
+
+ describe('Event Emission Behavior', () => {
+ /**
+ * Simulates the final event emission logic from request.js
+ */
+ const emitFinalEventIfNotReplaced = async ({
+ streamId,
+ originalCreatedAt,
+ finalEvent,
+ userId,
+ }) => {
+ const currentJob = await mockGenerationJobManager.getJob(streamId);
+ const jobWasReplaced = !currentJob || currentJob.createdAt !== originalCreatedAt;
+
+ if (jobWasReplaced) {
+ mockLogger.debug('Skipping FINAL emit - job was replaced', {
+ streamId,
+ originalCreatedAt,
+ currentCreatedAt: currentJob?.createdAt,
+ });
+ await mockDecrementPendingRequest(userId);
+ return false;
+ }
+
+ mockGenerationJobManager.emitDone(streamId, finalEvent);
+ mockGenerationJobManager.completeJob(streamId);
+ await mockDecrementPendingRequest(userId);
+ return true;
+ };
+
+ it('should skip emitting when job was replaced', async () => {
+ const streamId = 'test-stream-123';
+ const originalCreatedAt = 1000;
+ const newCreatedAt = 2000;
+ const userId = 'user-123';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ createdAt: newCreatedAt,
+ });
+
+ const emitted = await emitFinalEventIfNotReplaced({
+ streamId,
+ originalCreatedAt,
+ finalEvent: { final: true },
+ userId,
+ });
+
+ expect(emitted).toBe(false);
+ expect(mockGenerationJobManager.emitDone).not.toHaveBeenCalled();
+ expect(mockGenerationJobManager.completeJob).not.toHaveBeenCalled();
+ expect(mockDecrementPendingRequest).toHaveBeenCalledWith(userId);
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ 'Skipping FINAL emit - job was replaced',
+ expect.objectContaining({
+ streamId,
+ originalCreatedAt,
+ currentCreatedAt: newCreatedAt,
+ }),
+ );
+ });
+
+ it('should emit when job was not replaced', async () => {
+ const streamId = 'test-stream-123';
+ const originalCreatedAt = 1000;
+ const userId = 'user-123';
+ const finalEvent = { final: true, conversation: { conversationId: streamId } };
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ createdAt: originalCreatedAt,
+ });
+
+ const emitted = await emitFinalEventIfNotReplaced({
+ streamId,
+ originalCreatedAt,
+ finalEvent,
+ userId,
+ });
+
+ expect(emitted).toBe(true);
+ expect(mockGenerationJobManager.emitDone).toHaveBeenCalledWith(streamId, finalEvent);
+ expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(streamId);
+ expect(mockDecrementPendingRequest).toHaveBeenCalledWith(userId);
+ });
+ });
+
+ describe('Response Message Saving Order', () => {
+ /**
+ * Tests that response messages are saved BEFORE final events are emitted
+ * This prevents race conditions where clients send follow-up messages
+ * before the response is in the database
+ */
+ it('should save message before emitting final event', async () => {
+ const callOrder = [];
+
+ mockSaveMessage.mockImplementation(async () => {
+ callOrder.push('saveMessage');
+ });
+
+ mockGenerationJobManager.emitDone.mockImplementation(() => {
+ callOrder.push('emitDone');
+ });
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ createdAt: 1000,
+ });
+
+ // Simulate the order of operations from request.js
+ const streamId = 'test-stream-123';
+ const originalCreatedAt = 1000;
+ const response = { messageId: 'response-123' };
+ const userId = 'user-123';
+
+ // Step 1: Save message
+ await mockSaveMessage({}, { ...response, user: userId }, { context: 'test' });
+
+ // Step 2: Check for replacement
+ const currentJob = await mockGenerationJobManager.getJob(streamId);
+ const jobWasReplaced = !currentJob || currentJob.createdAt !== originalCreatedAt;
+
+ // Step 3: Emit if not replaced
+ if (!jobWasReplaced) {
+ mockGenerationJobManager.emitDone(streamId, { final: true });
+ }
+
+ expect(callOrder).toEqual(['saveMessage', 'emitDone']);
+ });
+ });
+
+ describe('Aborted Request Handling', () => {
+ it('should use unfinished: true instead of error: true for aborted requests', () => {
+ const response = { messageId: 'response-123', content: [] };
+
+ // The new format for aborted responses
+ const abortedResponse = { ...response, unfinished: true };
+
+ expect(abortedResponse.unfinished).toBe(true);
+ expect(abortedResponse.error).toBeUndefined();
+ });
+
+ it('should include unfinished flag in final event for aborted requests', () => {
+ const response = { messageId: 'response-123', content: [] };
+
+ // Old format (deprecated)
+ const _oldFinalEvent = {
+ final: true,
+ responseMessage: { ...response, error: true },
+ error: { message: 'Request was aborted' },
+ };
+
+ // New format (PR #11462)
+ const newFinalEvent = {
+ final: true,
+ responseMessage: { ...response, unfinished: true },
+ };
+
+ expect(newFinalEvent.responseMessage.unfinished).toBe(true);
+ expect(newFinalEvent.error).toBeUndefined();
+ expect(newFinalEvent.responseMessage.error).toBeUndefined();
+ });
+ });
+});
+
+/**
+ * HITL terminal-side-effect guards (PR #13942).
+ *
+ * Jobs are keyed by streamId == conversationId, so a NEW request REPLACES the running
+ * one on the same conversation. The replaced generation's tail (its pause attempt, its
+ * checkpoint prune, its resume catch-path terminal writes) must not clobber the live
+ * generation's state. Each guard re-reads the live job and compares createdAt against the
+ * generation's own captured identity before acting. These mirror the predicates in
+ * client.js (handleRunInterrupt / chatCompletion finally) and resume.js.
+ */
+describe('HITL Terminal-Side-Effect Guards', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('F22 — pause is skipped when the generation was replaced', () => {
+ // Mirrors client.js handleRunInterrupt pre-check, run BEFORE approvals.pause.
+ const shouldPause = async ({ jobCreatedAt, streamId }) => {
+ if (jobCreatedAt != null) {
+ const liveJob = await mockGenerationJobManager.getJob(streamId);
+ if (!liveJob || liveJob.createdAt !== jobCreatedAt) {
+ return false;
+ }
+ }
+ return true;
+ };
+
+ it('does not pause when a newer job replaced this one', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 2000 });
+ expect(await shouldPause({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(false);
+ });
+
+ it('does not pause when the job is already gone', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(null);
+ expect(await shouldPause({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(false);
+ });
+
+ it('pauses when this is still the live job', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 1000 });
+ expect(await shouldPause({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(true);
+ });
+
+ it('pauses without a lookup when identity is unknown (legacy job)', async () => {
+ expect(await shouldPause({ jobCreatedAt: null, streamId: 'c1' })).toBe(true);
+ expect(mockGenerationJobManager.getJob).not.toHaveBeenCalled();
+ });
+ });
+
+ // (Removed: F21 — the chatCompletion clean-path checkpoint prune + its job-replacement
+ // guard no longer exist. The lazy checkpointer never writes a clean-exit checkpoint, so
+ // there is nothing to prune after a non-paused turn; the pre-run prune (before
+ // processStream) clears any orphaned interrupt checkpoint instead. See
+ // checkpointer.ts LazyMongoSaver and client.js chatCompletion.)
+
+ describe('F24 — resume catch-path terminal writes are skipped when replaced', () => {
+ // Mirrors resume.js: stillLive gate around emitError/completeJob/deleteAgentCheckpoint.
+ const stillLive = async ({ streamId, jobCreatedAt }) => {
+ let live = true;
+ try {
+ const liveJob = await mockGenerationJobManager.getJob(streamId);
+ live = !!liveJob && liveJob.createdAt === jobCreatedAt;
+ } catch {
+ live = true; // read failed — fail open and run the terminal writes
+ }
+ return live;
+ };
+
+ it('runs terminal writes when this is still the live job', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 1000 });
+ expect(await stillLive({ streamId: 'c1', jobCreatedAt: 1000 })).toBe(true);
+ });
+
+ it('skips terminal writes when a newer job replaced this one', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 2000 });
+ expect(await stillLive({ streamId: 'c1', jobCreatedAt: 1000 })).toBe(false);
+ });
+
+ it('fails open (runs terminal writes) when the liveness read throws', async () => {
+ mockGenerationJobManager.getJob.mockRejectedValue(new Error('store down'));
+ expect(await stillLive({ streamId: 'c1', jobCreatedAt: 1000 })).toBe(true);
+ });
+ });
+
+ describe('F23 — resumed turn sources files from the job, not the racy DB row', () => {
+ // Mirrors resume.js: prefer the body, then job.metadata.userMessage.files, then DB.
+ const resolveFiles = ({ bodyFiles, metaFiles, dbFiles }) => {
+ if (Array.isArray(bodyFiles) && bodyFiles.length > 0) {
+ return bodyFiles;
+ }
+ if (Array.isArray(metaFiles) && metaFiles.length > 0) {
+ return metaFiles;
+ }
+ return Array.isArray(dbFiles) && dbFiles.length > 0 ? dbFiles : undefined;
+ };
+
+ it('prefers job-metadata files over the DB row (no DB-save race)', () => {
+ expect(
+ resolveFiles({
+ bodyFiles: [],
+ metaFiles: [{ file_id: 'meta' }],
+ dbFiles: [{ file_id: 'db' }],
+ }),
+ ).toEqual([{ file_id: 'meta' }]);
+ });
+
+ it('falls back to the DB row when the job has no persisted files (older job)', () => {
+ expect(
+ resolveFiles({ bodyFiles: [], metaFiles: undefined, dbFiles: [{ file_id: 'db' }] }),
+ ).toEqual([{ file_id: 'db' }]);
+ });
+
+ it('keeps files already present on the resume body', () => {
+ expect(
+ resolveFiles({
+ bodyFiles: [{ file_id: 'body' }],
+ metaFiles: [{ file_id: 'meta' }],
+ dbFiles: [],
+ }),
+ ).toEqual([{ file_id: 'body' }]);
+ });
+ });
+});
+
+/**
+ * Round-18 follow-ups to the guards above (Codex review 4594099963).
+ */
+describe('HITL Resume Fidelity Guards (round 18)', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('G1 — resume re-checks ownership AGAIN right before terminal writes', () => {
+ // The start-of-finalize guard can go stale across saveMessage + title generation,
+ // so resume.js re-reads the live job immediately before emitDone/completeJob/prune.
+ // Same predicate as the catch-path (F24), applied at the success path's second point.
+ const stillLiveBeforeFinalize = async ({ streamId, jobCreatedAt }) => {
+ const liveJob = await mockGenerationJobManager.getJob(streamId);
+ return !!liveJob && liveJob.createdAt === jobCreatedAt;
+ };
+
+ it('runs terminal writes when still the live job at the second check', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 1000 });
+ expect(await stillLiveBeforeFinalize({ streamId: 'c1', jobCreatedAt: 1000 })).toBe(true);
+ });
+
+ it('skips terminal writes when replaced DURING finalize (after the first check passed)', async () => {
+ // First check passed earlier with createdAt 1000; a new request replaced it to 2000
+ // while saveMessage + title generation awaited. The second check must catch it.
+ mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 2000 });
+ expect(await stillLiveBeforeFinalize({ streamId: 'c1', jobCreatedAt: 1000 })).toBe(false);
+ });
+ });
+
+ describe('G2 — uploaded files are seeded into the AWAITED preliminary user message', () => {
+ // Mirrors getPreliminaryUserMessage: files from the request are persisted on the
+ // preliminary (awaited, pre-run) metadata so they land before any interrupt emits.
+ const buildPreliminaryUserMessage = ({ messageId, files }) => {
+ if (typeof messageId !== 'string' || messageId.length === 0) {
+ return null;
+ }
+ return {
+ messageId,
+ ...(Array.isArray(files) && files.length > 0 && { files }),
+ };
+ };
+
+ it('includes files when the request carries them', () => {
+ const msg = buildPreliminaryUserMessage({ messageId: 'm1', files: [{ file_id: 'a' }] });
+ expect(msg.files).toEqual([{ file_id: 'a' }]);
+ });
+
+ it('omits files when none were uploaded (no empty array)', () => {
+ const msg = buildPreliminaryUserMessage({ messageId: 'm1', files: [] });
+ expect(msg).not.toHaveProperty('files');
+ });
+ });
+
+ describe('G3 — resume replays pre-pause discovered deferred tools', () => {
+ // Mirrors createRun's merge: discovered set is union(message-extracted, replayed),
+ // gated entirely on the agent actually having deferred tools.
+ const resolveDiscovered = ({ hasAnyDeferredTools, messageExtracted, replayed }) => {
+ const set = new Set();
+ if (hasAnyDeferredTools) {
+ for (const n of messageExtracted ?? []) {
+ set.add(n);
+ }
+ for (const n of replayed ?? []) {
+ set.add(n);
+ }
+ }
+ return set;
+ };
+
+ it('replays captured names on resume (messages empty) so the paused tool is present', () => {
+ const set = resolveDiscovered({
+ hasAnyDeferredTools: true,
+ messageExtracted: [],
+ replayed: ['deep_tool'],
+ });
+ expect(set.has('deep_tool')).toBe(true);
+ });
+
+ it('unions replayed names with message-extracted names', () => {
+ const set = resolveDiscovered({
+ hasAnyDeferredTools: true,
+ messageExtracted: ['from_history'],
+ replayed: ['deep_tool'],
+ });
+ expect([...set].sort()).toEqual(['deep_tool', 'from_history']);
+ });
+
+ it('is inert when the agent has no deferred tools', () => {
+ const set = resolveDiscovered({
+ hasAnyDeferredTools: false,
+ messageExtracted: ['x'],
+ replayed: ['deep_tool'],
+ });
+ expect(set.size).toBe(0);
+ });
+ });
+
+ describe("H3 — resume replays the paused turn's model parameters (ephemeral agents)", () => {
+ // Mirrors restoreResumeContext: spread persisted model_parameters back onto the body,
+ // excluding `model` (replayed via the fingerprinted RESUME_CONTEXT_KEYS path).
+ const replayModelParameters = (body, resumeContext) => {
+ const params = resumeContext?.model_parameters;
+ if (params && typeof params === 'object') {
+ const { model: _model, ...rest } = params;
+ Object.assign(body, rest);
+ }
+ return body;
+ };
+
+ it('restores non-default params (temperature, max tokens) onto the resume body', () => {
+ const body = { conversationId: 'c1', endpoint: 'agents' };
+ replayModelParameters(body, {
+ model_parameters: { model: 'gpt-4o', temperature: 0.2, max_tokens: 1024 },
+ });
+ expect(body).toMatchObject({ temperature: 0.2, max_tokens: 1024 });
+ });
+
+ it('does NOT overwrite model (kept consistent with the resume fingerprint)', () => {
+ const body = { model: 'pinned-model' };
+ replayModelParameters(body, { model_parameters: { model: 'other-model', temperature: 0.9 } });
+ expect(body.model).toBe('pinned-model');
+ });
+
+ it('overwrites a client-supplied param with the captured authoritative value', () => {
+ const body = { temperature: 1.0 }; // crafted/stale client value
+ replayModelParameters(body, { model_parameters: { temperature: 0.2 } });
+ expect(body.temperature).toBe(0.2);
+ });
+
+ it('is a no-op when nothing was captured', () => {
+ const body = { conversationId: 'c1' };
+ replayModelParameters(body, {});
+ expect(body).toEqual({ conversationId: 'c1' });
+ });
+ });
+
+ describe('J2 — pause unfinished-save is skipped once a fast resume took over', () => {
+ // Mirrors request.js: only mark the paused row unfinished while the job is STILL paused
+ // on THIS generation's action. A claim transitions it out of requires_action and a
+ // replacement bumps createdAt — either means a /resume now owns the row, so marking it
+ // unfinished would clobber the resumed turn's completed content. Fail open on read error.
+ const shouldMarkUnfinished = async ({ jobCreatedAt, streamId }) => {
+ let stillPaused = true;
+ try {
+ const liveJob = await mockGenerationJobManager.getJob(streamId);
+ stillPaused =
+ !!liveJob &&
+ liveJob.status === 'requires_action' &&
+ (jobCreatedAt == null || liveJob.createdAt === jobCreatedAt);
+ } catch {
+ stillPaused = true;
+ }
+ return stillPaused;
+ };
+
+ it('marks unfinished while still paused on this generation', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ status: 'requires_action',
+ createdAt: 1000,
+ });
+ expect(await shouldMarkUnfinished({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(true);
+ });
+
+ it('skips the unfinished-save once a fast resume claimed it (no longer requires_action)', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue({ status: 'running', createdAt: 1000 });
+ expect(await shouldMarkUnfinished({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(false);
+ });
+
+ it('skips the unfinished-save when a newer request replaced the job', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ status: 'requires_action',
+ createdAt: 2000,
+ });
+ expect(await shouldMarkUnfinished({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(false);
+ });
+
+ it('fails open (marks unfinished) when the liveness read throws', async () => {
+ mockGenerationJobManager.getJob.mockRejectedValue(new Error('store down'));
+ expect(await shouldMarkUnfinished({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(true);
+ });
+ });
+});
diff --git a/api/server/controllers/agents/__tests__/modelEndHandler.spec.js b/api/server/controllers/agents/__tests__/modelEndHandler.spec.js
new file mode 100644
index 0000000..07c55e7
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/modelEndHandler.spec.js
@@ -0,0 +1,195 @@
+jest.mock('@librechat/data-schemas', () => ({
+ logger: { error: jest.fn(), debug: jest.fn() },
+}));
+jest.mock('@librechat/api', () => ({
+ sendEvent: jest.fn(),
+ emitEvent: jest.fn(),
+ createToolExecuteHandler: jest.fn(),
+ markSummarizationUsage: (usage) => usage,
+}));
+jest.mock('~/server/services/Files/Citations', () => ({
+ processFileCitations: jest.fn(),
+}));
+jest.mock('~/server/services/Files/Code/process', () => ({
+ processCodeOutput: jest.fn(),
+ runPreviewFinalize: jest.fn(),
+}));
+jest.mock('~/server/services/Files/process', () => ({
+ saveBase64Image: jest.fn(),
+}));
+
+const { ModelEndHandler } = require('../callbacks');
+
+const buildGraph = () => ({
+ getAgentContext: () => ({
+ provider: 'vertexai',
+ clientOptions: { model: 'gemini-3.1-flash-lite-preview' },
+ }),
+});
+
+describe('ModelEndHandler — Vertex thoughtSignature capture (issue #13006 follow-up)', () => {
+ it('maps non-empty signatures onto tool_call_ids in order', async () => {
+ const collectedUsage = [];
+ const collectedThoughtSignatures = {};
+ const handler = new ModelEndHandler(collectedUsage, collectedThoughtSignatures);
+
+ await handler.handle(
+ 'on_chat_model_end',
+ {
+ output: {
+ usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
+ tool_calls: [
+ { id: 'tc_a', name: 'a', args: {} },
+ { id: 'tc_b', name: 'b', args: {} },
+ ],
+ additional_kwargs: { signatures: ['SIG_A', '', 'SIG_B'] },
+ },
+ },
+ { ls_model_name: 'gemini-3.1-flash-lite-preview', user_id: 'u1' },
+ buildGraph(),
+ );
+
+ expect(collectedThoughtSignatures).toEqual({ tc_a: 'SIG_A', tc_b: 'SIG_B' });
+ expect(collectedUsage).toHaveLength(1);
+ });
+
+ it('accumulates per-id across multiple model_end events (multi-step tool turn)', async () => {
+ const collectedUsage = [];
+ const collectedThoughtSignatures = {};
+ const handler = new ModelEndHandler(collectedUsage, collectedThoughtSignatures);
+
+ await handler.handle(
+ 'on_chat_model_end',
+ {
+ output: {
+ usage_metadata: { input_tokens: 5, output_tokens: 5, total_tokens: 10 },
+ tool_calls: [{ id: 'tc_step1', name: 'a', args: {} }],
+ additional_kwargs: { signatures: ['SIG_step1'] },
+ },
+ },
+ { ls_model_name: 'g', user_id: 'u' },
+ buildGraph(),
+ );
+ await handler.handle(
+ 'on_chat_model_end',
+ {
+ output: {
+ usage_metadata: { input_tokens: 5, output_tokens: 5, total_tokens: 10 },
+ tool_calls: [{ id: 'tc_step2', name: 'b', args: {} }],
+ additional_kwargs: { signatures: ['SIG_step2'] },
+ },
+ },
+ { ls_model_name: 'g', user_id: 'u' },
+ buildGraph(),
+ );
+
+ expect(collectedThoughtSignatures).toEqual({
+ tc_step1: 'SIG_step1',
+ tc_step2: 'SIG_step2',
+ });
+ });
+
+ it('is a no-op for signatures when collectedThoughtSignatures is null', async () => {
+ const collectedUsage = [];
+ const handler = new ModelEndHandler(collectedUsage, null);
+
+ await handler.handle(
+ 'on_chat_model_end',
+ {
+ output: {
+ usage_metadata: { input_tokens: 5, output_tokens: 5, total_tokens: 10 },
+ tool_calls: [{ id: 'tc1', name: 'a', args: {} }],
+ additional_kwargs: { signatures: ['SIG'] },
+ },
+ },
+ { ls_model_name: 'g', user_id: 'u' },
+ buildGraph(),
+ );
+
+ expect(collectedUsage).toHaveLength(1);
+ });
+
+ it('does not store anything when signatures field is missing (non-Vertex providers)', async () => {
+ const collectedUsage = [];
+ const collectedThoughtSignatures = {};
+ const handler = new ModelEndHandler(collectedUsage, collectedThoughtSignatures);
+
+ await handler.handle(
+ 'on_chat_model_end',
+ {
+ output: {
+ usage_metadata: { input_tokens: 5, output_tokens: 5, total_tokens: 10 },
+ tool_calls: [{ id: 'tc1', name: 'a', args: {} }],
+ additional_kwargs: {},
+ },
+ },
+ { ls_model_name: 'gpt-4', user_id: 'u' },
+ buildGraph(),
+ );
+
+ expect(collectedThoughtSignatures).toEqual({});
+ });
+
+ it('does not store anything when tool_calls is missing', async () => {
+ const collectedUsage = [];
+ const collectedThoughtSignatures = {};
+ const handler = new ModelEndHandler(collectedUsage, collectedThoughtSignatures);
+
+ await handler.handle(
+ 'on_chat_model_end',
+ {
+ output: {
+ usage_metadata: { input_tokens: 5, output_tokens: 5, total_tokens: 10 },
+ additional_kwargs: { signatures: ['SIG_orphan'] },
+ },
+ },
+ { ls_model_name: 'g', user_id: 'u' },
+ buildGraph(),
+ );
+
+ expect(collectedThoughtSignatures).toEqual({});
+ });
+
+ it('tags the producing agent on collected + emitted usage for per-endpoint pricing', async () => {
+ const collectedUsage = [];
+ const emitUsage = jest.fn();
+ const handler = new ModelEndHandler(collectedUsage, null, emitUsage);
+ const graph = {
+ getAgentContext: () => ({
+ provider: 'openai',
+ agentId: 'agent_sub',
+ clientOptions: { model: 'gpt-4' },
+ }),
+ };
+
+ await handler.handle(
+ 'on_chat_model_end',
+ { output: { usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } } },
+ { ls_model_name: 'gpt-4', run_id: 'r1', user_id: 'u1' },
+ graph,
+ );
+
+ expect(collectedUsage[0].agentId).toBe('agent_sub');
+ expect(emitUsage).toHaveBeenCalledWith(expect.objectContaining({ agentId: 'agent_sub' }));
+ });
+
+ it('leaves usage untagged when the graph context has no agentId (single-endpoint)', async () => {
+ const collectedUsage = [];
+ const emitUsage = jest.fn();
+ const handler = new ModelEndHandler(collectedUsage, null, emitUsage);
+
+ await handler.handle(
+ 'on_chat_model_end',
+ { output: { usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } } },
+ { ls_model_name: 'gemini-3.1-flash-lite-preview', run_id: 'r1', user_id: 'u1' },
+ buildGraph(),
+ );
+
+ expect(collectedUsage[0].agentId).toBeUndefined();
+ expect(emitUsage).toHaveBeenCalledWith(expect.objectContaining({ agentId: undefined }));
+ });
+
+ it('throws when collectedUsage is not an array (existing contract)', () => {
+ expect(() => new ModelEndHandler(null)).toThrow('collectedUsage must be an array');
+ });
+});
diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js
new file mode 100644
index 0000000..8b6910f
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/openai.spec.js
@@ -0,0 +1,511 @@
+/**
+ * Unit tests for OpenAI-compatible API controller
+ * 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
+ .fn()
+ .mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
+const mockGetBalanceConfig = jest.fn().mockReturnValue({ enabled: true });
+const mockGetTransactionsConfig = jest.fn().mockReturnValue({ enabled: true });
+const mockBuildSkillPrimedIdsByName = jest.fn((manualSkillPrimes, alwaysApplySkillPrimes) => {
+ const primed = {};
+ for (const skill of alwaysApplySkillPrimes ?? []) {
+ primed[skill.name] = skill._id.toString();
+ }
+ for (const skill of manualSkillPrimes ?? []) {
+ primed[skill.name] = skill._id.toString();
+ }
+ return Object.keys(primed).length > 0 ? primed : undefined;
+});
+const mockEnrichWithSkillConfigurable = jest.fn((result) => result);
+const mockBuildAgentToolContext = jest.fn(({ agent, config }) => ({
+ agent,
+ toolRegistry: config.toolRegistry,
+ userMCPAuthMap: config.userMCPAuthMap,
+ tool_resources: config.tool_resources,
+ actionsEnabled: config.actionsEnabled,
+ accessibleSkillIds: config.accessibleSkillIds,
+ activeSkillNames: config.activeSkillNames,
+ codeEnvAvailable: config.codeEnvAvailable,
+ skillAuthoringAvailable: config.skillAuthoringAvailable,
+ fileAuthoringToolNames: config.fileAuthoringToolNames,
+ skillPrimedIdsByName:
+ mockBuildSkillPrimedIdsByName(config.manualSkillPrimes, config.alwaysApplySkillPrimes) ?? {},
+}));
+const mockEnrichLoadedToolsWithAgentContext = jest.fn(({ result, req, ctx }) =>
+ mockEnrichWithSkillConfigurable({
+ result,
+ context: {
+ req,
+ accessibleSkillIds: ctx.accessibleSkillIds,
+ codeEnvAvailable: ctx.codeEnvAvailable === true,
+ skillPrimedIdsByName: ctx.skillPrimedIdsByName,
+ activeSkillNames: ctx.activeSkillNames,
+ skillAuthoringAvailable: ctx.skillAuthoringAvailable === true,
+ fileAuthoringToolNames: ctx.fileAuthoringToolNames,
+ },
+ }),
+);
+const mockCanAuthorSkillFiles = jest.fn(
+ ({ scopedEditableSkillIds = [], skillCreateAllowed }) =>
+ scopedEditableSkillIds.length > 0 || skillCreateAllowed === true,
+);
+const mockGetSkillToolDeps = jest.fn(() => ({}));
+
+jest.mock('nanoid', () => ({
+ nanoid: jest.fn(() => 'mock-nanoid-123'),
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: {
+ debug: jest.fn(),
+ error: jest.fn(),
+ warn: jest.fn(),
+ },
+}));
+
+jest.mock('@librechat/agents', () => ({
+ Callback: { TOOL_ERROR: 'TOOL_ERROR' },
+ ToolEndHandler: jest.fn(),
+ formatAgentMessages: jest.fn().mockReturnValue({
+ messages: [],
+ indexTokenCountMap: {},
+ }),
+}));
+
+jest.mock('@librechat/api', () => ({
+ writeSSE: jest.fn(),
+ createRun: jest.fn().mockResolvedValue({
+ processStream: mockProcessStream,
+ }),
+ createChunk: jest.fn().mockReturnValue({}),
+ buildToolSet: jest.fn().mockReturnValue(new Set()),
+ scopeSkillIds: jest.fn().mockImplementation((ids) => ids),
+ resolveAgentScopedSkillIds: jest
+ .fn()
+ .mockImplementation(({ accessibleSkillIds }) => accessibleSkillIds),
+ loadSkillStates: jest.fn().mockResolvedValue({ skillStates: {}, defaultActiveOnShare: false }),
+ sendFinalChunk: jest.fn(),
+ createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }),
+ validateRequest: jest
+ .fn()
+ .mockReturnValue({ request: { model: 'agent-123', messages: [], stream: false } }),
+ initializeAgent: jest.fn().mockResolvedValue({
+ id: 'agent-123',
+ model: 'gpt-4',
+ model_parameters: {},
+ toolRegistry: {},
+ edges: [],
+ }),
+ getBalanceConfig: mockGetBalanceConfig,
+ createErrorResponse: jest.fn(),
+ getTransactionsConfig: mockGetTransactionsConfig,
+ recordCollectedUsage: mockRecordCollectedUsage,
+ createSubagentUsageSink: jest.fn().mockReturnValue(jest.fn()),
+ extractManualSkills: jest.fn().mockReturnValue(undefined),
+ injectSkillPrimes: jest.fn().mockReturnValue({
+ initialMessages: [],
+ indexTokenCountMap: {},
+ inserted: 0,
+ insertIdx: -1,
+ alwaysApplyDropped: 0,
+ alwaysApplyDedupedFromManual: 0,
+ }),
+ buildNonStreamingResponse: jest.fn().mockReturnValue({ id: 'resp-123' }),
+ createOpenAIStreamTracker: jest.fn().mockReturnValue({
+ addText: jest.fn(),
+ addReasoning: jest.fn(),
+ toolCalls: new Map(),
+ usage: { promptTokens: 0, completionTokens: 0, reasoningTokens: 0 },
+ }),
+ createOpenAIContentAggregator: jest.fn().mockReturnValue({
+ addText: jest.fn(),
+ addReasoning: jest.fn(),
+ getText: jest.fn().mockReturnValue(''),
+ getReasoning: jest.fn().mockReturnValue(''),
+ 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),
+ findPiiMatchInMessages: jest.fn().mockReturnValue(null),
+ discoverConnectedAgents: jest.fn().mockResolvedValue({
+ agentConfigs: new Map(),
+ edges: [],
+ skippedAgentIds: new Set(),
+ userMCPAuthMap: undefined,
+ }),
+}));
+
+jest.mock('~/server/controllers/ModelController', () => ({
+ getModelsConfig: jest.fn().mockResolvedValue({}),
+}));
+
+jest.mock('~/server/services/Files/permissions', () => ({
+ filterFilesByAgentAccess: jest.fn(),
+}));
+
+jest.mock('~/server/services/Endpoints/agents/skillDeps', () => ({
+ getSkillToolDeps: mockGetSkillToolDeps,
+ getSkillDbMethods: jest.fn(() => ({})),
+ canAuthorSkillFiles: mockCanAuthorSkillFiles,
+ withDeploymentSkillIds: jest.fn((ids = []) => ids),
+ enrichWithSkillConfigurable: mockEnrichWithSkillConfigurable,
+ buildSkillPrimedIdsByName: mockBuildSkillPrimedIdsByName,
+ buildAgentToolContext: mockBuildAgentToolContext,
+ enrichLoadedToolsWithAgentContext: mockEnrichLoadedToolsWithAgentContext,
+}));
+
+jest.mock('~/cache', () => ({
+ logViolation: jest.fn(),
+}));
+
+jest.mock('~/server/services/ToolService', () => ({
+ loadAgentTools: jest.fn().mockResolvedValue([]),
+ loadToolsForExecution: jest.fn().mockResolvedValue([]),
+}));
+
+const mockGetMultiplier = jest.fn().mockReturnValue(1);
+const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
+
+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([]),
+ checkPermission: jest.fn().mockResolvedValue(true),
+}));
+
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn().mockReturnValue({}),
+}));
+
+jest.mock('~/server/services/Files/Code/crud', () => ({
+ batchUploadCodeEnvFiles: jest.fn().mockResolvedValue({ session_id: '', files: [] }),
+}));
+
+jest.mock('~/server/services/Files/Code/process', () => ({
+ getSessionInfo: jest.fn().mockResolvedValue(null),
+ checkIfActive: jest.fn().mockReturnValue(false),
+}));
+
+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(),
+ updateFilesUsage: jest.fn(),
+ getUserKeyValues: jest.fn(),
+ getUserCodeFiles: jest.fn(),
+ getToolFilesByIds: jest.fn(),
+ 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', () => {
+ let OpenAIChatCompletionController;
+ let req, res;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ const controller = require('../openai');
+ OpenAIChatCompletionController = controller.OpenAIChatCompletionController;
+
+ req = {
+ body: {
+ model: 'agent-123',
+ messages: [{ role: 'user', content: 'Hello' }],
+ stream: false,
+ },
+ user: { id: 'user-123' },
+ config: {
+ endpoints: {
+ agents: { allowedProviders: ['openAI'] },
+ },
+ },
+ on: jest.fn(),
+ };
+
+ res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ setHeader: jest.fn(),
+ flushHeaders: jest.fn(),
+ end: jest.fn(),
+ write: jest.fn(),
+ };
+ });
+
+ describe('conversation ownership validation', () => {
+ it('should skip ownership check when conversation_id is not provided', async () => {
+ const { getConvo } = require('~/models');
+ await OpenAIChatCompletionController(req, res);
+ expect(getConvo).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 when conversation_id is not a string', async () => {
+ const { validateRequest } = require('@librechat/api');
+ validateRequest.mockReturnValueOnce({
+ request: { model: 'agent-123', messages: [], stream: false, conversation_id: { $gt: '' } },
+ });
+
+ await OpenAIChatCompletionController(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ });
+
+ it('should return 404 when conversation is not owned by user', async () => {
+ const { validateRequest } = require('@librechat/api');
+ const { getConvo } = require('~/models');
+ validateRequest.mockReturnValueOnce({
+ request: {
+ model: 'agent-123',
+ messages: [],
+ stream: false,
+ conversation_id: 'convo-abc',
+ },
+ });
+ getConvo.mockResolvedValueOnce(null);
+
+ await OpenAIChatCompletionController(req, res);
+ expect(getConvo).toHaveBeenCalledWith('user-123', 'convo-abc');
+ expect(res.status).toHaveBeenCalledWith(404);
+ });
+
+ it('should proceed when conversation is owned by user', async () => {
+ const { validateRequest } = require('@librechat/api');
+ const { getConvo } = require('~/models');
+ validateRequest.mockReturnValueOnce({
+ request: {
+ model: 'agent-123',
+ messages: [],
+ stream: false,
+ conversation_id: 'convo-abc',
+ },
+ });
+ getConvo.mockResolvedValueOnce({ conversationId: 'convo-abc', user: 'user-123' });
+
+ await OpenAIChatCompletionController(req, res);
+ expect(getConvo).toHaveBeenCalledWith('user-123', 'convo-abc');
+ expect(res.status).not.toHaveBeenCalledWith(404);
+ });
+
+ it('should return 500 when getConvo throws a DB error', async () => {
+ const { validateRequest } = require('@librechat/api');
+ const { getConvo } = require('~/models');
+ validateRequest.mockReturnValueOnce({
+ request: {
+ model: 'agent-123',
+ messages: [],
+ stream: false,
+ conversation_id: 'convo-abc',
+ },
+ });
+ getConvo.mockRejectedValueOnce(new Error('DB connection failed'));
+
+ await OpenAIChatCompletionController(req, res);
+ expect(res.status).toHaveBeenCalledWith(500);
+ });
+ });
+
+ describe('token usage recording', () => {
+ it('should call recordCollectedUsage after successful non-streaming completion', async () => {
+ await OpenAIChatCompletionController(req, res);
+
+ expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
+ expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
+ {
+ spendTokens: mockSpendTokens,
+ spendStructuredTokens: mockSpendStructuredTokens,
+ pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier },
+ bulkWriteOps: {
+ insertMany: mockBulkInsertTransactions,
+ updateBalance: mockUpdateBalance,
+ },
+ },
+ expect.objectContaining({
+ user: 'user-123',
+ conversationId: expect.any(String),
+ collectedUsage: expect.any(Array),
+ context: 'message',
+ balance: { enabled: true },
+ transactions: { enabled: true },
+ }),
+ );
+ });
+
+ it('should pass balance and transactions config to recordCollectedUsage', async () => {
+ mockGetBalanceConfig.mockReturnValue({ enabled: true, startBalance: 1000 });
+ mockGetTransactionsConfig.mockReturnValue({ enabled: true, rateLimit: 100 });
+
+ await OpenAIChatCompletionController(req, res);
+
+ expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.objectContaining({
+ balance: { enabled: true, startBalance: 1000 },
+ transactions: { enabled: true, rateLimit: 100 },
+ }),
+ );
+ });
+
+ it('should pass spendTokens, spendStructuredTokens, pricing, and bulkWriteOps as dependencies', async () => {
+ await OpenAIChatCompletionController(req, res);
+
+ const [deps] = mockRecordCollectedUsage.mock.calls[0];
+ expect(deps).toHaveProperty('spendTokens', mockSpendTokens);
+ expect(deps).toHaveProperty('spendStructuredTokens', mockSpendStructuredTokens);
+ expect(deps).toHaveProperty('pricing');
+ expect(deps.pricing).toHaveProperty('getMultiplier', mockGetMultiplier);
+ expect(deps.pricing).toHaveProperty('getCacheMultiplier', mockGetCacheMultiplier);
+ expect(deps).toHaveProperty('bulkWriteOps');
+ expect(deps.bulkWriteOps).toHaveProperty('insertMany', mockBulkInsertTransactions);
+ expect(deps.bulkWriteOps).toHaveProperty('updateBalance', mockUpdateBalance);
+ });
+
+ it('should include model from primaryConfig in recordCollectedUsage params', async () => {
+ await OpenAIChatCompletionController(req, res);
+
+ expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.objectContaining({
+ model: 'gpt-4',
+ }),
+ );
+ });
+ });
+
+ 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);
+ });
+ });
+
+ describe('sub-agent skill priming', () => {
+ it('passes the sub-agent primed skill IDs into tool execution', async () => {
+ const {
+ initializeAgent,
+ discoverConnectedAgents,
+ createToolExecuteHandler,
+ } = require('@librechat/api');
+ const { loadToolsForExecution } = require('~/server/services/ToolService');
+ const subAgent = { id: 'agent-sub', name: 'Sub Agent' };
+ const subConfig = {
+ id: 'agent-sub',
+ model: 'gpt-4',
+ model_parameters: {},
+ toolRegistry: new Map(),
+ userMCPAuthMap: { sub: { token: 'sub-token' } },
+ tool_resources: { code_interpreter: { file_ids: ['sub-file'] } },
+ actionsEnabled: true,
+ accessibleSkillIds: ['sub-skill-id'],
+ activeSkillNames: ['sub-hidden-skill'],
+ codeEnvAvailable: true,
+ skillAuthoringAvailable: true,
+ fileAuthoringToolNames: ['create_file', 'edit_file'],
+ manualSkillPrimes: [{ name: 'sub-hidden-skill', _id: { toString: () => 'sub-manual-id' } }],
+ alwaysApplySkillPrimes: [
+ { name: 'sub-always-skill', _id: { toString: () => 'sub-always-id' } },
+ ],
+ };
+
+ initializeAgent.mockResolvedValueOnce({
+ id: 'agent-123',
+ model: 'gpt-4',
+ model_parameters: {},
+ toolRegistry: new Map(),
+ edges: [{ source: 'agent-123', target: 'agent-sub' }],
+ accessibleSkillIds: ['primary-skill-id'],
+ activeSkillNames: ['primary-skill'],
+ codeEnvAvailable: false,
+ skillAuthoringAvailable: false,
+ fileAuthoringToolNames: [],
+ manualSkillPrimes: [{ name: 'primary-skill', _id: { toString: () => 'primary-skill-id' } }],
+ });
+ discoverConnectedAgents.mockImplementationOnce(async (_params, deps) => {
+ deps.onAgentInitialized('agent-sub', subAgent, subConfig);
+ return {
+ agentConfigs: new Map([['agent-sub', subConfig]]),
+ edges: [],
+ skippedAgentIds: new Set(),
+ userMCPAuthMap: undefined,
+ };
+ });
+
+ await OpenAIChatCompletionController(req, res);
+
+ const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0];
+ await toolExecuteOptions.loadTools(['read_file'], 'agent-sub');
+
+ expect(loadToolsForExecution).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ agent: subAgent,
+ toolRegistry: subConfig.toolRegistry,
+ userMCPAuthMap: subConfig.userMCPAuthMap,
+ tool_resources: subConfig.tool_resources,
+ actionsEnabled: true,
+ }),
+ );
+ expect(mockEnrichWithSkillConfigurable).toHaveBeenLastCalledWith({
+ result: expect.anything(),
+ context: {
+ req,
+ accessibleSkillIds: ['sub-skill-id'],
+ codeEnvAvailable: true,
+ skillPrimedIdsByName: {
+ 'sub-always-skill': 'sub-always-id',
+ 'sub-hidden-skill': 'sub-manual-id',
+ },
+ activeSkillNames: ['sub-hidden-skill'],
+ skillAuthoringAvailable: true,
+ fileAuthoringToolNames: ['create_file', 'edit_file'],
+ },
+ });
+ });
+ });
+});
diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
new file mode 100644
index 0000000..8024b8d
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
@@ -0,0 +1,621 @@
+const { EventEmitter } = require('events');
+
+const mockLogger = {
+ debug: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ info: jest.fn(),
+};
+
+const mockGenerationJobManager = {
+ createJob: jest.fn(),
+ emitError: jest.fn(),
+ completeJob: jest.fn(),
+ getResumeState: jest.fn(),
+ updateMetadata: jest.fn(),
+};
+
+const mockCheckAndIncrementPendingRequest = jest.fn();
+const mockDecrementPendingRequest = jest.fn();
+const mockFilterPersistableAbortContent = jest.fn((content) =>
+ content.filter((part) => part?.type !== 'tool_call'),
+);
+const mockGetConvo = jest.fn();
+const mockGetMessages = jest.fn();
+const mockSaveMessage = jest.fn();
+let mockMCPContexts = new WeakMap();
+
+const mockCreateMCPRequestContext = jest.fn(() => ({
+ connections: new Map(),
+ pending: new Map(),
+ cleanupStarted: false,
+ cleanupOnResponse: false,
+ responseCleanupAttached: false,
+}));
+const mockGetMCPRequestContext = jest.fn((req) => {
+ if (!req) {
+ return undefined;
+ }
+
+ let context = mockMCPContexts.get(req);
+ if (!context) {
+ context = mockCreateMCPRequestContext();
+ mockMCPContexts.set(req, context);
+ }
+
+ return context.cleanupStarted ? undefined : context;
+});
+const mockCleanupMCPRequestContext = jest.fn(async (context) => {
+ if (!context || context.cleanupStarted) {
+ return;
+ }
+
+ context.cleanupStarted = true;
+ const connections = new Set(context.connections.values());
+ const settled = await Promise.allSettled(context.pending.values());
+ for (const result of settled) {
+ if (result.status === 'fulfilled' && result.value) {
+ connections.add(result.value);
+ }
+ }
+
+ await Promise.allSettled(Array.from(connections).map((connection) => connection.disconnect?.()));
+ context.connections.clear();
+ context.pending.clear();
+});
+const mockCleanupMCPRequestContextForReq = jest.fn(async (req) => {
+ const context = mockMCPContexts.get(req);
+ if (!context) {
+ return;
+ }
+
+ try {
+ await mockCleanupMCPRequestContext(context);
+ } finally {
+ mockMCPContexts.delete(req);
+ }
+});
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: mockLogger,
+}));
+
+jest.mock('@librechat/api', () => ({
+ sendEvent: jest.fn(),
+ getViolationInfo: jest.fn(),
+ buildMessageFiles: jest.fn(() => []),
+ resolveTitleTiming: jest.fn(() => 'immediate'),
+ GenerationJobManager: mockGenerationJobManager,
+ getReferencedQuotes: jest.fn((quotes) => {
+ if (!Array.isArray(quotes)) {
+ return null;
+ }
+ const normalized = quotes
+ .filter((quote) => typeof quote === 'string' && quote.trim().length > 0)
+ .map((quote) => quote.trim());
+ return normalized.length > 0 ? normalized : null;
+ }),
+ cleanupMCPRequestContext: (...args) => mockCleanupMCPRequestContext(...args),
+ createMCPRequestContext: (...args) => mockCreateMCPRequestContext(...args),
+ getMCPRequestContext: (...args) => mockGetMCPRequestContext(...args),
+ filterPersistableAbortContent: (...args) => mockFilterPersistableAbortContent(...args),
+ cleanupMCPRequestContextForReq: (...args) => mockCleanupMCPRequestContextForReq(...args),
+ decrementPendingRequest: (...args) => mockDecrementPendingRequest(...args),
+ sanitizeMessageForTransmit: jest.fn((message) => message),
+ checkAndIncrementPendingRequest: (...args) => mockCheckAndIncrementPendingRequest(...args),
+ isUnpersistedPreliminaryParent: async ({
+ userId,
+ conversationId,
+ parentMessageId,
+ getMessages,
+ }) => {
+ if (typeof parentMessageId !== 'string' || !parentMessageId.endsWith('_')) {
+ return false;
+ }
+
+ const filter = { user: userId, messageId: parentMessageId };
+ if (conversationId && conversationId !== 'new') {
+ filter.conversationId = conversationId;
+ }
+
+ const messages = await getMessages(filter, '_id');
+ return messages.length === 0;
+ },
+}));
+
+jest.mock('~/server/cleanup', () => ({
+ disposeClient: jest.fn(),
+ clientRegistry: null,
+ requestDataMap: {
+ set: jest.fn(),
+ },
+}));
+
+jest.mock('~/server/middleware', () => ({
+ handleAbortError: jest.fn(() => Promise.resolve()),
+}));
+
+jest.mock('~/cache', () => ({
+ logViolation: jest.fn(),
+}));
+
+jest.mock('~/models', () => ({
+ saveMessage: (...args) => mockSaveMessage(...args),
+ getMessages: (...args) => mockGetMessages(...args),
+ getConvo: (...args) => mockGetConvo(...args),
+}));
+
+const AgentController = require('../request');
+const { getMCPRequestContext } = require('~/server/services/MCPRequestContext');
+
+function createResumableResponse() {
+ const res = new EventEmitter();
+ res.headersSent = false;
+ res.writableEnded = false;
+ res.finished = false;
+ res.destroyed = false;
+ res.json = jest.fn(() => {
+ res.headersSent = true;
+ res.writableEnded = true;
+ res.finished = true;
+ res.emit('finish');
+ return res;
+ });
+ res.status = jest.fn(() => res);
+ return res;
+}
+
+function nextTick() {
+ return new Promise((resolve) => setImmediate(resolve));
+}
+
+describe('ResumableAgentController resume metadata', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockMCPContexts = new WeakMap();
+ mockCheckAndIncrementPendingRequest.mockResolvedValue({ allowed: true });
+ mockDecrementPendingRequest.mockResolvedValue(undefined);
+ mockGetConvo.mockResolvedValue({ createdAt: '2026-06-07T00:00:00.000Z' });
+ mockGetMessages.mockResolvedValue([]);
+ mockGenerationJobManager.createJob.mockResolvedValue({
+ createdAt: 1000,
+ readyPromise: Promise.resolve(),
+ abortController: new AbortController(),
+ emitter: { on: jest.fn() },
+ });
+ mockGenerationJobManager.getResumeState.mockResolvedValue(null);
+ mockGenerationJobManager.updateMetadata.mockResolvedValue(undefined);
+ mockGenerationJobManager.emitError.mockResolvedValue(undefined);
+ mockSaveMessage.mockResolvedValue({});
+ });
+
+ it('rejects an underscore-suffixed parent that is not persisted', async () => {
+ const conversationId = 'conversation-123';
+ const initializeClient = jest.fn();
+ const req = {
+ user: { id: 'user-123' },
+ body: {
+ text: 'Follow up too early.',
+ messageId: 'follow-up-user',
+ parentMessageId: 'pending-response_',
+ conversationId,
+ endpointOption: {
+ endpoint: 'agents',
+ modelOptions: { model: 'gpt-3.5-turbo' },
+ },
+ },
+ config: {},
+ };
+ const res = {
+ json: jest.fn(),
+ status: jest.fn(() => res),
+ };
+
+ await AgentController(req, res, jest.fn(), initializeClient, null);
+
+ expect(mockGetMessages).toHaveBeenCalledWith(
+ { user: 'user-123', messageId: 'pending-response_', conversationId },
+ '_id',
+ );
+ expect(res.status).toHaveBeenCalledWith(409);
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: expect.stringContaining('selected parent response is still being saved'),
+ }),
+ );
+ expect(mockCheckAndIncrementPendingRequest).not.toHaveBeenCalled();
+ expect(mockGenerationJobManager.createJob).not.toHaveBeenCalled();
+ expect(initializeClient).not.toHaveBeenCalled();
+ });
+
+ it('allows an underscore-suffixed parent when it is already persisted', async () => {
+ const conversationId = 'conversation-123';
+ mockGetMessages.mockResolvedValue([{ _id: 'persisted-parent' }]);
+ const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
+ const req = {
+ user: { id: 'user-123' },
+ body: {
+ text: 'Follow up to persisted underscore id.',
+ messageId: 'follow-up-user',
+ parentMessageId: 'persisted-response_',
+ conversationId,
+ endpointOption: {
+ endpoint: 'agents',
+ modelOptions: { model: 'gpt-3.5-turbo' },
+ },
+ },
+ config: {},
+ };
+ const res = {
+ headersSent: true,
+ json: jest.fn(() => {
+ res.headersSent = true;
+ }),
+ status: jest.fn(() => res),
+ };
+
+ await AgentController(req, res, jest.fn(), initializeClient, null);
+
+ expect(mockGetMessages).toHaveBeenCalledWith(
+ { user: 'user-123', messageId: 'persisted-response_', conversationId },
+ '_id',
+ );
+ expect(res.status).not.toHaveBeenCalledWith(409);
+ expect(mockCheckAndIncrementPendingRequest).toHaveBeenCalledWith('user-123');
+ expect(mockGenerationJobManager.createJob).toHaveBeenCalledWith(
+ conversationId,
+ 'user-123',
+ conversationId,
+ );
+ });
+
+ it('stores the in-flight turn before MCP initialization can emit OAuth', async () => {
+ const conversationId = 'conversation-123';
+ const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
+ const req = {
+ user: { id: 'user-123' },
+ body: {
+ text: 'Check Google Workspace availability.',
+ messageId: 'follow-up-user',
+ parentMessageId: 'original-response',
+ conversationId,
+ endpointOption: {
+ endpoint: 'agents',
+ iconURL: 'https://example.com/spec-icon.png',
+ modelOptions: { model: 'gpt-3.5-turbo' },
+ },
+ },
+ config: {},
+ };
+ const res = {
+ headersSent: true,
+ json: jest.fn(() => {
+ res.headersSent = true;
+ }),
+ status: jest.fn(() => res),
+ };
+
+ await AgentController(req, res, jest.fn(), initializeClient, null);
+
+ expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(
+ conversationId,
+ expect.objectContaining({
+ conversationId,
+ endpoint: 'agents',
+ iconURL: 'https://example.com/spec-icon.png',
+ model: 'gpt-3.5-turbo',
+ responseMessageId: 'follow-up-user_',
+ userMessage: {
+ messageId: 'follow-up-user',
+ parentMessageId: 'original-response',
+ conversationId,
+ text: 'Check Google Workspace availability.',
+ },
+ }),
+ );
+ expect(mockGenerationJobManager.updateMetadata.mock.invocationCallOrder[0]).toBeLessThan(
+ initializeClient.mock.invocationCallOrder[0],
+ );
+ });
+
+ it('keeps request-scoped MCP connections until resumable initialization finishes', async () => {
+ const conversationId = 'conversation-123';
+ const disconnect = jest.fn().mockResolvedValue(undefined);
+ const initializeClient = jest.fn(async ({ req, res }) => {
+ const context = getMCPRequestContext(req, res);
+ context.connections.set('mcp-server', { disconnect });
+
+ await nextTick();
+ expect(disconnect).not.toHaveBeenCalled();
+
+ throw new Error('stop after request-scoped MCP connection');
+ });
+ const req = {
+ user: { id: 'user-123' },
+ body: {
+ text: 'Use a BODY-scoped MCP server.',
+ messageId: 'user-message',
+ parentMessageId: 'parent-message',
+ conversationId,
+ endpointOption: {
+ endpoint: 'agents',
+ modelOptions: { model: 'gpt-4.1' },
+ },
+ },
+ config: {},
+ };
+ const res = createResumableResponse();
+
+ await AgentController(req, res, jest.fn(), initializeClient, null);
+
+ expect(res.json).toHaveBeenCalledWith({
+ streamId: conversationId,
+ conversationId,
+ status: 'started',
+ });
+ expect(disconnect).toHaveBeenCalledTimes(1);
+ expect(disconnect.mock.invocationCallOrder[0]).toBeLessThan(
+ mockDecrementPendingRequest.mock.invocationCallOrder[0],
+ );
+ });
+
+ it('stores model spec icon fallbacks and agent ids in early resume metadata', async () => {
+ const conversationId = 'conversation-123';
+ const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
+ const req = {
+ user: { id: 'user-123' },
+ body: {
+ text: 'Use the resume spec.',
+ messageId: 'follow-up-user',
+ parentMessageId: 'original-response',
+ conversationId,
+ endpointOption: {
+ endpoint: 'agents',
+ spec: 'agent-spec',
+ agent_id: 'agent_resume_spec',
+ model_parameters: { model: 'gpt-4.1' },
+ },
+ },
+ config: {
+ modelSpecs: {
+ list: [
+ {
+ name: 'agent-spec',
+ preset: {
+ endpoint: 'openAI',
+ iconURL: 'https://example.com/preset-icon.png',
+ },
+ },
+ ],
+ },
+ },
+ };
+ const res = {
+ headersSent: true,
+ json: jest.fn(() => {
+ res.headersSent = true;
+ }),
+ status: jest.fn(() => res),
+ };
+
+ await AgentController(req, res, jest.fn(), initializeClient, null);
+
+ expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(
+ conversationId,
+ expect.objectContaining({
+ iconURL: 'https://example.com/preset-icon.png',
+ model: 'agent_resume_spec',
+ }),
+ );
+ });
+
+ it('falls back to the model spec preset endpoint when no icon URL is configured', async () => {
+ const conversationId = 'conversation-123';
+ const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
+ const req = {
+ user: { id: 'user-123' },
+ body: {
+ text: 'Use the endpoint icon.',
+ messageId: 'follow-up-user',
+ parentMessageId: 'original-response',
+ conversationId,
+ endpointOption: {
+ endpoint: 'agents',
+ spec: 'endpoint-icon-spec',
+ model_parameters: { model: 'gpt-4.1' },
+ },
+ },
+ config: {
+ modelSpecs: {
+ list: [
+ {
+ name: 'endpoint-icon-spec',
+ preset: {
+ endpoint: 'anthropic',
+ },
+ },
+ ],
+ },
+ },
+ };
+ const res = {
+ headersSent: true,
+ json: jest.fn(() => {
+ res.headersSent = true;
+ }),
+ status: jest.fn(() => res),
+ };
+
+ await AgentController(req, res, jest.fn(), initializeClient, null);
+
+ expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(
+ conversationId,
+ expect.objectContaining({
+ iconURL: 'anthropic',
+ model: 'gpt-4.1',
+ }),
+ );
+ });
+
+ it('filters OAuth prompts before saving partial responses on disconnect', async () => {
+ const conversationId = 'conversation-123';
+ let allSubscribersLeftHandler;
+ mockGenerationJobManager.createJob.mockResolvedValue({
+ createdAt: 1000,
+ readyPromise: Promise.resolve(),
+ abortController: new AbortController(),
+ emitter: {
+ on: jest.fn((event, handler) => {
+ if (event === 'allSubscribersLeft') {
+ allSubscribersLeftHandler = handler;
+ }
+ }),
+ },
+ });
+ mockGenerationJobManager.getResumeState.mockResolvedValue({
+ conversationId,
+ responseMessageId: 'response-message',
+ iconURL: 'https://example.com/spec-icon.png',
+ model: 'gpt-4.1',
+ userMessage: {
+ messageId: 'user-message',
+ parentMessageId: 'parent-message',
+ conversationId,
+ text: 'Use Google Workspace',
+ },
+ });
+
+ const initializeClient = jest.fn().mockRejectedValue(new Error('stop after setup'));
+ const req = {
+ user: { id: 'user-123' },
+ body: {
+ text: 'Use Google Workspace',
+ messageId: 'user-message',
+ parentMessageId: 'parent-message',
+ conversationId,
+ endpointOption: {
+ endpoint: 'agents',
+ iconURL: 'https://example.com/fallback-icon.png',
+ modelOptions: { model: 'gpt-3.5-turbo' },
+ },
+ },
+ config: {},
+ };
+ const res = {
+ headersSent: true,
+ json: jest.fn(() => {
+ res.headersSent = true;
+ }),
+ status: jest.fn(() => res),
+ };
+
+ await AgentController(req, res, jest.fn(), initializeClient, null);
+ expect(allSubscribersLeftHandler).toEqual(expect.any(Function));
+
+ const oauthPart = {
+ type: 'tool_call',
+ tool_call: {
+ name: 'oauth_mcp_Google-Workspace',
+ auth: 'https://auth.example.com/oauth',
+ },
+ };
+ const textPart = { type: 'text', text: 'Partial response...' };
+
+ await allSubscribersLeftHandler([oauthPart, textPart]);
+
+ expect(mockFilterPersistableAbortContent).toHaveBeenCalledWith([oauthPart, textPart]);
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ userId: 'user-123' }),
+ expect.objectContaining({
+ content: [textPart],
+ iconURL: 'https://example.com/spec-icon.png',
+ model: 'gpt-4.1',
+ messageId: 'response-message',
+ parentMessageId: 'user-message',
+ }),
+ expect.any(Object),
+ );
+ });
+
+ it('uses model spec and agent fallbacks when saving partial responses on disconnect', async () => {
+ const conversationId = 'conversation-123';
+ let allSubscribersLeftHandler;
+ mockGenerationJobManager.createJob.mockResolvedValue({
+ createdAt: 1000,
+ readyPromise: Promise.resolve(),
+ abortController: new AbortController(),
+ emitter: {
+ on: jest.fn((event, handler) => {
+ if (event === 'allSubscribersLeft') {
+ allSubscribersLeftHandler = handler;
+ }
+ }),
+ },
+ });
+ mockGenerationJobManager.getResumeState.mockResolvedValue({
+ conversationId,
+ responseMessageId: 'response-message',
+ userMessage: {
+ messageId: 'user-message',
+ parentMessageId: 'parent-message',
+ conversationId,
+ text: 'Use fallback metadata',
+ },
+ });
+
+ const initializeClient = jest.fn().mockRejectedValue(new Error('stop after setup'));
+ const req = {
+ user: { id: 'user-123' },
+ body: {
+ text: 'Use fallback metadata',
+ messageId: 'user-message',
+ parentMessageId: 'parent-message',
+ conversationId,
+ endpointOption: {
+ endpoint: 'agents',
+ spec: 'agent-spec',
+ agent_id: 'agent_resume_spec',
+ model_parameters: { model: 'gpt-4.1' },
+ },
+ },
+ config: {
+ modelSpecs: {
+ list: [
+ {
+ name: 'agent-spec',
+ preset: {
+ endpoint: 'openAI',
+ iconURL: 'https://example.com/preset-icon.png',
+ },
+ },
+ ],
+ },
+ },
+ };
+ const res = {
+ headersSent: true,
+ json: jest.fn(() => {
+ res.headersSent = true;
+ }),
+ status: jest.fn(() => res),
+ };
+
+ await AgentController(req, res, jest.fn(), initializeClient, null);
+ expect(allSubscribersLeftHandler).toEqual(expect.any(Function));
+
+ const textPart = { type: 'text', text: 'Partial response...' };
+ await allSubscribersLeftHandler([textPart]);
+
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ userId: 'user-123' }),
+ expect.objectContaining({
+ content: [textPart],
+ iconURL: 'https://example.com/preset-icon.png',
+ model: 'agent_resume_spec',
+ messageId: 'response-message',
+ parentMessageId: 'user-message',
+ }),
+ expect.any(Object),
+ );
+ });
+});
diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js
new file mode 100644
index 0000000..4bc6d19
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js
@@ -0,0 +1,711 @@
+/**
+ * Unit tests for Open Responses API controller
+ * Tests that recordCollectedUsage is called correctly for token spending
+ */
+
+const mockSpendTokens = jest.fn().mockResolvedValue({});
+const mockSpendStructuredTokens = jest.fn().mockResolvedValue({});
+const mockRecordCollectedUsage = jest
+ .fn()
+ .mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
+const mockGetBalanceConfig = jest.fn().mockReturnValue({ enabled: true });
+const mockGetTransactionsConfig = jest.fn().mockReturnValue({ enabled: true });
+const mockBuildSkillPrimedIdsByName = jest.fn((manualSkillPrimes, alwaysApplySkillPrimes) => {
+ const primed = {};
+ for (const skill of alwaysApplySkillPrimes ?? []) {
+ primed[skill.name] = skill._id.toString();
+ }
+ for (const skill of manualSkillPrimes ?? []) {
+ primed[skill.name] = skill._id.toString();
+ }
+ return Object.keys(primed).length > 0 ? primed : undefined;
+});
+const mockEnrichWithSkillConfigurable = jest.fn((result) => result);
+const mockBuildAgentToolContext = jest.fn(({ agent, config }) => ({
+ agent,
+ toolRegistry: config.toolRegistry,
+ userMCPAuthMap: config.userMCPAuthMap,
+ tool_resources: config.tool_resources,
+ actionsEnabled: config.actionsEnabled,
+ accessibleSkillIds: config.accessibleSkillIds,
+ activeSkillNames: config.activeSkillNames,
+ codeEnvAvailable: config.codeEnvAvailable,
+ skillAuthoringAvailable: config.skillAuthoringAvailable,
+ fileAuthoringToolNames: config.fileAuthoringToolNames,
+ skillPrimedIdsByName:
+ mockBuildSkillPrimedIdsByName(config.manualSkillPrimes, config.alwaysApplySkillPrimes) ?? {},
+}));
+const mockEnrichLoadedToolsWithAgentContext = jest.fn(({ result, req, ctx }) =>
+ mockEnrichWithSkillConfigurable({
+ result,
+ context: {
+ req,
+ accessibleSkillIds: ctx.accessibleSkillIds,
+ codeEnvAvailable: ctx.codeEnvAvailable === true,
+ skillPrimedIdsByName: ctx.skillPrimedIdsByName,
+ activeSkillNames: ctx.activeSkillNames,
+ skillAuthoringAvailable: ctx.skillAuthoringAvailable === true,
+ fileAuthoringToolNames: ctx.fileAuthoringToolNames,
+ },
+ }),
+);
+const mockCanAuthorSkillFiles = jest.fn(
+ ({ scopedEditableSkillIds = [], skillCreateAllowed }) =>
+ scopedEditableSkillIds.length > 0 || skillCreateAllowed === true,
+);
+const mockGetSkillToolDeps = jest.fn(() => ({}));
+const mockBuildAgentScopedContext = jest.fn().mockResolvedValue(new Map());
+const mockBuildAgentContextAttachmentsByAgentId = jest.fn().mockReturnValue(new Map());
+const mockApplyContextToAgent = jest.fn().mockResolvedValue(undefined);
+
+jest.mock('nanoid', () => ({
+ nanoid: jest.fn(() => 'mock-nanoid-123'),
+}));
+
+jest.mock('uuid', () => ({
+ v4: jest.fn(() => 'mock-uuid-456'),
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: {
+ debug: jest.fn(),
+ error: jest.fn(),
+ warn: jest.fn(),
+ },
+}));
+
+jest.mock('@librechat/agents', () => ({
+ Callback: { TOOL_ERROR: 'TOOL_ERROR' },
+ ToolEndHandler: jest.fn(),
+ formatAgentMessages: jest.fn().mockReturnValue({
+ messages: [],
+ indexTokenCountMap: {},
+ }),
+}));
+
+jest.mock('@librechat/api', () => ({
+ createRun: jest.fn().mockResolvedValue({
+ processStream: jest.fn().mockResolvedValue(undefined),
+ }),
+ applyContextToAgent: (...args) => mockApplyContextToAgent(...args),
+ buildToolSet: jest.fn().mockReturnValue(new Set()),
+ buildAgentScopedContext: (...args) => mockBuildAgentScopedContext(...args),
+ buildAgentContextAttachmentsByAgentId: (...args) =>
+ mockBuildAgentContextAttachmentsByAgentId(...args),
+ scopeSkillIds: jest.fn().mockImplementation((ids) => ids),
+ resolveAgentScopedSkillIds: jest
+ .fn()
+ .mockImplementation(({ accessibleSkillIds }) => accessibleSkillIds),
+ loadSkillStates: jest.fn().mockResolvedValue({ skillStates: {}, defaultActiveOnShare: false }),
+ createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }),
+ initializeAgent: jest.fn().mockResolvedValue({
+ id: 'agent-123',
+ model: 'claude-3',
+ model_parameters: {},
+ toolRegistry: {},
+ edges: [],
+ agentContextAttachments: [],
+ }),
+ discoverConnectedAgents: jest.fn().mockImplementation(async (computedParams, deps) => {
+ // Call onAgentInitialized for each agent config if provided by the mock setup
+ if (deps?.onAgentInitialized && mockGlobalDiscoveredAgentConfigs) {
+ for (const [agentId, config] of mockGlobalDiscoveredAgentConfigs) {
+ deps.onAgentInitialized(agentId, config, config);
+ }
+ }
+ return {
+ agentConfigs: mockGlobalDiscoveredAgentConfigs ?? new Map(),
+ edges: [],
+ skippedAgentIds: new Set(),
+ userMCPAuthMap: undefined,
+ };
+ }),
+ getBalanceConfig: mockGetBalanceConfig,
+ getTransactionsConfig: mockGetTransactionsConfig,
+ recordCollectedUsage: mockRecordCollectedUsage,
+ createSubagentUsageSink: jest.fn().mockReturnValue(jest.fn()),
+ extractManualSkills: jest.fn().mockReturnValue(undefined),
+ injectSkillPrimes: jest.fn().mockReturnValue({
+ initialMessages: [],
+ indexTokenCountMap: {},
+ inserted: 0,
+ insertIdx: -1,
+ alwaysApplyDropped: 0,
+ alwaysApplyDedupedFromManual: 0,
+ }),
+ createToolExecuteHandler: jest.fn().mockReturnValue({ handle: jest.fn() }),
+ // Responses API
+ writeDone: jest.fn(),
+ buildResponse: jest.fn().mockReturnValue({ id: 'resp_123', output: [] }),
+ generateResponseId: jest.fn().mockReturnValue('resp_mock-123'),
+ isValidationFailure: jest.fn().mockReturnValue(false),
+ findPiiMatchInMessages: jest.fn().mockReturnValue(null),
+ emitResponseCreated: jest.fn(),
+ createResponseContext: jest.fn().mockReturnValue({ responseId: 'resp_123' }),
+ createResponseTracker: jest.fn().mockReturnValue({
+ usage: { promptTokens: 100, completionTokens: 50 },
+ }),
+ setupStreamingResponse: jest.fn(),
+ emitResponseInProgress: jest.fn(),
+ convertInputToMessages: jest.fn().mockReturnValue([]),
+ validateResponseRequest: jest.fn().mockReturnValue({
+ request: { model: 'agent-123', input: 'Hello', stream: false },
+ }),
+ buildAggregatedResponse: jest.fn().mockReturnValue({
+ id: 'resp_123',
+ status: 'completed',
+ output: [],
+ usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 },
+ }),
+ createResponseAggregator: jest.fn().mockReturnValue({
+ usage: { promptTokens: 100, completionTokens: 50 },
+ }),
+ sendResponsesErrorResponse: jest.fn(),
+ createResponsesEventHandlers: jest.fn().mockReturnValue({
+ handlers: {
+ 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() },
+ },
+ finalizeStream: jest.fn(),
+ }),
+ createAggregatorEventHandlers: jest.fn().mockReturnValue({
+ 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() },
+ }),
+}));
+
+jest.mock('~/server/services/ToolService', () => ({
+ loadAgentTools: jest.fn().mockResolvedValue([]),
+ loadToolsForExecution: jest.fn().mockResolvedValue([]),
+}));
+
+const mockGetMultiplier = jest.fn().mockReturnValue(1);
+const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
+
+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([]),
+ checkPermission: jest.fn().mockResolvedValue(true),
+}));
+
+jest.mock('~/server/controllers/ModelController', () => ({
+ getModelsConfig: jest.fn().mockResolvedValue({}),
+}));
+
+jest.mock('~/server/services/MCP', () => ({
+ resolveConfigServers: jest.fn().mockResolvedValue({}),
+}));
+
+jest.mock('~/config', () => ({
+ getMCPManager: jest.fn().mockReturnValue({}),
+}));
+
+jest.mock('~/server/services/Files/permissions', () => ({
+ filterFilesByAgentAccess: jest.fn(),
+}));
+
+jest.mock('~/server/services/Endpoints/agents/skillDeps', () => ({
+ getSkillToolDeps: mockGetSkillToolDeps,
+ getSkillDbMethods: jest.fn(() => ({})),
+ canAuthorSkillFiles: mockCanAuthorSkillFiles,
+ withDeploymentSkillIds: jest.fn((ids = []) => ids),
+ enrichWithSkillConfigurable: mockEnrichWithSkillConfigurable,
+ buildSkillPrimedIdsByName: mockBuildSkillPrimedIdsByName,
+ buildAgentToolContext: mockBuildAgentToolContext,
+ enrichLoadedToolsWithAgentContext: mockEnrichLoadedToolsWithAgentContext,
+}));
+
+jest.mock('~/cache', () => ({
+ logViolation: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn().mockReturnValue({}),
+}));
+
+jest.mock('~/server/services/Files/Code/crud', () => ({
+ batchUploadCodeEnvFiles: jest.fn().mockResolvedValue({ session_id: '', files: [] }),
+}));
+
+jest.mock('~/server/services/Files/Code/process', () => ({
+ getSessionInfo: jest.fn().mockResolvedValue(null),
+ checkIfActive: jest.fn().mockReturnValue(false),
+}));
+
+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([]),
+ saveMessage: jest.fn().mockResolvedValue({}),
+ updateFilesUsage: jest.fn(),
+ getUserKeyValues: jest.fn(),
+ getUserCodeFiles: jest.fn(),
+ getToolFilesByIds: jest.fn(),
+ 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),
+}));
+
+let mockGlobalDiscoveredAgentConfigs = null;
+
+describe('createResponse controller', () => {
+ let createResponse;
+ let req, res;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGlobalDiscoveredAgentConfigs = null;
+
+ const controller = require('../responses');
+ createResponse = controller.createResponse;
+
+ req = {
+ body: {
+ model: 'agent-123',
+ input: 'Hello',
+ stream: false,
+ },
+ user: { id: 'user-123' },
+ config: {
+ endpoints: {
+ agents: { allowedProviders: ['anthropic'] },
+ },
+ },
+ on: jest.fn(),
+ };
+
+ res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ setHeader: jest.fn(),
+ flushHeaders: jest.fn(),
+ end: jest.fn(),
+ write: jest.fn(),
+ };
+ });
+
+ describe('conversation ownership validation', () => {
+ it('should skip ownership check when previous_response_id is not provided', async () => {
+ const { getConvo } = require('~/models');
+ await createResponse(req, res);
+ expect(getConvo).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 when previous_response_id is not a string', async () => {
+ const { validateResponseRequest, sendResponsesErrorResponse } = require('@librechat/api');
+ validateResponseRequest.mockReturnValueOnce({
+ request: {
+ model: 'agent-123',
+ input: 'Hello',
+ stream: false,
+ previous_response_id: { $gt: '' },
+ },
+ });
+
+ await createResponse(req, res);
+ expect(sendResponsesErrorResponse).toHaveBeenCalledWith(
+ res,
+ 400,
+ 'previous_response_id must be a string',
+ 'invalid_request',
+ );
+ });
+
+ it('should return 404 when conversation is not owned by user', async () => {
+ const { validateResponseRequest, sendResponsesErrorResponse } = require('@librechat/api');
+ const { getConvo } = require('~/models');
+ validateResponseRequest.mockReturnValueOnce({
+ request: {
+ model: 'agent-123',
+ input: 'Hello',
+ stream: false,
+ previous_response_id: 'resp_abc',
+ },
+ });
+ getConvo.mockResolvedValueOnce(null);
+
+ await createResponse(req, res);
+ expect(getConvo).toHaveBeenCalledWith('user-123', 'resp_abc');
+ expect(sendResponsesErrorResponse).toHaveBeenCalledWith(
+ res,
+ 404,
+ 'Conversation not found',
+ 'not_found',
+ );
+ });
+
+ it('should proceed when conversation is owned by user', async () => {
+ const { validateResponseRequest, sendResponsesErrorResponse } = require('@librechat/api');
+ const { getConvo } = require('~/models');
+ validateResponseRequest.mockReturnValueOnce({
+ request: {
+ model: 'agent-123',
+ input: 'Hello',
+ stream: false,
+ previous_response_id: 'resp_abc',
+ },
+ });
+ getConvo.mockResolvedValueOnce({ conversationId: 'resp_abc', user: 'user-123' });
+
+ await createResponse(req, res);
+ expect(getConvo).toHaveBeenCalledWith('user-123', 'resp_abc');
+ expect(sendResponsesErrorResponse).not.toHaveBeenCalledWith(
+ res,
+ 404,
+ expect.any(String),
+ expect.any(String),
+ );
+ });
+
+ it('should return 500 when getConvo throws a DB error', async () => {
+ const { validateResponseRequest, sendResponsesErrorResponse } = require('@librechat/api');
+ const { getConvo } = require('~/models');
+ validateResponseRequest.mockReturnValueOnce({
+ request: {
+ model: 'agent-123',
+ input: 'Hello',
+ stream: false,
+ previous_response_id: 'resp_abc',
+ },
+ });
+ getConvo.mockRejectedValueOnce(new Error('DB connection failed'));
+
+ await createResponse(req, res);
+ expect(sendResponsesErrorResponse).toHaveBeenCalledWith(
+ res,
+ 500,
+ expect.any(String),
+ expect.any(String),
+ );
+ });
+ });
+
+ describe('token usage recording - non-streaming', () => {
+ it('should call recordCollectedUsage after successful non-streaming completion', async () => {
+ await createResponse(req, res);
+
+ expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
+ expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
+ {
+ spendTokens: mockSpendTokens,
+ spendStructuredTokens: mockSpendStructuredTokens,
+ pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier },
+ bulkWriteOps: {
+ insertMany: mockBulkInsertTransactions,
+ updateBalance: mockUpdateBalance,
+ },
+ },
+ expect.objectContaining({
+ user: 'user-123',
+ conversationId: expect.any(String),
+ collectedUsage: expect.any(Array),
+ context: 'message',
+ }),
+ );
+ });
+
+ it('should pass balance and transactions config to recordCollectedUsage', async () => {
+ mockGetBalanceConfig.mockReturnValue({ enabled: true, startBalance: 2000 });
+ mockGetTransactionsConfig.mockReturnValue({ enabled: true });
+
+ await createResponse(req, res);
+
+ expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.objectContaining({
+ balance: { enabled: true, startBalance: 2000 },
+ transactions: { enabled: true },
+ }),
+ );
+ });
+
+ it('should pass spendTokens, spendStructuredTokens, pricing, and bulkWriteOps as dependencies', async () => {
+ await createResponse(req, res);
+
+ const [deps] = mockRecordCollectedUsage.mock.calls[0];
+ expect(deps).toHaveProperty('spendTokens', mockSpendTokens);
+ expect(deps).toHaveProperty('spendStructuredTokens', mockSpendStructuredTokens);
+ expect(deps).toHaveProperty('pricing');
+ expect(deps.pricing).toHaveProperty('getMultiplier', mockGetMultiplier);
+ expect(deps.pricing).toHaveProperty('getCacheMultiplier', mockGetCacheMultiplier);
+ expect(deps).toHaveProperty('bulkWriteOps');
+ expect(deps.bulkWriteOps).toHaveProperty('insertMany', mockBulkInsertTransactions);
+ expect(deps.bulkWriteOps).toHaveProperty('updateBalance', mockUpdateBalance);
+ });
+
+ it('should include model from primaryConfig in recordCollectedUsage params', async () => {
+ await createResponse(req, res);
+
+ expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.objectContaining({
+ model: 'claude-3',
+ }),
+ );
+ });
+ });
+
+ describe('agent context parity with UI path', () => {
+ it('applies agent-scoped attachment context before createRun', async () => {
+ const api = require('@librechat/api');
+ api.initializeAgent.mockResolvedValueOnce({
+ id: 'agent-123',
+ model: 'claude-3',
+ model_parameters: {},
+ toolRegistry: {},
+ edges: [],
+ agentContextAttachments: [{ file_id: 'file-1', filename: 'ocr_file.pdf' }],
+ });
+ mockBuildAgentContextAttachmentsByAgentId.mockReturnValueOnce(
+ new Map([['agent-123', [{ file_id: 'file-1', filename: 'ocr_file.pdf' }]]]),
+ );
+ mockBuildAgentScopedContext.mockResolvedValueOnce(
+ new Map([['agent-123', 'PDF context: ocr_file.pdf']]),
+ );
+
+ await createResponse(req, res);
+
+ expect(mockBuildAgentContextAttachmentsByAgentId).toHaveBeenCalledWith([
+ expect.objectContaining({ id: 'agent-123' }),
+ ]);
+ expect(mockBuildAgentScopedContext).toHaveBeenCalledWith(
+ expect.objectContaining({
+ agentIds: ['agent-123'],
+ attachmentsByAgentId: expect.any(Map),
+ req,
+ }),
+ );
+ expect(mockApplyContextToAgent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ agent: expect.objectContaining({ id: 'agent-123' }),
+ agentId: 'agent-123',
+ sharedRunContext: 'PDF context: ocr_file.pdf',
+ }),
+ );
+ });
+
+ it('applies context to primary and discovered handoff agents', async () => {
+ const api = require('@librechat/api');
+ const handoffConfig = {
+ id: 'agent-handoff',
+ model: 'claude-3',
+ model_parameters: {},
+ toolRegistry: {},
+ edges: [],
+ agentContextAttachments: [{ file_id: 'file-2', filename: 'handoff_context.pdf' }],
+ };
+
+ // Set primary agent to have edges pointing to handoff agent
+ api.initializeAgent.mockResolvedValueOnce({
+ id: 'agent-123',
+ model: 'claude-3',
+ model_parameters: {},
+ toolRegistry: {},
+ edges: [{ source: 'agent-123', target: 'agent-handoff' }],
+ agentContextAttachments: [{ file_id: 'file-1', filename: 'primary_context.pdf' }],
+ });
+
+ // Set global config so discoverConnectedAgents mock can invoke onAgentInitialized
+ mockGlobalDiscoveredAgentConfigs = new Map([['agent-handoff', handoffConfig]]);
+
+ mockBuildAgentScopedContext.mockResolvedValueOnce(
+ new Map([
+ ['agent-123', 'Primary context'],
+ ['agent-handoff', 'Handoff context'],
+ ]),
+ );
+
+ await createResponse(req, res);
+
+ const appliedAgentIds = mockApplyContextToAgent.mock.calls.map((call) => call[0].agentId);
+ expect(appliedAgentIds).toEqual(expect.arrayContaining(['agent-123', 'agent-handoff']));
+ expect(mockApplyContextToAgent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ agentId: 'agent-handoff',
+ sharedRunContext: 'Handoff context',
+ }),
+ );
+ });
+ });
+
+ describe('token usage recording - streaming', () => {
+ beforeEach(() => {
+ req.body.stream = true;
+
+ const api = require('@librechat/api');
+ api.validateResponseRequest.mockReturnValue({
+ request: { model: 'agent-123', input: 'Hello', stream: true },
+ });
+ });
+
+ it('should call recordCollectedUsage after successful streaming completion', async () => {
+ await createResponse(req, res);
+
+ expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
+ expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
+ {
+ spendTokens: mockSpendTokens,
+ spendStructuredTokens: mockSpendStructuredTokens,
+ pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier },
+ bulkWriteOps: {
+ insertMany: mockBulkInsertTransactions,
+ updateBalance: mockUpdateBalance,
+ },
+ },
+ expect.objectContaining({
+ user: 'user-123',
+ context: 'message',
+ }),
+ );
+ });
+ });
+
+ describe('collectedUsage population', () => {
+ it('should collect usage from on_chat_model_end events', async () => {
+ const api = require('@librechat/api');
+
+ api.createRun.mockImplementation(async ({ customHandlers }) => {
+ return {
+ processStream: jest.fn().mockImplementation(async () => {
+ customHandlers.on_chat_model_end.handle('on_chat_model_end', {
+ output: {
+ usage_metadata: {
+ input_tokens: 150,
+ output_tokens: 75,
+ model: 'claude-3',
+ },
+ },
+ });
+ }),
+ };
+ });
+
+ await createResponse(req, res);
+ expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.objectContaining({
+ collectedUsage: expect.arrayContaining([
+ expect.objectContaining({
+ input_tokens: 150,
+ output_tokens: 75,
+ }),
+ ]),
+ }),
+ );
+ });
+ });
+
+ describe('sub-agent skill priming', () => {
+ it('passes the sub-agent primed skill IDs into non-streaming tool execution', async () => {
+ const {
+ initializeAgent,
+ discoverConnectedAgents,
+ createToolExecuteHandler,
+ } = require('@librechat/api');
+ const { loadToolsForExecution } = require('~/server/services/ToolService');
+ const subAgent = { id: 'agent-sub', name: 'Sub Agent' };
+ const subConfig = {
+ id: 'agent-sub',
+ model: 'claude-3',
+ model_parameters: {},
+ toolRegistry: new Map(),
+ userMCPAuthMap: { sub: { token: 'sub-token' } },
+ tool_resources: { code_interpreter: { file_ids: ['sub-file'] } },
+ actionsEnabled: true,
+ accessibleSkillIds: ['sub-skill-id'],
+ activeSkillNames: ['sub-hidden-skill'],
+ codeEnvAvailable: true,
+ skillAuthoringAvailable: true,
+ fileAuthoringToolNames: ['create_file', 'edit_file'],
+ manualSkillPrimes: [{ name: 'sub-hidden-skill', _id: { toString: () => 'sub-manual-id' } }],
+ alwaysApplySkillPrimes: [
+ { name: 'sub-always-skill', _id: { toString: () => 'sub-always-id' } },
+ ],
+ };
+
+ initializeAgent.mockResolvedValueOnce({
+ id: 'agent-123',
+ model: 'claude-3',
+ model_parameters: {},
+ toolRegistry: new Map(),
+ edges: [{ source: 'agent-123', target: 'agent-sub' }],
+ accessibleSkillIds: ['primary-skill-id'],
+ activeSkillNames: ['primary-skill'],
+ codeEnvAvailable: false,
+ skillAuthoringAvailable: false,
+ fileAuthoringToolNames: [],
+ manualSkillPrimes: [{ name: 'primary-skill', _id: { toString: () => 'primary-skill-id' } }],
+ });
+ discoverConnectedAgents.mockImplementationOnce(async (_params, deps) => {
+ deps.onAgentInitialized('agent-sub', subAgent, subConfig);
+ return {
+ agentConfigs: new Map([['agent-sub', subConfig]]),
+ edges: [],
+ skippedAgentIds: new Set(),
+ userMCPAuthMap: undefined,
+ };
+ });
+
+ await createResponse(req, res);
+
+ const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0];
+ await toolExecuteOptions.loadTools(['read_file'], 'agent-sub');
+
+ expect(loadToolsForExecution).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ agent: subAgent,
+ toolRegistry: subConfig.toolRegistry,
+ userMCPAuthMap: subConfig.userMCPAuthMap,
+ tool_resources: subConfig.tool_resources,
+ actionsEnabled: true,
+ }),
+ );
+ expect(mockEnrichWithSkillConfigurable).toHaveBeenLastCalledWith({
+ result: expect.anything(),
+ context: {
+ req,
+ accessibleSkillIds: ['sub-skill-id'],
+ codeEnvAvailable: true,
+ skillPrimedIdsByName: {
+ 'sub-always-skill': 'sub-always-id',
+ 'sub-hidden-skill': 'sub-manual-id',
+ },
+ activeSkillNames: ['sub-hidden-skill'],
+ skillAuthoringAvailable: true,
+ fileAuthoringToolNames: ['create_file', 'edit_file'],
+ },
+ });
+ });
+ });
+});
diff --git a/api/server/controllers/agents/__tests__/resume.spec.js b/api/server/controllers/agents/__tests__/resume.spec.js
new file mode 100644
index 0000000..c7fb4db
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/resume.spec.js
@@ -0,0 +1,1128 @@
+/**
+ * Integration tests for the HITL resume controller (POST /agents/chat/resume).
+ *
+ * Drives the real `ResumeAgentController` end-to-end over supertest with the SDK
+ * run, durable checkpointer, Mongo, and concurrency cache mocked out. The pure
+ * decision/liveness helpers (`isPendingActionStale`, `mapToolApprovalResolutions`,
+ * `findUndecidedToolCalls`, `findDisallowedDecisions`, `buildAbortedResponseMetadata`,
+ * `sanitizeMessageForTransmit`) run for real via `requireActual`, so the test
+ * exercises the actual guard ladder and the pause -> approve -> resume -> finalize
+ * lifecycle rather than re-implemented stubs.
+ *
+ * Covers:
+ * - the authorization / staleness / agent-and-endpoint / actionId guard ladder
+ * - tool_approval validation (undecided, policy-disallowed decision)
+ * - ask_user_question answer requirement
+ * - concurrency gate (429) and the atomic single-winner claim (409)
+ * - the happy path: ACK, run reconstruction, resumeCompletion, finalize (save the
+ * now-finished response, emit done, complete job, prune checkpoint)
+ * - re-pause (no double finalize), abort-during-resume (no double finalize),
+ * and the resume-failure terminal path
+ */
+
+const express = require('express');
+const request = require('supertest');
+const { Constants } = require('librechat-data-provider');
+
+const USER_ID = 'user-1';
+const TENANT_ID = 'tenant-1';
+const AGENT_ID = 'agent-abc';
+const CONVO_ID = 'convo-123';
+const ACTION_ID = 'action-xyz';
+const RESPONSE_MSG_ID = 'resp-1';
+const USER_MSG_ID = 'umsg-1';
+const THREAD_PARENT_ID = 'thread-parent-1';
+
+const mockLogger = {
+ debug: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ info: jest.fn(),
+};
+
+const mockJobStore = {
+ getJob: jest.fn(),
+ updateJob: jest.fn(),
+};
+
+const mockGenerationJobManager = {
+ getJob: jest.fn(),
+ getJobStore: jest.fn(() => mockJobStore),
+ getResumeState: jest.fn(),
+ setContentParts: jest.fn(),
+ emitChunk: jest.fn(),
+ emitDone: jest.fn(),
+ emitError: jest.fn(),
+ completeJob: jest.fn(),
+ expireApproval: jest.fn(),
+ approvals: { resolve: jest.fn() },
+};
+
+const mockDeleteAgentCheckpoint = jest.fn();
+const mockDecrementPendingRequest = jest.fn();
+const mockCheckAndIncrementPendingRequest = jest.fn();
+
+const mockSaveMessage = jest.fn();
+const mockGetConvo = jest.fn();
+const mockGetMessages = jest.fn();
+const mockDisposeClient = jest.fn();
+const mockGetMCPRequestContext = jest.fn();
+const mockCleanupMCPRequestContextForReq = jest.fn();
+
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ logger: mockLogger,
+}));
+
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ GenerationJobManager: mockGenerationJobManager,
+ deleteAgentCheckpoint: (...args) => mockDeleteAgentCheckpoint(...args),
+ decrementPendingRequest: (...args) => mockDecrementPendingRequest(...args),
+ checkAndIncrementPendingRequest: (...args) => mockCheckAndIncrementPendingRequest(...args),
+}));
+
+jest.mock('~/models', () => ({
+ saveMessage: (...args) => mockSaveMessage(...args),
+ getConvo: (...args) => mockGetConvo(...args),
+ getMessages: (...args) => mockGetMessages(...args),
+}));
+
+jest.mock('~/server/cleanup', () => ({
+ disposeClient: (...args) => mockDisposeClient(...args),
+}));
+
+jest.mock('~/server/services/MCPRequestContext', () => ({
+ getMCPRequestContext: (...args) => mockGetMCPRequestContext(...args),
+ cleanupMCPRequestContextForReq: (...args) => mockCleanupMCPRequestContextForReq(...args),
+}));
+
+// Import after mocks
+const ResumeAgentController = require('~/server/controllers/agents/resume');
+
+/** Drain the microtask + immediate queues so the post-ACK continuation settles. */
+const flush = () => new Promise((resolve) => setImmediate(resolve));
+
+/** A live, resolvable paused tool-approval job (single tool call `tc1`). */
+function makeToolApprovalJob(overrides = {}) {
+ const metaOverrides = overrides.metadata ?? {};
+ const pendingOverrides = metaOverrides.pendingAction ?? {};
+ return {
+ status: 'requires_action',
+ abortController: new AbortController(),
+ ...overrides,
+ metadata: {
+ userId: USER_ID,
+ tenantId: TENANT_ID,
+ agent_id: AGENT_ID,
+ endpoint: 'agents',
+ responseMessageId: RESPONSE_MSG_ID,
+ sender: 'TestAgent',
+ iconURL: 'https://example.com/icon.png',
+ model: 'claude-test',
+ isTemporary: false,
+ userMessage: {
+ messageId: USER_MSG_ID,
+ parentMessageId: THREAD_PARENT_ID,
+ text: 'please run the tool',
+ },
+ ...metaOverrides,
+ pendingAction: {
+ actionId: ACTION_ID,
+ expiresAt: Date.now() + 60_000,
+ payload: {
+ type: 'tool_approval',
+ action_requests: [{ tool_call_id: 'tc1' }],
+ review_configs: [{ tool_call_id: 'tc1', allowed_decisions: ['approve', 'reject'] }],
+ },
+ ...pendingOverrides,
+ },
+ },
+ };
+}
+
+/** A live, resolvable paused ask-user-question job. */
+function makeAskUserJob(overrides = {}) {
+ const job = makeToolApprovalJob(overrides);
+ job.metadata.pendingAction.payload = {
+ type: 'ask_user_question',
+ question: 'What should I name the file?',
+ };
+ return job;
+}
+
+/** A mock reconstructed client for the post-ACK path. */
+function makeClient(overrides = {}) {
+ return {
+ sender: 'TestAgent',
+ contentParts: [{ type: 'text', text: 'resumed answer' }],
+ artifactPromises: [],
+ pendingApproval: false,
+ buildResponseMetadata: jest.fn(() => null),
+ resumeCompletion: jest.fn().mockResolvedValue(undefined),
+ ...overrides,
+ };
+}
+
+describe('ResumeAgentController (POST /agents/chat/resume)', () => {
+ let app;
+ let mockInitializeClient;
+ let mockAddTitle;
+ let capturedInit;
+ let settle;
+ let settled;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ capturedInit = null;
+ mockCheckAndIncrementPendingRequest.mockResolvedValue({ allowed: true });
+ mockDecrementPendingRequest.mockResolvedValue(undefined);
+ mockDeleteAgentCheckpoint.mockResolvedValue(undefined);
+ mockCleanupMCPRequestContextForReq.mockResolvedValue(undefined);
+ mockSaveMessage.mockResolvedValue(undefined);
+ mockGetConvo.mockResolvedValue(null);
+ mockGetMessages.mockResolvedValue([]);
+ mockJobStore.getJob.mockResolvedValue({ tokenUsage: null, contextUsage: null });
+ mockJobStore.updateJob.mockResolvedValue(undefined);
+ mockGenerationJobManager.getResumeState.mockResolvedValue({ aggregatedContent: [] });
+ mockGenerationJobManager.emitDone.mockResolvedValue(undefined);
+ mockGenerationJobManager.emitError.mockResolvedValue(undefined);
+ mockGenerationJobManager.emitChunk.mockResolvedValue(undefined);
+ mockGenerationJobManager.completeJob.mockResolvedValue(undefined);
+ mockGenerationJobManager.approvals.resolve.mockResolvedValue(true);
+
+ // `decrementPendingRequest` runs in the controller's `finally` on every
+ // post-ACK path, so resolving on it signals the async continuation is done.
+ settled = new Promise((resolve) => {
+ settle = resolve;
+ });
+ mockDecrementPendingRequest.mockImplementation(async () => {
+ settle();
+ });
+
+ mockAddTitle = jest.fn().mockResolvedValue(undefined);
+ mockInitializeClient = jest.fn(async ({ req }) => {
+ // Capture the request state the controller seeds BEFORE reconstruction.
+ capturedInit = {
+ parentMessageId: req.body.parentMessageId,
+ files: req.body.files,
+ conversationCreatedAt: req.conversationCreatedAt,
+ timezone: req.body.timezone,
+ };
+ return { client: makeClient(), userMCPAuthMap: { server1: { token: 't' } } };
+ });
+
+ app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ req.user = { id: USER_ID, tenantId: TENANT_ID };
+ req.config = {
+ endpoints: { agents: { checkpointer: { type: 'mongo' } } },
+ interfaceConfig: {},
+ };
+ next();
+ });
+ app.post('/api/agents/chat/resume', (req, res, next) =>
+ ResumeAgentController(req, res, next, mockInitializeClient, mockAddTitle),
+ );
+ });
+
+ const post = (body) => request(app).post('/api/agents/chat/resume').send(body);
+
+ const approveBody = (extra = {}) => ({
+ conversationId: CONVO_ID,
+ actionId: ACTION_ID,
+ agent_id: AGENT_ID,
+ endpoint: 'agents',
+ decisions: [{ tool_call_id: 'tc1', decision: 'approve' }],
+ ...extra,
+ });
+
+ describe('temporal context restore', () => {
+ it('restores req.conversationCreatedAt from the convo before initializeClient', async () => {
+ // Temporal prompt vars must resolve against the paused anchor, not resume wall-clock.
+ mockGetConvo.mockResolvedValue({ createdAt: new Date('2020-01-02T03:04:05.000Z') });
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ const res = await post(approveBody());
+ expect(res.status).toBe(200);
+ await settled;
+ expect(capturedInit.conversationCreatedAt).toBe('2020-01-02T03:04:05.000Z');
+ });
+
+ it('leaves conversationCreatedAt unset when the convo lookup yields nothing', async () => {
+ mockGetConvo.mockResolvedValue(null);
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ const res = await post(approveBody());
+ expect(res.status).toBe(200);
+ await settled;
+ expect(capturedInit.conversationCreatedAt).toBeUndefined();
+ });
+ });
+
+ describe('MCP request-context lifecycle', () => {
+ it('pre-seeds the run-scoped MCP context before initializeClient and tears it down after', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ const res = await post(approveBody());
+ expect(res.status).toBe(200);
+ await settled; // the controller's finally has run
+
+ // Seeded with a null `res` + cleanupOnResponse:false so the post-ACK tool load
+ // finds the existing store instead of getting undefined (res is already finished).
+ expect(mockGetMCPRequestContext).toHaveBeenCalledWith(expect.anything(), undefined, {
+ cleanupOnResponse: false,
+ });
+ // ...and seeded BEFORE the client (hence tool loading) is built.
+ expect(mockGetMCPRequestContext.mock.invocationCallOrder[0]).toBeLessThan(
+ mockInitializeClient.mock.invocationCallOrder[0],
+ );
+ // ...then torn down exactly once in the finally.
+ expect(mockCleanupMCPRequestContextForReq).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('request guards (rejected before claiming the action)', () => {
+ it('400 when conversationId is missing', async () => {
+ const res = await post({ actionId: ACTION_ID });
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/conversationId is required/i);
+ expect(mockGenerationJobManager.getJob).not.toHaveBeenCalled();
+ });
+
+ it('400 when conversationId is the "new" placeholder', async () => {
+ const res = await post({ conversationId: 'new', actionId: ACTION_ID });
+ expect(res.status).toBe(400);
+ expect(mockGenerationJobManager.getJob).not.toHaveBeenCalled();
+ });
+
+ it('404 when there is no paused job for the conversation', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(null);
+ const res = await post(approveBody());
+ expect(res.status).toBe(404);
+ expect(res.body.error).toMatch(/no paused generation/i);
+ });
+
+ it('403 when the job belongs to another user', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(
+ makeToolApprovalJob({ metadata: { userId: 'someone-else' } }),
+ );
+ const res = await post(approveBody());
+ expect(res.status).toBe(403);
+ expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
+ });
+
+ it('403 on a tenant mismatch', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(
+ makeToolApprovalJob({ metadata: { tenantId: 'other-tenant' } }),
+ );
+ const res = await post(approveBody());
+ expect(res.status).toBe(403);
+ });
+
+ it('403 when the resume omits the paused agent_id', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ const res = await post(approveBody({ agent_id: undefined }));
+ expect(res.status).toBe(403);
+ expect(res.body.error).toMatch(/different agent/i);
+ });
+
+ it('403 when the resume claims a different agent_id', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ const res = await post(approveBody({ agent_id: 'agent-other' }));
+ expect(res.status).toBe(403);
+ expect(res.body.error).toMatch(/different agent/i);
+ });
+
+ it('403 when the resume claims a different endpoint', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ const res = await post(approveBody({ endpoint: 'bedrock' }));
+ expect(res.status).toBe(403);
+ expect(res.body.error).toMatch(/different endpoint/i);
+ });
+
+ it('403 when the resume OMITS the paused endpoint (no fall-through to ephemeral)', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ const res = await post(approveBody({ endpoint: undefined }));
+ expect(res.status).toBe(403);
+ expect(res.body.error).toMatch(/different endpoint/i);
+ expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
+ });
+
+ it('409 when the job is not in requires_action (already terminal; no expire)', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob({ status: 'running' }));
+ const res = await post(approveBody());
+ expect(res.status).toBe(409);
+ expect(res.body.error).toMatch(/no live pending action/i);
+ // Already resolved/terminal — nothing to expire.
+ expect(mockGenerationJobManager.expireApproval).not.toHaveBeenCalled();
+ });
+
+ it('409 AND drives expiry when the pending action has expired (stale)', async () => {
+ const job = makeToolApprovalJob();
+ job.metadata.pendingAction.expiresAt = Date.now() - 1_000;
+ mockGenerationJobManager.getJob.mockResolvedValue(job);
+ const res = await post(approveBody());
+ expect(res.status).toBe(409);
+ expect(res.body.error).toMatch(/no live pending action/i);
+ // The stale action is expired NOW (expire CAS + terminal SSE) so an attached SSE
+ // client gets a terminal event instead of hanging until the periodic sweeper runs.
+ expect(mockGenerationJobManager.expireApproval).toHaveBeenCalledWith(CONVO_ID, ACTION_ID);
+ });
+
+ it('400 when actionId is missing', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ const res = await post(approveBody({ actionId: undefined }));
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/actionId is required/i);
+ });
+
+ it('409 when actionId targets a stale action', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ const res = await post(approveBody({ actionId: 'stale-action' }));
+ expect(res.status).toBe(409);
+ expect(res.body.error).toMatch(/stale action/i);
+ });
+
+ it('400 when a tool call is left undecided', async () => {
+ const job = makeToolApprovalJob();
+ job.metadata.pendingAction.payload.action_requests = [
+ { tool_call_id: 'tc1' },
+ { tool_call_id: 'tc2' },
+ ];
+ job.metadata.pendingAction.payload.review_configs = [
+ { tool_call_id: 'tc1', allowed_decisions: ['approve', 'reject'] },
+ { tool_call_id: 'tc2', allowed_decisions: ['approve', 'reject'] },
+ ];
+ mockGenerationJobManager.getJob.mockResolvedValue(job);
+ const res = await post(approveBody()); // only decides tc1
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/must be decided/i);
+ expect(res.body.undecided).toEqual(['tc2']);
+ expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
+ });
+
+ it('403 when a decision is not permitted by the tool policy', async () => {
+ const job = makeToolApprovalJob();
+ // Policy restricts tc1 to reject only; an `approve` must be refused.
+ job.metadata.pendingAction.payload.review_configs = [
+ { tool_call_id: 'tc1', allowed_decisions: ['reject'] },
+ ];
+ mockGenerationJobManager.getJob.mockResolvedValue(job);
+ const res = await post(approveBody());
+ expect(res.status).toBe(403);
+ expect(res.body.error).toMatch(/not permitted/i);
+ expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
+ });
+
+ it('400 when an edit decision omits editedArguments', async () => {
+ const job = makeToolApprovalJob();
+ job.metadata.pendingAction.payload.review_configs = [
+ { tool_call_id: 'tc1', allowed_decisions: ['approve', 'edit'] },
+ ];
+ mockGenerationJobManager.getJob.mockResolvedValue(job);
+ const res = await post(
+ approveBody({ decisions: [{ tool_call_id: 'tc1', decision: 'edit' }] }),
+ );
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/editedArguments/i);
+ expect(res.body.incomplete).toEqual(['tc1']);
+ expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
+ });
+
+ it('400 when a respond decision omits responseText', async () => {
+ const job = makeToolApprovalJob();
+ job.metadata.pendingAction.payload.review_configs = [
+ { tool_call_id: 'tc1', allowed_decisions: ['approve', 'respond'] },
+ ];
+ mockGenerationJobManager.getJob.mockResolvedValue(job);
+ const res = await post(
+ approveBody({ decisions: [{ tool_call_id: 'tc1', decision: 'respond' }] }),
+ );
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/responseText/i);
+ });
+
+ it('accepts a complete edit decision (editedArguments present)', async () => {
+ const job = makeToolApprovalJob();
+ job.metadata.pendingAction.payload.review_configs = [
+ { tool_call_id: 'tc1', allowed_decisions: ['approve', 'edit'] },
+ ];
+ mockGenerationJobManager.getJob.mockResolvedValue(job);
+ const res = await post(
+ approveBody({
+ decisions: [{ tool_call_id: 'tc1', decision: 'edit', editedArguments: { q: 'x' } }],
+ }),
+ );
+ expect(res.status).toBe(200);
+ await settled;
+ await flush();
+ });
+
+ it('403 when the resume request fingerprint does not match the paused config', async () => {
+ const job = makeToolApprovalJob();
+ job.metadata.pendingAction.requestFingerprint = 'fingerprint-of-a-different-config';
+ mockGenerationJobManager.getJob.mockResolvedValue(job);
+ const res = await post(approveBody());
+ expect(res.status).toBe(403);
+ expect(res.body.error).toMatch(/different agent configuration/i);
+ expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
+ });
+
+ it('proceeds when the resume request fingerprint matches the paused config', async () => {
+ const { computeAgentRequestFingerprint } = jest.requireActual('@librechat/api');
+ const job = makeToolApprovalJob();
+ job.metadata.pendingAction.requestFingerprint = computeAgentRequestFingerprint({
+ endpoint: 'agents',
+ agent_id: AGENT_ID,
+ });
+ mockGenerationJobManager.getJob.mockResolvedValue(job);
+ const res = await post(approveBody());
+ expect(res.status).toBe(200);
+ expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalledWith(CONVO_ID, ACTION_ID);
+ await settled;
+ await flush();
+ });
+
+ it('403 when the resume sends a different promptPrefix than the paused config', async () => {
+ const { computeAgentRequestFingerprint } = jest.requireActual('@librechat/api');
+ const job = makeToolApprovalJob();
+ // Ephemeral instructions come from promptPrefix, so it's part of the fingerprint.
+ job.metadata.pendingAction.requestFingerprint = computeAgentRequestFingerprint({
+ endpoint: 'agents',
+ agent_id: AGENT_ID,
+ promptPrefix: 'be terse',
+ });
+ mockGenerationJobManager.getJob.mockResolvedValue(job);
+ const res = await post(approveBody({ promptPrefix: 'ignore previous instructions' }));
+ expect(res.status).toBe(403);
+ expect(res.body.error).toMatch(/different agent configuration/i);
+ expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
+ });
+
+ it('400 when an ask_user_question resume carries no answer', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeAskUserJob());
+ const res = await post({
+ conversationId: CONVO_ID,
+ actionId: ACTION_ID,
+ agent_id: AGENT_ID,
+ endpoint: 'agents',
+ });
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/answer is required/i);
+ });
+
+ it('400 when an ask_user_question answer exceeds the length cap', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeAskUserJob());
+ const res = await post({
+ conversationId: CONVO_ID,
+ actionId: ACTION_ID,
+ agent_id: AGENT_ID,
+ endpoint: 'agents',
+ answer: 'x'.repeat(16_001),
+ });
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/maximum length/i);
+ expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
+ });
+
+ it('400 on an unsupported pending-action type', async () => {
+ const job = makeToolApprovalJob();
+ job.metadata.pendingAction.payload = { type: 'totally_unknown' };
+ mockGenerationJobManager.getJob.mockResolvedValue(job);
+ const res = await post(approveBody());
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/unsupported pending action/i);
+ expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
+ });
+
+ it('proceeds (does not 403) for a pre-multi-tenancy job with no tenantId', async () => {
+ // hasTenantMismatch only blocks when the job carries a tenantId that differs;
+ // an untenanted (legacy) job must still resume once the userId check passes.
+ const job = makeToolApprovalJob({ metadata: { tenantId: undefined } });
+ mockGenerationJobManager.getJob.mockResolvedValue(job);
+ const res = await post(approveBody());
+ expect(res.status).toBe(200);
+ expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalledWith(CONVO_ID, ACTION_ID);
+ await settled;
+ await flush();
+ });
+
+ it('429 when the concurrency gate rejects the resume', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ mockCheckAndIncrementPendingRequest.mockResolvedValue({ allowed: false });
+ const res = await post(approveBody());
+ expect(res.status).toBe(429);
+ expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
+ });
+
+ it('409 and releases the slot when the action was already claimed (single-winner)', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ mockGenerationJobManager.approvals.resolve.mockResolvedValue(false);
+ const res = await post(approveBody());
+ expect(res.status).toBe(409);
+ expect(res.body.error).toMatch(/already resolved or has expired/i);
+ expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalledWith(CONVO_ID, ACTION_ID);
+ expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID);
+ expect(mockInitializeClient).not.toHaveBeenCalled();
+ });
+
+ it('releases the slot when the claim itself throws (store error, not a leak)', async () => {
+ // The increment happens before the claim, which runs before the run's own
+ // try/finally — a store error here must still release the slot or a retry of the
+ // still-paused approval gets spuriously 429'd until the counter TTL expires.
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ mockGenerationJobManager.approvals.resolve.mockRejectedValue(new Error('redis down'));
+ const res = await post(approveBody());
+ expect(res.status).toBe(500);
+ expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID);
+ expect(mockInitializeClient).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('happy path: approve -> reconstruct -> resume -> finalize', () => {
+ it('ACKs immediately and claims the action atomically with the submitted actionId', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ const res = await post(approveBody());
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({
+ streamId: CONVO_ID,
+ conversationId: CONVO_ID,
+ status: 'resuming',
+ });
+ expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalledWith(CONVO_ID, ACTION_ID);
+ await settled;
+ await flush();
+ });
+
+ it('seeds the thread parent before reconstruction and maps the decision to the SDK', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ await post(approveBody());
+ await settled;
+ await flush();
+
+ // initializeAgent scopes thread files off req.body.parentMessageId, seeded
+ // from the paused user message's parent before initializeClient runs.
+ expect(capturedInit.parentMessageId).toBe(THREAD_PARENT_ID);
+
+ expect(mockInitializeClient).toHaveBeenCalledTimes(1);
+ const client = await mockInitializeClient.mock.results[0].value.then((r) => r.client);
+ expect(client.resumeCompletion).toHaveBeenCalledWith(
+ expect.objectContaining({
+ resumeValue: { tc1: { type: 'approve' } },
+ userMCPAuthMap: { server1: { token: 't' } },
+ }),
+ );
+ });
+
+ it('restores the paused user message files before reconstruction (execute-code files)', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ // The resume body carries no files; the controller must source them from the
+ // persisted user message so an approved code/read-file tool keeps its uploads.
+ mockGetMessages.mockResolvedValue([{ files: [{ file_id: 'f1' }] }]);
+
+ await post(approveBody());
+ await settled;
+ await flush();
+
+ expect(capturedInit.files).toEqual([{ file_id: 'f1' }]);
+ });
+
+ it('ignores client-supplied resume files, sourcing from the paused job (security)', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ // The paused turn's authoritative files (DB row); a crafted client tries to swap them.
+ mockGetMessages.mockResolvedValue([{ files: [{ file_id: 'paused' }] }]);
+
+ await post(approveBody({ files: [{ file_id: 'attacker-supplied' }] }));
+ await settled;
+ await flush();
+
+ // The crafted client files must NOT reach initializeAgent — only the paused set.
+ expect(capturedInit.files).toEqual([{ file_id: 'paused' }]);
+ });
+
+ it('clears client-supplied resume files when the paused turn had none (security)', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ mockGetMessages.mockResolvedValue([{ files: [] }]); // the paused turn had no files
+
+ await post(approveBody({ files: [{ file_id: 'attacker-supplied' }] }));
+ await settled;
+ await flush();
+
+ expect(capturedInit.files).toEqual([]);
+ });
+
+ it('prefers job-metadata files over both the client body and the DB row', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(
+ makeToolApprovalJob({
+ metadata: {
+ userMessage: {
+ messageId: USER_MSG_ID,
+ parentMessageId: THREAD_PARENT_ID,
+ text: 'x',
+ files: [{ file_id: 'meta' }],
+ },
+ },
+ }),
+ );
+ mockGetMessages.mockResolvedValue([{ files: [{ file_id: 'db' }] }]);
+
+ await post(approveBody({ files: [{ file_id: 'attacker-supplied' }] }));
+ await settled;
+ await flush();
+
+ expect(capturedInit.files).toEqual([{ file_id: 'meta' }]);
+ });
+
+ it('carries the restored files onto the final requestMessage (user bubble keeps attachments)', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ // job.metadata.userMessage is persisted without files; the final SSE must still
+ // carry the restored uploads or the user bubble loses its attachments on resume.
+ mockGetMessages.mockResolvedValue([{ files: [{ file_id: 'f1', filename: 'a.pdf' }] }]);
+
+ await post(approveBody());
+ await settled;
+ await flush();
+
+ const [, finalEvent] = mockGenerationJobManager.emitDone.mock.calls[0];
+ expect(finalEvent.requestMessage).toMatchObject({
+ messageId: USER_MSG_ID,
+ isCreatedByUser: true,
+ files: [{ file_id: 'f1', filename: 'a.pdf' }],
+ });
+ });
+
+ it('persists the finished response, emits done, completes the job, and prunes the checkpoint', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ await post(approveBody());
+ await settled;
+ await flush();
+
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ userId: USER_ID, isTemporary: false }),
+ expect.objectContaining({
+ messageId: RESPONSE_MSG_ID,
+ parentMessageId: USER_MSG_ID,
+ conversationId: CONVO_ID,
+ content: [{ type: 'text', text: 'resumed answer' }],
+ unfinished: false,
+ error: false,
+ isCreatedByUser: false,
+ user: USER_ID,
+ agent_id: AGENT_ID,
+ }),
+ expect.objectContaining({
+ context: 'api/server/controllers/agents/resume.js - resumed response end',
+ }),
+ );
+
+ // Assert the finalEvent STRUCTURE, not just the hardcoded `final: true` literal —
+ // a `final: true`-only check would still pass if the entire content / title /
+ // requestMessage build in finalizeResumedTurn were deleted.
+ const [doneStreamId, finalEvent] = mockGenerationJobManager.emitDone.mock.calls[0];
+ expect(doneStreamId).toBe(CONVO_ID);
+ expect(finalEvent).toMatchObject({
+ final: true,
+ conversation: { conversationId: CONVO_ID },
+ responseMessage: {
+ messageId: RESPONSE_MSG_ID,
+ content: [{ type: 'text', text: 'resumed answer' }],
+ unfinished: false,
+ },
+ requestMessage: { messageId: USER_MSG_ID, isCreatedByUser: true },
+ });
+ expect(typeof finalEvent.title).toBe('string');
+
+ expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID);
+ expect(mockDeleteAgentCheckpoint).toHaveBeenCalledWith(CONVO_ID, { type: 'mongo' });
+ expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID);
+ expect(mockDisposeClient).toHaveBeenCalledTimes(1);
+ });
+
+ it('skips finalization (no save/emitDone/complete) when the job was replaced mid-resume', async () => {
+ // The paused job has createdAt 1000; a concurrent request reused this conversationId,
+ // so the live job now has a different createdAt — finalizing would clobber the newer
+ // turn's job. The finally still runs (slot release), so `settled` resolves.
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob({ createdAt: 1000 }));
+ mockJobStore.getJob.mockResolvedValue({
+ tokenUsage: null,
+ contextUsage: null,
+ createdAt: 2000,
+ });
+ await post(approveBody());
+ await settled;
+ await flush();
+
+ expect(mockSaveMessage).not.toHaveBeenCalled();
+ expect(mockGenerationJobManager.emitDone).not.toHaveBeenCalled();
+ expect(mockGenerationJobManager.completeJob).not.toHaveBeenCalled();
+ });
+
+ it('does not release the slot in the finally when the client already released it on pause', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ // Simulate handleRunInterrupt having released the concurrency slot on a re-pause.
+ mockInitializeClient.mockResolvedValue({
+ client: makeClient({ pendingRequestReleased: true }),
+ userMCPAuthMap: {},
+ });
+ let disposed;
+ const disposedP = new Promise((resolve) => {
+ disposed = resolve;
+ });
+ mockDisposeClient.mockImplementation(() => disposed());
+
+ await post(approveBody());
+ await disposedP;
+ await flush();
+
+ // The finally must NOT double-release — handleRunInterrupt already did.
+ expect(mockDecrementPendingRequest).not.toHaveBeenCalled();
+ });
+
+ it('persists tool artifacts produced by the resumed continuation as attachments', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ const artifact = { type: 'image', file_id: 'img-1' };
+ // The lean resume path bypasses BaseClient.sendMessage's artifact await, so the
+ // controller must await client.artifactPromises itself (and drop null results).
+ mockInitializeClient.mockResolvedValue({
+ client: makeClient({
+ artifactPromises: [Promise.resolve(artifact), Promise.resolve(null)],
+ }),
+ userMCPAuthMap: {},
+ });
+
+ await post(approveBody());
+ await settled;
+ await flush();
+
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ attachments: [artifact] }),
+ expect.anything(),
+ );
+ });
+
+ it('falls back to the aggregated store content when the live client content is empty', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ // No live content on the rebuilt client → the saved response must use the
+ // pre-pause aggregated content from the store, not an empty array.
+ mockInitializeClient.mockResolvedValue({
+ client: makeClient({ contentParts: [] }),
+ userMCPAuthMap: {},
+ });
+ mockGenerationJobManager.getResumeState.mockResolvedValue({
+ aggregatedContent: [{ type: 'text', text: 'from-store' }],
+ });
+
+ await post(approveBody());
+ await settled;
+ await flush();
+
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ content: [{ type: 'text', text: 'from-store' }] }),
+ expect.anything(),
+ );
+ });
+
+ it('strips malformed tool_call parts from the saved content', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ mockInitializeClient.mockResolvedValue({
+ client: makeClient({
+ contentParts: [
+ { type: 'text', text: 'kept' },
+ { type: 'tool_call' }, // malformed: no tool_call payload — must be filtered
+ ],
+ }),
+ userMCPAuthMap: {},
+ });
+
+ await post(approveBody());
+ await settled;
+ await flush();
+
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ content: [{ type: 'text', text: 'kept' }] }),
+ expect.anything(),
+ );
+ });
+
+ it('merges previously persisted attachments with the resumed segment artifacts', async () => {
+ const priorArtifact = { type: 'image', file_id: 'prior-1' };
+ const newArtifact = { type: 'image', file_id: 'new-1' };
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ // An earlier pause segment already saved an attachment on the response row.
+ mockGetMessages.mockResolvedValue([{ attachments: [priorArtifact] }]);
+ mockInitializeClient.mockResolvedValue({
+ client: makeClient({ artifactPromises: [Promise.resolve(newArtifact)] }),
+ userMCPAuthMap: {},
+ });
+
+ await post(approveBody());
+ await settled;
+ await flush();
+
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ attachments: [priorArtifact, newArtifact] }),
+ expect.anything(),
+ );
+ });
+
+ it('persists the resumed run context calibration (contextMeta) onto the saved response', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ const contextMeta = { calibrationRatio: 0.8 };
+ mockInitializeClient.mockResolvedValue({
+ client: makeClient({ contextMeta }),
+ userMCPAuthMap: {},
+ });
+
+ await post(approveBody());
+ await settled;
+ await flush();
+
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ contextMeta }),
+ expect.anything(),
+ );
+ });
+
+ it('carries manualSkills/alwaysAppliedSkills onto the resumed requestMessage', async () => {
+ const job = makeToolApprovalJob();
+ job.metadata.userMessage.manualSkills = ['skill-a'];
+ job.metadata.userMessage.alwaysAppliedSkills = ['skill-b'];
+ mockGenerationJobManager.getJob.mockResolvedValue(job);
+
+ await post(approveBody());
+ await settled;
+ await flush();
+
+ const [, finalEvent] = mockGenerationJobManager.emitDone.mock.calls[0];
+ expect(finalEvent.requestMessage).toMatchObject({
+ manualSkills: ['skill-a'],
+ alwaysAppliedSkills: ['skill-b'],
+ });
+ });
+
+ it('attaches client response metadata to the saved message when present', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ const contextUsage = { tokenCount: 1234 };
+ mockInitializeClient.mockResolvedValue({
+ client: makeClient({ buildResponseMetadata: jest.fn(() => ({ contextUsage })) }),
+ userMCPAuthMap: {},
+ });
+
+ await post(approveBody());
+ await settled;
+ await flush();
+
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ metadata: expect.objectContaining({ contextUsage }) }),
+ expect.anything(),
+ );
+ });
+
+ it('resumes an ask_user_question with the free-form answer', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeAskUserJob());
+ const res = await post({
+ conversationId: CONVO_ID,
+ actionId: ACTION_ID,
+ agent_id: AGENT_ID,
+ endpoint: 'agents',
+ answer: 'call it report.pdf',
+ });
+ expect(res.status).toBe(200);
+ await settled;
+ await flush();
+
+ const client = await mockInitializeClient.mock.results[0].value.then((r) => r.client);
+ expect(client.resumeCompletion).toHaveBeenCalledWith(
+ expect.objectContaining({ resumeValue: { answer: 'call it report.pdf' } }),
+ );
+ expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID);
+ });
+
+ it('generates a title for a first-turn pause before completing the stream', async () => {
+ const job = makeToolApprovalJob();
+ job.metadata.userMessage.parentMessageId = Constants.NO_PARENT;
+ mockGenerationJobManager.getJob.mockResolvedValue(job);
+ mockGetConvo.mockResolvedValue({ title: 'New Chat' });
+
+ await post(approveBody());
+ await settled;
+ await flush();
+
+ expect(mockAddTitle).toHaveBeenCalledTimes(1);
+ // Title is emitted (and the job completed) — order matters but both must happen.
+ expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID);
+ });
+
+ it('still finalizes the turn when first-turn title generation throws', async () => {
+ const job = makeToolApprovalJob();
+ job.metadata.userMessage.parentMessageId = Constants.NO_PARENT;
+ mockGenerationJobManager.getJob.mockResolvedValue(job);
+ mockGetConvo.mockResolvedValue({ title: 'New Chat' });
+ // Title generation is best-effort: a throw must not break the resumed turn.
+ mockAddTitle.mockRejectedValue(new Error('title service down'));
+
+ await post(approveBody());
+ await settled;
+ await flush();
+
+ expect(mockLogger.error).toHaveBeenCalled();
+ expect(mockSaveMessage).toHaveBeenCalledTimes(1);
+ expect(mockGenerationJobManager.emitDone).toHaveBeenCalledWith(CONVO_ID, expect.any(Object));
+ expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID);
+ });
+ });
+
+ describe('non-finalizing outcomes', () => {
+ it('re-pause: does not finalize when the run pauses again', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ mockInitializeClient.mockResolvedValue({
+ client: makeClient({ pendingApproval: true }),
+ userMCPAuthMap: {},
+ });
+
+ const res = await post(approveBody());
+ expect(res.status).toBe(200);
+ await settled;
+ await flush();
+
+ // It persists progress (unfinished) but must NOT finalize the turn.
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ unfinished: true }),
+ expect.anything(),
+ );
+ expect(mockGenerationJobManager.emitDone).not.toHaveBeenCalled();
+ expect(mockGenerationJobManager.completeJob).not.toHaveBeenCalled();
+ expect(mockDeleteAgentCheckpoint).not.toHaveBeenCalled();
+ // The slot is still released and the client disposed.
+ expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID);
+ expect(mockDisposeClient).toHaveBeenCalledTimes(1);
+ });
+
+ it('re-pause: persists the segment content (unfinished) so an expiring re-pause keeps it', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ mockInitializeClient.mockResolvedValue({
+ client: makeClient({
+ pendingApproval: true,
+ contentParts: [{ type: 'text', text: 'streamed this segment' }],
+ artifactPromises: [],
+ }),
+ userMCPAuthMap: {},
+ });
+
+ const res = await post(approveBody());
+ expect(res.status).toBe(200);
+ await settled;
+ await flush();
+
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ content: [{ type: 'text', text: 'streamed this segment' }],
+ unfinished: true,
+ }),
+ expect.objectContaining({
+ context: 'api/server/controllers/agents/resume.js - re-pause progress persist',
+ }),
+ );
+ expect(mockGenerationJobManager.emitDone).not.toHaveBeenCalled();
+ });
+
+ it('re-pause: persists artifacts produced before pausing again (unfinished)', async () => {
+ const artifact = { type: 'image', file_id: 'seg-1' };
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ mockInitializeClient.mockResolvedValue({
+ client: makeClient({
+ pendingApproval: true,
+ artifactPromises: [Promise.resolve(artifact)],
+ }),
+ userMCPAuthMap: {},
+ });
+
+ const res = await post(approveBody());
+ expect(res.status).toBe(200);
+ await settled;
+ await flush();
+
+ // No finalize, but the segment's artifact is persisted unfinished so the next
+ // resume's finalize can merge it (otherwise the fresh client drops it).
+ expect(mockGenerationJobManager.emitDone).not.toHaveBeenCalled();
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ attachments: [artifact], unfinished: true }),
+ expect.objectContaining({
+ context: 'api/server/controllers/agents/resume.js - re-pause progress persist',
+ }),
+ );
+ });
+
+ it('abort-during-resume: lets the abort route finalize, does not double-save', async () => {
+ const job = makeToolApprovalJob();
+ mockGenerationJobManager.getJob.mockResolvedValue(job);
+ mockInitializeClient.mockImplementation(async () => {
+ job.abortController.abort();
+ return { client: makeClient(), userMCPAuthMap: {} };
+ });
+
+ const res = await post(approveBody());
+ expect(res.status).toBe(200);
+ await settled;
+ await flush();
+
+ expect(mockSaveMessage).not.toHaveBeenCalled();
+ expect(mockGenerationJobManager.emitDone).not.toHaveBeenCalled();
+ expect(mockGenerationJobManager.completeJob).not.toHaveBeenCalled();
+ expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID);
+ });
+
+ it('resume failure: emits an error, finalizes the job, and prunes the checkpoint', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ mockInitializeClient.mockResolvedValue({
+ client: makeClient({
+ resumeCompletion: jest.fn().mockRejectedValue(new Error('boom')),
+ }),
+ userMCPAuthMap: {},
+ });
+
+ const res = await post(approveBody());
+ expect(res.status).toBe(200); // already ACKed before the failure
+ await settled;
+ await flush();
+
+ expect(mockGenerationJobManager.emitError).toHaveBeenCalledWith(CONVO_ID, 'boom');
+ expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID, 'boom');
+ expect(mockDeleteAgentCheckpoint).toHaveBeenCalledWith(CONVO_ID, { type: 'mongo' });
+ expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID);
+ expect(mockSaveMessage).not.toHaveBeenCalled();
+ });
+
+ it('forces a terminal job state when completeJob also fails during a resume error', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
+ mockInitializeClient.mockResolvedValue({
+ client: makeClient({
+ resumeCompletion: jest.fn().mockRejectedValue(new Error('boom')),
+ }),
+ userMCPAuthMap: {},
+ });
+ // The error path's completeJob also fails → last-resort updateJob must force a
+ // terminal state so the job isn't orphaned in `running`.
+ mockGenerationJobManager.completeJob.mockRejectedValue(new Error('complete failed'));
+
+ await post(approveBody());
+ await settled;
+ await flush();
+
+ expect(mockJobStore.updateJob).toHaveBeenCalledWith(
+ CONVO_ID,
+ expect.objectContaining({ status: 'error', error: 'Resume failed' }),
+ );
+ expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID);
+ });
+ });
+});
diff --git a/api/server/controllers/agents/__tests__/usageEvents.integration.spec.js b/api/server/controllers/agents/__tests__/usageEvents.integration.spec.js
new file mode 100644
index 0000000..caa14f6
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/usageEvents.integration.spec.js
@@ -0,0 +1,477 @@
+const { z } = require('zod');
+const { tool } = require('@langchain/core/tools');
+const { ChatGenerationChunk } = require('@langchain/core/outputs');
+const { HumanMessage, AIMessage, AIMessageChunk } = require('@langchain/core/messages');
+const {
+ Run,
+ Providers,
+ GraphEvents,
+ FakeChatModel,
+ createContentAggregator,
+} = require('@librechat/agents');
+const {
+ GenerationJobManager,
+ aggregateEmittedUsage,
+ resolveAgentTokenConfig,
+ buildPersistedContextUsage,
+} = require('@librechat/api');
+const { getDefaultHandlers } = require('~/server/controllers/agents/callbacks');
+
+jest.mock('nanoid', () => ({
+ nanoid: jest.fn(() => 'mock-nanoid'),
+}));
+
+jest.mock('~/server/services/Files/Citations', () => ({
+ processFileCitations: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/Code/process', () => ({
+ processCodeOutput: jest.fn(),
+ runPreviewFinalize: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/process', () => ({
+ saveBase64Image: jest.fn(),
+}));
+
+/** Real pipeline guard: published lib versions without the event skip its assertions */
+const hasContextUsageEvent = GraphEvents.ON_CONTEXT_USAGE != null;
+
+/**
+ * FakeChatModel that attaches provider-style usage_metadata on a final
+ * empty chunk (the OpenAI streaming pattern), so CHAT_MODEL_END carries
+ * aggregated usage through the real @librechat/agents pipeline.
+ */
+class UsageFakeModel extends FakeChatModel {
+ constructor(options, usagePerCall) {
+ super(options);
+ this.usagePerCall = usagePerCall;
+ this.usageCallIndex = 0;
+ }
+
+ async *_streamResponseChunks(messages, options, runManager) {
+ yield* super._streamResponseChunks(messages, options, runManager);
+ const index = Math.min(this.usageCallIndex, this.usagePerCall.length - 1);
+ this.usageCallIndex += 1;
+ yield new ChatGenerationChunk({
+ text: '',
+ message: new AIMessageChunk({ content: '', usage_metadata: this.usagePerCall[index] }),
+ });
+ }
+}
+
+const addTool = tool(async ({ a, b }) => String(a + b), {
+ name: 'add',
+ description: 'Add two numbers',
+ schema: z.object({ a: z.number(), b: z.number() }),
+});
+
+const charCounter = (msg) => {
+ const content = msg.content;
+ if (typeof content === 'string') {
+ return content.length + 3;
+ }
+ if (Array.isArray(content)) {
+ let length = 3;
+ for (const part of content) {
+ if (typeof part === 'string') {
+ length += part.length;
+ } else if (typeof part?.text === 'string') {
+ length += part.text.length;
+ }
+ }
+ return length;
+ }
+ return 3;
+};
+
+function createMockRes() {
+ const events = [];
+ return {
+ events,
+ headersSent: true,
+ writableEnded: false,
+ write(payload) {
+ for (const line of String(payload).split('\n')) {
+ if (line.startsWith('data: ')) {
+ events.push(JSON.parse(line.slice(6)));
+ }
+ }
+ return true;
+ },
+ };
+}
+
+const FIRST_CALL_USAGE = {
+ input_tokens: 100,
+ output_tokens: 20,
+ total_tokens: 120,
+};
+
+const SECOND_CALL_USAGE = {
+ input_tokens: 150,
+ output_tokens: 10,
+ total_tokens: 160,
+ input_token_details: { cache_creation: 30, cache_read: 50 },
+};
+
+const MAX_CONTEXT_TOKENS = 8000;
+
+async function runToolLoop({
+ res,
+ streamId = null,
+ collectedUsage,
+ contextUsageSink = null,
+ usageEmitSink = null,
+ usageCost = null,
+}) {
+ const { contentParts, aggregateContent } = createContentAggregator();
+ const handlers = getDefaultHandlers({
+ res,
+ aggregateContent,
+ toolEndCallback: () => {},
+ collectedUsage,
+ streamId,
+ contextUsageSink,
+ usageEmitSink,
+ usageCost,
+ });
+
+ const run = await Run.create({
+ runId: 'usage-e2e-response',
+ graphConfig: {
+ type: 'standard',
+ llmConfig: {
+ provider: Providers.OPENAI,
+ model: 'gpt-4o-mini',
+ streaming: true,
+ streamUsage: false,
+ },
+ instructions: 'You are a helpful assistant.',
+ maxContextTokens: MAX_CONTEXT_TOKENS,
+ tools: [addTool],
+ },
+ returnContent: true,
+ customHandlers: handlers,
+ tokenCounter: charCounter,
+ indexTokenCountMap: {},
+ });
+
+ run.Graph.overrideModel = new UsageFakeModel(
+ {
+ responses: ['Let me calculate that.', 'The answer is 4.'],
+ toolCalls: [{ name: 'add', args: { a: 2, b: 2 }, id: 'tc_1', type: 'tool_call' }],
+ },
+ [FIRST_CALL_USAGE, SECOND_CALL_USAGE],
+ );
+
+ await run.processStream(
+ { messages: [new HumanMessage('What is 2+2?')] },
+ {
+ configurable: { thread_id: 'usage-e2e-thread', user_id: 'user-1' },
+ streamMode: 'values',
+ version: 'v2',
+ },
+ );
+
+ return { run, contentParts };
+}
+
+describe('usage events through the real agents pipeline', () => {
+ jest.setTimeout(30000);
+
+ afterAll(async () => {
+ await GenerationJobManager.destroy();
+ });
+
+ test('emits on_token_usage per model call with collectedUsage parity', async () => {
+ const res = createMockRes();
+ const collectedUsage = [];
+ const { contentParts } = await runToolLoop({ res, collectedUsage });
+
+ const usageEvents = res.events.filter((e) => e.event === 'on_token_usage');
+ expect(usageEvents).toHaveLength(2);
+
+ expect(usageEvents[0].data).toMatchObject(FIRST_CALL_USAGE);
+ expect(usageEvents[1].data).toMatchObject(SECOND_CALL_USAGE);
+ expect(usageEvents[0].data.provider).toBe(Providers.OPENAI);
+ expect(usageEvents[0].data.model).toBeTruthy();
+ expect(usageEvents[0].data.usage_type).toBeUndefined();
+
+ expect(collectedUsage).toHaveLength(2);
+ expect(collectedUsage[0]).toMatchObject(FIRST_CALL_USAGE);
+ expect(collectedUsage[1]).toMatchObject(SECOND_CALL_USAGE);
+
+ const text = contentParts
+ .filter((part) => part?.type === 'text')
+ .map((part) => part.text)
+ .join('');
+ expect(text).toContain('The answer is 4.');
+ });
+
+ test('emits a context snapshot before each model call', async () => {
+ if (!hasContextUsageEvent) {
+ console.warn('Skipping: installed @librechat/agents predates ON_CONTEXT_USAGE');
+ return;
+ }
+ const res = createMockRes();
+ const { run } = await runToolLoop({ res, collectedUsage: [] });
+ expect(run).toBeDefined();
+
+ const contextEvents = res.events.filter((e) => e.event === 'on_context_usage');
+ expect(contextEvents).toHaveLength(2);
+
+ for (const event of contextEvents) {
+ const { breakdown, contextBudget, remainingContextTokens, effectiveInstructionTokens } =
+ event.data;
+ expect(breakdown.maxContextTokens).toBe(MAX_CONTEXT_TOKENS);
+ expect(contextBudget).toBeGreaterThan(0);
+ expect(contextBudget).toBeLessThanOrEqual(MAX_CONTEXT_TOKENS);
+ expect(effectiveInstructionTokens).toBeGreaterThan(0);
+ expect(remainingContextTokens).toBeGreaterThan(0);
+ expect(remainingContextTokens).toBeLessThan(contextBudget);
+ expect(breakdown.toolTokenCounts.add).toBeGreaterThan(0);
+ }
+
+ /** Tool loop grows the context between calls */
+ expect(contextEvents[1].data.prePruneContextTokens).toBeGreaterThan(
+ contextEvents[0].data.prePruneContextTokens,
+ );
+
+ /** Snapshot precedes the call's usage event */
+ const firstContextIndex = res.events.findIndex((e) => e.event === 'on_context_usage');
+ const firstUsageIndex = res.events.findIndex((e) => e.event === 'on_token_usage');
+ expect(firstContextIndex).toBeGreaterThanOrEqual(0);
+ expect(firstContextIndex).toBeLessThan(firstUsageIndex);
+ });
+
+ test('captures the usage rollup + latest context snapshot for message persistence', () => {
+ const res = createMockRes();
+ const contextUsageSink = { latest: null };
+ const usageEmitSink = [];
+ return runToolLoop({ res, collectedUsage: [], contextUsageSink, usageEmitSink }).then(() => {
+ /** Both model calls' emitted payloads are captured for the rollup */
+ expect(usageEmitSink).toHaveLength(2);
+
+ const usage = aggregateEmittedUsage(usageEmitSink);
+ /** Display units: openAI is cache-subset, so input excludes cache
+ * (150−30−50=70); output is repaired completion */
+ expect(usage).toEqual({
+ input:
+ FIRST_CALL_USAGE.input_tokens +
+ (SECOND_CALL_USAGE.input_tokens -
+ SECOND_CALL_USAGE.input_token_details.cache_creation -
+ SECOND_CALL_USAGE.input_token_details.cache_read),
+ output: FIRST_CALL_USAGE.output_tokens + SECOND_CALL_USAGE.output_tokens,
+ cacheWrite: SECOND_CALL_USAGE.input_token_details.cache_creation,
+ cacheRead: SECOND_CALL_USAGE.input_token_details.cache_read,
+ });
+ /** contextCost off → no cost folded into the rollup */
+ expect(usage.cost).toBeUndefined();
+
+ if (hasContextUsageEvent) {
+ expect(contextUsageSink.latest).not.toBeNull();
+ const persisted = buildPersistedContextUsage(contextUsageSink.latest);
+ expect(persisted.breakdown.maxContextTokens).toBe(MAX_CONTEXT_TOKENS);
+ /** Zero-valued tool counts are trimmed from the persisted blob */
+ for (const count of Object.values(persisted.breakdown.toolTokenCounts ?? {})) {
+ expect(count).toBeGreaterThan(0);
+ }
+ }
+ });
+ });
+
+ test('folds authoritative per-event cost into the rollup when contextCost is on', async () => {
+ const res = createMockRes();
+ const usageEmitSink = [];
+ /** Stub pricing mirroring getMultiplier/getCacheMultiplier shape */
+ const usageCost = {
+ enabled: true,
+ pricing: {
+ getMultiplier: ({ tokenType }) => (tokenType === 'completion' ? 15 : 3),
+ getCacheMultiplier: ({ cacheType }) => (cacheType === 'write' ? 3.75 : 0.3),
+ },
+ };
+ await runToolLoop({ res, collectedUsage: [], usageEmitSink, usageCost });
+
+ for (const event of usageEmitSink) {
+ expect(typeof event.cost).toBe('number');
+ }
+ const usage = aggregateEmittedUsage(usageEmitSink);
+ expect(usage.cost).toBeGreaterThan(0);
+ expect(usage.cost).toBeCloseTo(usageEmitSink.reduce((sum, e) => sum + e.cost, 0));
+ });
+
+ test('emit path prices each call by its producing agent and strips the agentId tag', () => {
+ const res = createMockRes();
+ const usageEmitSink = [];
+ /** Two endpoints share a model id but bill at different rates. */
+ const primaryConfig = { 'gpt-4': { prompt: 0.01, completion: 0.03, context: 8192 } };
+ const subagentConfig = { 'gpt-4': { prompt: 0.05, completion: 0.15, context: 8192 } };
+ const byAgentId = new Map([
+ ['primary', primaryConfig],
+ ['sub', subagentConfig],
+ ]);
+ const usageCost = {
+ enabled: true,
+ endpointTokenConfig: primaryConfig,
+ pricing: {
+ getMultiplier: ({ tokenType, model, endpointTokenConfig }) =>
+ endpointTokenConfig?.[model]?.[tokenType] ?? 0,
+ getCacheMultiplier: () => 0,
+ },
+ resolveEndpointTokenConfig: (usage) =>
+ resolveAgentTokenConfig({ agentId: usage?.agentId, byAgentId, fallback: primaryConfig }),
+ };
+
+ const { aggregateContent } = createContentAggregator();
+ const handlers = getDefaultHandlers({
+ res,
+ aggregateContent,
+ toolEndCallback: () => {},
+ collectedUsage: [],
+ usageEmitSink,
+ usageCost,
+ });
+ /** The CHAT_MODEL_END handler's emitUsage IS the real emitTokenUsage closure. */
+ const emitUsage = handlers[GraphEvents.CHAT_MODEL_END].emitUsage;
+ const call = { model: 'gpt-4', input_tokens: 100, output_tokens: 50, total_tokens: 150 };
+ emitUsage({ ...call, agentId: 'sub' });
+ emitUsage({ ...call, agentId: 'primary' });
+
+ const events = res.events.filter((e) => e.event === 'on_token_usage');
+ expect(events).toHaveLength(2);
+ /** agentId is an internal pricing tag — never streamed to the client nor
+ * folded into the persisted rollup. */
+ for (const e of events) {
+ expect(e.data.agentId).toBeUndefined();
+ }
+ for (const entry of usageEmitSink) {
+ expect(entry.agentId).toBeUndefined();
+ }
+ /** Same tokens + model id, but the subagent endpoint's higher rates price
+ * its call above the primary — proving per-agent emit pricing. The 5× ratio
+ * ((100·0.05+50·0.15)/(100·0.01+50·0.03)) is scale-independent of credit units. */
+ expect(events[1].data.cost).toBeGreaterThan(0);
+ expect(events[0].data.cost).toBeGreaterThan(events[1].data.cost);
+ expect(events[0].data.cost / events[1].data.cost).toBeCloseTo(5);
+ });
+
+ test('persists usage and context snapshot for resume via GenerationJobManager', async () => {
+ const streamId = `usage-e2e-stream-${Date.now()}`;
+ await GenerationJobManager.createJob(streamId, 'user-1', 'convo-1');
+
+ const res = createMockRes();
+ await runToolLoop({ res, streamId, collectedUsage: [] });
+
+ const resumeState = await GenerationJobManager.getResumeState(streamId);
+ expect(resumeState).not.toBeNull();
+
+ expect(resumeState.collectedUsage).toHaveLength(2);
+ expect(resumeState.collectedUsage[0]).toMatchObject(FIRST_CALL_USAGE);
+ expect(resumeState.collectedUsage[1]).toMatchObject(SECOND_CALL_USAGE);
+
+ if (hasContextUsageEvent) {
+ expect(resumeState.contextUsage.breakdown.maxContextTokens).toBe(MAX_CONTEXT_TOKENS);
+ /** Latest-wins: the persisted snapshot is the second call's */
+ expect(resumeState.contextUsage.prePruneContextTokens).toBeGreaterThan(0);
+ /** Reconciled to the final primary call's actual prompt: openAI folds cache
+ * into input_tokens (150), so the resume snapshot's used = 150 — the real
+ * context, not the calibrated estimate. */
+ const used =
+ resumeState.contextUsage.contextBudget - resumeState.contextUsage.remainingContextTokens;
+ expect(used).toBe(SECOND_CALL_USAGE.input_tokens);
+ }
+ });
+
+ /** Drives a real summarization (tight context + padded history); self-summarize
+ * reuses the overridden fake model so no API key is needed. */
+ async function runSummarizationLoop({ res, collectedUsage, contextUsageSink, usageEmitSink }) {
+ const { aggregateContent } = createContentAggregator();
+ const handlers = getDefaultHandlers({
+ res,
+ aggregateContent,
+ toolEndCallback: () => {},
+ collectedUsage,
+ contextUsageSink,
+ usageEmitSink,
+ summarizationOptions: { enabled: true },
+ });
+
+ const pad = 'context detail to overflow the tiny budget. '.repeat(40);
+ const history = [
+ new HumanMessage(`Turn 1 question. ${pad}`),
+ new AIMessage(`Turn 1 answer. ${pad}`),
+ new HumanMessage(`Turn 2 question. ${pad}`),
+ new AIMessage(`Turn 2 answer. ${pad}`),
+ new HumanMessage(`Final question after a lot of prior history. ${pad}`),
+ ];
+ const indexTokenCountMap = {};
+ history.forEach((message, i) => {
+ indexTokenCountMap[i] = charCounter(message);
+ });
+
+ const run = await Run.create({
+ runId: `summ-e2e-${Date.now()}`,
+ graphConfig: {
+ type: 'standard',
+ llmConfig: {
+ provider: Providers.OPENAI,
+ model: 'gpt-4o-mini',
+ streaming: true,
+ streamUsage: false,
+ },
+ instructions: 'You are a helpful assistant.',
+ maxContextTokens: 700,
+ summarizationEnabled: true,
+ summarizationConfig: { provider: Providers.OPENAI, model: 'gpt-4o-mini' },
+ },
+ returnContent: true,
+ customHandlers: handlers,
+ tokenCounter: charCounter,
+ indexTokenCountMap,
+ });
+
+ run.Graph.overrideModel = new UsageFakeModel(
+ { responses: ['## Summary\nPrior turns compacted.', 'Here is the final answer.'] },
+ [{ input_tokens: 40, output_tokens: 8, total_tokens: 48 }],
+ );
+
+ await run.processStream(
+ { messages: history },
+ {
+ configurable: { thread_id: 'summ-e2e-thread', user_id: 'user-1' },
+ streamMode: 'values',
+ version: 'v2',
+ },
+ );
+ return run;
+ }
+
+ /** A summarized turn compacts the context (summary tokens replace the older
+ * turns) and the reduced snapshot is persisted — the latest snapshot is
+ * followed by a primary usage, so the save guard keeps it and the client
+ * uses the snapshot (not the inflated whole-history estimate). */
+ test('persists the reduced (compacted) snapshot after summarization', async () => {
+ if (!hasContextUsageEvent) {
+ return;
+ }
+ const res = createMockRes();
+ const contextUsageSink = { latest: null, count: 0 };
+ const usageEmitSink = [];
+ await runSummarizationLoop({ res, collectedUsage: [], contextUsageSink, usageEmitSink });
+
+ const snapshot = contextUsageSink.latest;
+ /** Summarization fired: a summary exists and the kept message tokens are
+ * small (the compacted context, not the full history). */
+ expect(snapshot?.breakdown?.summaryTokens).toBeGreaterThan(0);
+ expect(snapshot?.breakdown?.messageTokens).toBeLessThan(snapshot?.breakdown?.summaryTokens);
+
+ /** The save guard keeps it: a primary usage follows the latest snapshot. */
+ const afterLatest = usageEmitSink.slice(contextUsageSink.latestUsageIndex ?? 0);
+ expect(afterLatest.some((e) => e.usage_type == null)).toBe(true);
+ expect(
+ buildPersistedContextUsage(snapshot, usageEmitSink).breakdown.summaryTokens,
+ ).toBeGreaterThan(0);
+ });
+});
diff --git a/api/server/controllers/agents/__tests__/usageEvents.live.spec.js b/api/server/controllers/agents/__tests__/usageEvents.live.spec.js
new file mode 100644
index 0000000..ff02c93
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/usageEvents.live.spec.js
@@ -0,0 +1,161 @@
+/**
+ * Live host-layer verification: real Anthropic run through the actual
+ * getDefaultHandlers pipeline, asserting the SSE usage/context events the
+ * client consumes and their resume persistence.
+ *
+ * Run with:
+ * RUN_USAGE_LIVE_TESTS=1 ANTHROPIC_API_KEY=... npx jest usageEvents.live --runInBand
+ */
+const { HumanMessage } = require('@langchain/core/messages');
+const { Run, Providers, GraphEvents } = require('@librechat/agents');
+const { GenerationJobManager } = require('@librechat/api');
+const { getDefaultHandlers } = require('~/server/controllers/agents/callbacks');
+
+jest.mock('nanoid', () => ({
+ nanoid: jest.fn(() => 'mock-nanoid'),
+}));
+
+jest.mock('~/server/services/Files/Citations', () => ({
+ processFileCitations: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/Code/process', () => ({
+ processCodeOutput: jest.fn(),
+ runPreviewFinalize: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/process', () => ({
+ saveBase64Image: jest.fn(),
+}));
+
+const shouldRunLive =
+ process.env.RUN_USAGE_LIVE_TESTS === '1' &&
+ process.env.ANTHROPIC_API_KEY != null &&
+ process.env.ANTHROPIC_API_KEY !== '';
+
+const describeIfLive = shouldRunLive ? describe : describe.skip;
+const modelName = process.env.ANTHROPIC_USAGE_LIVE_MODEL ?? 'claude-haiku-4-5';
+const hasContextUsageEvent = GraphEvents.ON_CONTEXT_USAGE != null;
+
+const charCounter = (msg) => {
+ const content = msg.content;
+ if (typeof content === 'string') {
+ return Math.ceil(content.length / 4) + 3;
+ }
+ if (Array.isArray(content)) {
+ let length = 3;
+ for (const part of content) {
+ if (typeof part === 'string') {
+ length += Math.ceil(part.length / 4);
+ } else if (typeof part?.text === 'string') {
+ length += Math.ceil(part.text.length / 4);
+ }
+ }
+ return length;
+ }
+ return 3;
+};
+
+function createMockRes() {
+ const events = [];
+ return {
+ events,
+ headersSent: true,
+ writableEnded: false,
+ write(payload) {
+ for (const line of String(payload).split('\n')) {
+ if (line.startsWith('data: ')) {
+ events.push(JSON.parse(line.slice(6)));
+ }
+ }
+ return true;
+ },
+ };
+}
+
+describeIfLive('live usage events through the host pipeline', () => {
+ jest.setTimeout(120000);
+
+ afterAll(async () => {
+ await GenerationJobManager.destroy();
+ });
+
+ test('streams real provider usage and persists it for resume', async () => {
+ const streamId = `usage-live-${Date.now()}`;
+ await GenerationJobManager.createJob(streamId, 'user-live', 'convo-live');
+
+ /** streamId mode routes events through the job emitter — capture them
+ * as a subscribed resumable client would, not via res.write */
+ const res = createMockRes();
+ await GenerationJobManager.subscribe(streamId, (event) => {
+ res.events.push(event);
+ });
+ const collectedUsage = [];
+ const handlers = getDefaultHandlers({
+ res,
+ aggregateContent: () => {},
+ toolEndCallback: () => {},
+ collectedUsage,
+ streamId,
+ });
+
+ const run = await Run.create({
+ runId: 'usage-live-response',
+ graphConfig: {
+ type: 'standard',
+ llmConfig: {
+ provider: Providers.ANTHROPIC,
+ model: modelName,
+ apiKey: process.env.ANTHROPIC_API_KEY,
+ temperature: 0,
+ maxTokens: 64,
+ streaming: true,
+ streamUsage: true,
+ },
+ instructions: 'You are concise. Reply with one short sentence.',
+ maxContextTokens: 8000,
+ },
+ returnContent: true,
+ customHandlers: handlers,
+ tokenCounter: charCounter,
+ indexTokenCountMap: {},
+ });
+
+ await run.processStream(
+ { messages: [new HumanMessage('Say hello in five words or fewer.')] },
+ {
+ configurable: { thread_id: 'usage-live-thread', user_id: 'user-live' },
+ streamMode: 'values',
+ version: 'v2',
+ },
+ );
+
+ const usageEvents = res.events.filter((e) => e.event === 'on_token_usage');
+ expect(usageEvents).toHaveLength(1);
+ const usage = usageEvents[0].data;
+ expect(usage.input_tokens).toBeGreaterThan(0);
+ expect(usage.output_tokens).toBeGreaterThan(0);
+ expect(usage.provider).toBe(Providers.ANTHROPIC);
+ expect(usage.model).toBe(modelName);
+ expect(collectedUsage).toHaveLength(1);
+ expect(usage.input_tokens).toBe(collectedUsage[0].input_tokens);
+
+ if (hasContextUsageEvent) {
+ const contextEvents = res.events.filter((e) => e.event === 'on_context_usage');
+ expect(contextEvents).toHaveLength(1);
+ const snapshot = contextEvents[0].data;
+ expect(snapshot.breakdown.maxContextTokens).toBe(8000);
+ const estimatedUsed = snapshot.contextBudget - snapshot.remainingContextTokens;
+ expect(estimatedUsed).toBeGreaterThan(0);
+ expect(estimatedUsed / usage.input_tokens).toBeGreaterThan(0.2);
+ expect(estimatedUsed / usage.input_tokens).toBeLessThan(5);
+ }
+
+ const resumeState = await GenerationJobManager.getResumeState(streamId);
+ expect(resumeState.collectedUsage).toHaveLength(1);
+ expect(resumeState.collectedUsage[0].input_tokens).toBe(usage.input_tokens);
+ if (hasContextUsageEvent) {
+ expect(resumeState.contextUsage.breakdown.maxContextTokens).toBe(8000);
+ }
+ });
+});
diff --git a/api/server/controllers/agents/__tests__/v1.duplicate-actions.spec.js b/api/server/controllers/agents/__tests__/v1.duplicate-actions.spec.js
new file mode 100644
index 0000000..cc298bd
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/v1.duplicate-actions.spec.js
@@ -0,0 +1,159 @@
+jest.mock('~/server/services/PermissionService', () => ({
+ findPubliclyAccessibleResources: jest.fn(),
+ findAccessibleResources: jest.fn(),
+ hasPublicPermission: jest.fn(),
+ grantPermission: jest.fn().mockResolvedValue({}),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ getCachedTools: jest.fn(),
+ getMCPServerTools: jest.fn(),
+}));
+
+const mongoose = require('mongoose');
+const { actionDelimiter } = require('librechat-data-provider');
+const { agentSchema, actionSchema } = require('@librechat/data-schemas');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { duplicateAgent } = require('../v1');
+
+let mongoServer;
+
+beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ if (!mongoose.models.Agent) {
+ mongoose.model('Agent', agentSchema);
+ }
+ 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.Agent.deleteMany({});
+ await mongoose.models.Action.deleteMany({});
+});
+
+describe('duplicateAgentHandler — action domain extraction', () => {
+ it('builds duplicated action entries using metadata.domain, not action_id', async () => {
+ const userId = new mongoose.Types.ObjectId();
+ const originalAgentId = `agent_original`;
+
+ const agent = await mongoose.models.Agent.create({
+ id: originalAgentId,
+ name: 'Test Agent',
+ author: userId.toString(),
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: [],
+ actions: [`api.example.com${actionDelimiter}act_original`],
+ versions: [{ name: 'Test Agent', createdAt: new Date(), updatedAt: new Date() }],
+ });
+
+ await mongoose.models.Action.create({
+ user: userId,
+ action_id: 'act_original',
+ agent_id: originalAgentId,
+ metadata: { domain: 'api.example.com' },
+ });
+
+ const req = {
+ params: { id: agent.id },
+ user: { id: userId.toString() },
+ };
+ const res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+
+ await duplicateAgent(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(201);
+
+ const { agent: newAgent, actions: newActions } = res.json.mock.calls[0][0];
+
+ expect(newAgent.id).not.toBe(originalAgentId);
+ expect(String(newAgent.author)).toBe(userId.toString());
+ expect(newActions).toHaveLength(1);
+ expect(newActions[0].metadata.domain).toBe('api.example.com');
+ expect(newActions[0].agent_id).toBe(newAgent.id);
+
+ for (const actionEntry of newAgent.actions) {
+ const [domain, actionId] = actionEntry.split(actionDelimiter);
+ expect(domain).toBe('api.example.com');
+ expect(actionId).toBeTruthy();
+ expect(actionId).not.toBe('act_original');
+ }
+
+ const allActions = await mongoose.models.Action.find({}).lean();
+ expect(allActions).toHaveLength(2);
+
+ const originalAction = allActions.find((a) => a.action_id === 'act_original');
+ expect(originalAction.agent_id).toBe(originalAgentId);
+
+ const duplicatedAction = allActions.find((a) => a.action_id !== 'act_original');
+ expect(duplicatedAction.agent_id).toBe(newAgent.id);
+ expect(duplicatedAction.metadata.domain).toBe('api.example.com');
+ });
+
+ it('strips sensitive metadata fields from duplicated actions', async () => {
+ const userId = new mongoose.Types.ObjectId();
+ const originalAgentId = 'agent_sensitive';
+
+ await mongoose.models.Agent.create({
+ id: originalAgentId,
+ name: 'Sensitive Agent',
+ author: userId.toString(),
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: [],
+ actions: [`secure.api.com${actionDelimiter}act_secret`],
+ versions: [{ name: 'Sensitive Agent', createdAt: new Date(), updatedAt: new Date() }],
+ });
+
+ await mongoose.models.Action.create({
+ user: userId,
+ action_id: 'act_secret',
+ agent_id: originalAgentId,
+ metadata: {
+ domain: 'secure.api.com',
+ api_key: 'sk-secret-key-12345',
+ oauth_client_id: 'client_id_xyz',
+ oauth_client_secret: 'client_secret_xyz',
+ },
+ });
+
+ const req = {
+ params: { id: originalAgentId },
+ user: { id: userId.toString() },
+ };
+ const res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+
+ await duplicateAgent(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(201);
+
+ const duplicatedAction = await mongoose.models.Action.findOne({
+ agent_id: { $ne: originalAgentId },
+ }).lean();
+
+ expect(duplicatedAction.metadata.domain).toBe('secure.api.com');
+ expect(duplicatedAction.metadata.api_key).toBeUndefined();
+ expect(duplicatedAction.metadata.oauth_client_id).toBeUndefined();
+ expect(duplicatedAction.metadata.oauth_client_secret).toBeUndefined();
+
+ const originalAction = await mongoose.models.Action.findOne({
+ action_id: 'act_secret',
+ }).lean();
+ expect(originalAction.metadata.api_key).toBe('sk-secret-key-12345');
+ });
+});
diff --git a/api/server/controllers/agents/__tests__/v1.spec.js b/api/server/controllers/agents/__tests__/v1.spec.js
new file mode 100644
index 0000000..39cf994
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/v1.spec.js
@@ -0,0 +1,193 @@
+const { duplicateAgent } = require('../v1');
+const { getAgent, createAgent, getActions } = require('~/models');
+const { nanoid } = require('nanoid');
+
+jest.mock('~/models');
+jest.mock('nanoid');
+
+describe('duplicateAgent', () => {
+ let req, res;
+
+ beforeEach(() => {
+ req = {
+ params: { id: 'agent_123' },
+ user: { id: 'user_456' },
+ };
+ res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+ jest.clearAllMocks();
+ });
+
+ it('should duplicate an agent successfully', async () => {
+ const mockAgent = {
+ id: 'agent_123',
+ name: 'Test Agent',
+ description: 'Test Description',
+ instructions: 'Test Instructions',
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: ['file_search'],
+ actions: [],
+ author: 'user_789',
+ versions: [{ name: 'Test Agent', version: 1 }],
+ __v: 0,
+ };
+
+ const mockNewAgent = {
+ id: 'agent_new_123',
+ name: 'Test Agent (1/2/23, 12:34)',
+ description: 'Test Description',
+ instructions: 'Test Instructions',
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: ['file_search'],
+ actions: [],
+ author: 'user_456',
+ versions: [
+ {
+ name: 'Test Agent (1/2/23, 12:34)',
+ description: 'Test Description',
+ instructions: 'Test Instructions',
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: ['file_search'],
+ actions: [],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ };
+
+ getAgent.mockResolvedValue(mockAgent);
+ getActions.mockResolvedValue([]);
+ nanoid.mockReturnValue('new_123');
+ createAgent.mockResolvedValue(mockNewAgent);
+
+ await duplicateAgent(req, res);
+
+ expect(getAgent).toHaveBeenCalledWith({ id: 'agent_123' });
+ expect(getActions).toHaveBeenCalledWith({ agent_id: 'agent_123' }, true);
+ expect(createAgent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'agent_new_123',
+ author: 'user_456',
+ name: expect.stringContaining('Test Agent ('),
+ description: 'Test Description',
+ instructions: 'Test Instructions',
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: ['file_search'],
+ actions: [],
+ }),
+ );
+
+ expect(createAgent).toHaveBeenCalledWith(
+ expect.not.objectContaining({
+ versions: expect.anything(),
+ __v: expect.anything(),
+ }),
+ );
+
+ expect(res.status).toHaveBeenCalledWith(201);
+ expect(res.json).toHaveBeenCalledWith({
+ agent: mockNewAgent,
+ actions: [],
+ });
+ });
+
+ it('should ensure duplicated agent has clean versions array without nested fields', async () => {
+ const mockAgent = {
+ id: 'agent_123',
+ name: 'Test Agent',
+ description: 'Test Description',
+ versions: [
+ {
+ name: 'Test Agent',
+ versions: [{ name: 'Nested' }],
+ __v: 1,
+ },
+ ],
+ __v: 2,
+ };
+
+ const mockNewAgent = {
+ id: 'agent_new_123',
+ name: 'Test Agent (1/2/23, 12:34)',
+ description: 'Test Description',
+ versions: [
+ {
+ name: 'Test Agent (1/2/23, 12:34)',
+ description: 'Test Description',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ };
+
+ getAgent.mockResolvedValue(mockAgent);
+ getActions.mockResolvedValue([]);
+ nanoid.mockReturnValue('new_123');
+ createAgent.mockResolvedValue(mockNewAgent);
+
+ await duplicateAgent(req, res);
+
+ expect(mockNewAgent.versions).toHaveLength(1);
+
+ const firstVersion = mockNewAgent.versions[0];
+ expect(firstVersion).not.toHaveProperty('versions');
+ expect(firstVersion).not.toHaveProperty('__v');
+
+ expect(mockNewAgent).not.toHaveProperty('__v');
+
+ expect(res.status).toHaveBeenCalledWith(201);
+ });
+
+ it('should return 404 if agent not found', async () => {
+ getAgent.mockResolvedValue(null);
+
+ await duplicateAgent(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Agent not found',
+ status: 'error',
+ });
+ });
+
+ it('should convert `tool_resources.ocr` to `tool_resources.context`', async () => {
+ const mockAgent = {
+ id: 'agent_123',
+ name: 'Test Agent',
+ tool_resources: {
+ ocr: { enabled: true, config: 'test' },
+ other: { should: 'not be copied' },
+ },
+ };
+
+ getAgent.mockResolvedValue(mockAgent);
+ getActions.mockResolvedValue([]);
+ nanoid.mockReturnValue('new_123');
+ createAgent.mockResolvedValue({ id: 'agent_new_123' });
+
+ await duplicateAgent(req, res);
+
+ expect(createAgent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tool_resources: {
+ context: { enabled: true, config: 'test' },
+ },
+ }),
+ );
+ });
+
+ it('should handle errors gracefully', async () => {
+ getAgent.mockRejectedValue(new Error('Database error'));
+
+ await duplicateAgent(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(500);
+ expect(res.json).toHaveBeenCalledWith({ error: 'Database error' });
+ });
+});
diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js
new file mode 100644
index 0000000..8b00ac3
--- /dev/null
+++ b/api/server/controllers/agents/callbacks.js
@@ -0,0 +1,1246 @@
+const { nanoid } = require('nanoid');
+const { logger } = require('@librechat/data-schemas');
+const {
+ Tools,
+ StepTypes,
+ FileContext,
+ ErrorTypes,
+ UsageEvents,
+} = require('librechat-data-provider');
+const {
+ GraphEvents,
+ GraphNodeKeys,
+ ToolEndHandler,
+ createContentAggregator,
+} = require('@librechat/agents');
+const {
+ sendEvent,
+ computeUsageCostUSD,
+ GenerationJobManager,
+ writeAttachmentEvent,
+ createToolExecuteHandler,
+ HOST_FILE_AUTHORING_ARTIFACT_KEY,
+ isCodeSessionToolName,
+} = require('@librechat/api');
+const { processFileCitations } = require('~/server/services/Files/Citations');
+const { processCodeOutput, runPreviewFinalize } = require('~/server/services/Files/Code/process');
+const { saveBase64Image } = require('~/server/services/Files/process');
+
+function isHostFileAuthoringArtifact(artifact) {
+ return artifact?.[HOST_FILE_AUTHORING_ARTIFACT_KEY] === true;
+}
+
+function isCodeArtifactToolOutput(output) {
+ return isCodeSessionToolName(output.name) || isHostFileAuthoringArtifact(output.artifact);
+}
+
+class ModelEndHandler {
+ /**
+ * @param {Array} collectedUsage
+ * @param {Record | null} [collectedThoughtSignatures] Map of
+ * `tool_call_id → thoughtSignature` accumulated across `chat_model_end`
+ * events. Used to persist Vertex Gemini 3 thought signatures across DB
+ * round-trips so resumed conversations don't 400 on the next API call.
+ * Each `model_end` may emit multiple tool calls (one per LLM cycle in a
+ * tool-using turn); per-id storage preserves the mapping so each tool
+ * call's signature can be restored onto the right reconstructed
+ * AIMessage rather than being concentrated on the last one.
+ * Optional; when `null`, the handler is a no-op for signatures. Non-Vertex
+ * providers don't emit `additional_kwargs.signatures`, so capture is also
+ * a no-op for them even when the map is provided.
+ * @param {(data: Record) => Promise | void} [emitUsage] Optional
+ * callback to stream per-call token usage to the client.
+ */
+ constructor(collectedUsage, collectedThoughtSignatures = null, emitUsage = null) {
+ if (!Array.isArray(collectedUsage)) {
+ throw new Error('collectedUsage must be an array');
+ }
+ this.collectedUsage = collectedUsage;
+ this.collectedThoughtSignatures = collectedThoughtSignatures;
+ this.emitUsage = emitUsage;
+ }
+
+ finalize(errorMessage) {
+ if (!errorMessage) {
+ return;
+ }
+ throw new Error(errorMessage);
+ }
+
+ /**
+ * @param {string} event
+ * @param {ModelEndData | undefined} data
+ * @param {Record | undefined} metadata
+ * @param {StandardGraph} graph
+ * @returns {Promise}
+ */
+ async handle(event, data, metadata, graph) {
+ if (!graph || !metadata) {
+ console.warn(`Graph or metadata not found in ${event} event`);
+ return;
+ }
+
+ /** @type {string | undefined} */
+ let errorMessage;
+ try {
+ const agentContext = graph.getAgentContext(metadata);
+ if (data?.output?.additional_kwargs?.stop_reason === 'refusal') {
+ const info = { ...data.output.additional_kwargs };
+ errorMessage = JSON.stringify({
+ type: ErrorTypes.REFUSAL,
+ info,
+ });
+ logger.debug(`[ModelEndHandler] Model refused to respond`, {
+ ...info,
+ userId: metadata.user_id,
+ messageId: metadata.run_id,
+ conversationId: metadata.thread_id,
+ });
+ }
+
+ const usage = data?.output?.usage_metadata;
+ if (!usage) {
+ return this.finalize(errorMessage);
+ }
+ const modelName = metadata?.ls_model_name || agentContext.clientOptions?.model;
+ if (modelName) {
+ usage.model = modelName;
+ }
+ if (agentContext.provider) {
+ usage.provider = agentContext.provider;
+ }
+ /** Tag the producing agent so multi-endpoint graphs can price each call
+ * with its own endpoint token config (recordCollectedUsage resolver). */
+ if (agentContext.agentId) {
+ usage.agentId = agentContext.agentId;
+ }
+
+ let taggedUsage = markSummarizationUsage(usage, metadata);
+ /** Hidden intermediate sequential-agent calls are billed but never shown.
+ * Tag them non-primary on the COLLECTED usage too (not just the emit) so
+ * recordCollectedUsage excludes their output from the parent's tokenCount
+ * and the client folds them into cost/totals only — not the live gauge. */
+ if (
+ taggedUsage.usage_type == null &&
+ !checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node) &&
+ metadata?.hide_sequential_outputs === true
+ ) {
+ taggedUsage = { ...taggedUsage, usage_type: 'sequential' };
+ }
+
+ this.collectedUsage.push(taggedUsage);
+
+ if (this.emitUsage) {
+ /** Normalize Anthropic/Bedrock-style top-level cache fields into details */
+ const cache_creation =
+ taggedUsage.input_token_details?.cache_creation ??
+ taggedUsage.cache_creation_input_tokens;
+ const cache_read =
+ taggedUsage.input_token_details?.cache_read ?? taggedUsage.cache_read_input_tokens;
+ try {
+ await this.emitUsage({
+ input_tokens: taggedUsage.input_tokens,
+ output_tokens: taggedUsage.output_tokens,
+ total_tokens: taggedUsage.total_tokens,
+ input_token_details:
+ cache_creation != null || cache_read != null
+ ? { cache_creation, cache_read }
+ : undefined,
+ model: taggedUsage.model,
+ provider: taggedUsage.provider,
+ usage_type: taggedUsage.usage_type,
+ /** Producing agent for per-endpoint pricing; consumed by the emit
+ * cost resolver and not included in the emitted/persisted payload. */
+ agentId: taggedUsage.agentId,
+ runId: metadata?.run_id,
+ /** Per-run sequence so identical payloads from distinct calls
+ * stay distinguishable during resume dedupe */
+ seq: this.collectedUsage.length,
+ });
+ } catch (err) {
+ /** Best-effort telemetry: a failed emit (closed SSE, Redis publish
+ * error) must not abort the handler before the thought-signature
+ * capture below, or resumed tool-call requests lose that metadata */
+ logger.warn('[ModelEndHandler] Failed to emit token usage', err);
+ }
+ }
+
+ /**
+ * `additional_kwargs.signatures` is a flat array indexed by response
+ * part position (text + functionCall interleaved). `tool_calls` is
+ * just the function calls in their original order. Non-empty
+ * signatures correspond 1:1 with `tool_calls` in order — see
+ * `partsToSignatures` in `@langchain/google-common`. Walk both in a
+ * single pass to map each signature onto the right `tool_call.id`.
+ */
+ const signatures = data?.output?.additional_kwargs?.signatures;
+ const toolCalls = data?.output?.tool_calls;
+ if (
+ this.collectedThoughtSignatures &&
+ Array.isArray(signatures) &&
+ Array.isArray(toolCalls)
+ ) {
+ let toolIdx = 0;
+ for (const sig of signatures) {
+ if (typeof sig !== 'string' || sig.length === 0) continue;
+ if (toolIdx >= toolCalls.length) break;
+ const id = toolCalls[toolIdx++]?.id;
+ if (id) this.collectedThoughtSignatures[id] = sig;
+ }
+ }
+ } catch (error) {
+ logger.error('Error handling model end event:', error);
+ return this.finalize(errorMessage);
+ }
+ }
+}
+
+/**
+ * @deprecated Agent Chain helper
+ * @param {string | undefined} [last_agent_id]
+ * @param {string | undefined} [langgraph_node]
+ * @returns {boolean}
+ */
+function checkIfLastAgent(last_agent_id, langgraph_node) {
+ if (!last_agent_id || !langgraph_node) {
+ return false;
+ }
+ return langgraph_node?.endsWith(last_agent_id);
+}
+
+/**
+ * Helper to emit events either to res (standard mode) or to job emitter (resumable mode).
+ * In Redis mode, awaits the emit to guarantee event ordering (critical for streaming deltas).
+ * @param {ServerResponse} res - The server response object
+ * @param {string | null} streamId - The stream ID for resumable mode, or null for standard mode
+ * @param {Object} eventData - The event data to send
+ * @returns {Promise}
+ */
+async function emitEvent(res, streamId, eventData) {
+ if (streamId) {
+ await GenerationJobManager.emitChunk(streamId, eventData);
+ } else {
+ sendEvent(res, eventData);
+ }
+}
+
+/**
+ * Maps a {@link SubagentUpdateEvent} phase to the corresponding
+ * {@link GraphEvents} name that the SDK's `createContentAggregator`
+ * knows how to consume. Phases that don't carry content (`start`, `stop`,
+ * `error`) or whose payload doesn't match a handled event (`run_step`
+ * with an `ON_TOOL_EXECUTE`-shaped batch request rather than a RunStep)
+ * return `null` so the caller skips them.
+ * @param {SubagentUpdateEvent} event
+ * @returns {string | null}
+ */
+function subagentPhaseToGraphEvent(event) {
+ switch (event?.phase) {
+ case 'run_step':
+ /** `ON_RUN_STEP` and `ON_TOOL_EXECUTE` both forward with phase
+ * `run_step`; only the former matches the aggregator's RunStep
+ * schema. Detect by presence of `stepDetails`. */
+ return event.data?.stepDetails ? GraphEvents.ON_RUN_STEP : null;
+ case 'run_step_delta':
+ return GraphEvents.ON_RUN_STEP_DELTA;
+ case 'run_step_completed':
+ return GraphEvents.ON_RUN_STEP_COMPLETED;
+ case 'message_delta':
+ return GraphEvents.ON_MESSAGE_DELTA;
+ case 'reasoning_delta':
+ return GraphEvents.ON_REASONING_DELTA;
+ default:
+ return null;
+ }
+}
+
+/**
+ * Folds a single {@link SubagentUpdateEvent} into the given content
+ * aggregator. Silent no-op for phases outside the aggregator's domain.
+ * @param {{ aggregateContent: Function }} aggregator
+ * @param {SubagentUpdateEvent} event
+ */
+function feedSubagentAggregator(aggregator, event) {
+ const graphEvent = subagentPhaseToGraphEvent(event);
+ if (!graphEvent) return;
+ aggregator.aggregateContent({ event: graphEvent, data: event.data });
+}
+
+/**
+ * @typedef {Object} ToolExecuteOptions
+ * @property {(toolNames: string[]) => Promise<{loadedTools: StructuredTool[]}>} loadTools - Function to load tools by name
+ * @property {Object} configurable - Configurable context for tool invocation
+ */
+
+/**
+ * Get default handlers for stream events.
+ * @param {Object} options - The options object.
+ * @param {ServerResponse} options.res - The server response object.
+ * @param {ContentAggregator} options.aggregateContent - Content aggregator function.
+ * @param {ToolEndCallback} options.toolEndCallback - Callback to use when tool ends.
+ * @param {Array} options.collectedUsage - The list of collected usage metadata.
+ * @param {string | null} [options.streamId] - The stream ID for resumable mode, or null for standard mode.
+ * @param {ToolExecuteOptions} [options.toolExecuteOptions] - Options for event-driven tool execution.
+ * @param {UsageCostDeps} [options.usageCost] - Pricing context for authoritative per-event cost.
+ * @param {{ latest: TContextUsageEvent | null, count: number }} [options.contextUsageSink] - Mutable
+ * holder for the latest visible context snapshot + a count of visible snapshots (model calls),
+ * used to persist the breakdown only when the final call emitted usage.
+ * @param {Array} [options.usageEmitSink] - Array collecting each emitted
+ * `on_token_usage` payload (incl. cost) so the response's usage rollup can be persisted.
+ * @returns {Record} The default handlers.
+ * @throws {Error} If the request is not found.
+ */
+function getDefaultHandlers({
+ res,
+ aggregateContent,
+ toolEndCallback,
+ collectedUsage,
+ collectedThoughtSignatures = null,
+ streamId = null,
+ toolExecuteOptions = null,
+ summarizationOptions = null,
+ subagentAggregatorsByToolCallId = null,
+ usageCost = null,
+ contextUsageSink = null,
+ usageEmitSink = null,
+}) {
+ if (!res || !aggregateContent) {
+ throw new Error(
+ `[getDefaultHandlers] Missing required options: res: ${!res}, aggregateContent: ${!aggregateContent}`,
+ );
+ }
+ /**
+ * Emit a token-usage event, attaching the authoritative per-event USD cost
+ * when cost display is enabled. The backend is the single source of truth
+ * for pricing (premium tiers, cache rates) — the client sums these instead
+ * of re-deriving from base rates.
+ * @param {Record} data
+ */
+ const emitTokenUsage = ({ agentId, ...data }) => {
+ let payload = data;
+ if (usageCost?.enabled === true && usageCost.pricing) {
+ try {
+ /** Price with the producing agent's config (multi-endpoint graphs) so
+ * the streamed/persisted cost matches the per-agent balance transaction;
+ * `agentId` is resolved here, not forwarded to the client or rollup. */
+ const endpointTokenConfig = usageCost.resolveEndpointTokenConfig
+ ? usageCost.resolveEndpointTokenConfig({ agentId })
+ : usageCost.endpointTokenConfig;
+ payload = {
+ ...data,
+ cost: computeUsageCostUSD(data, usageCost.pricing, endpointTokenConfig),
+ };
+ } catch (err) {
+ logger.warn('[getDefaultHandlers] Failed to compute usage cost', err);
+ }
+ }
+ /** Collect the same payload the client folds so the response's usage rollup
+ * persisted on `metadata.usage` reproduces the live branch/total + cost. */
+ if (usageEmitSink) {
+ usageEmitSink.push(payload);
+ }
+ return emitEvent(res, streamId, { event: UsageEvents.ON_TOKEN_USAGE, data: payload });
+ };
+ const handlers = {
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(
+ collectedUsage,
+ collectedThoughtSignatures,
+ emitTokenUsage,
+ ),
+ [GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback, logger),
+ [GraphEvents.ON_RUN_STEP]: {
+ /**
+ * Handle ON_RUN_STEP event.
+ * @param {string} event - The event name.
+ * @param {StreamEventData} data - The event data.
+ * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
+ */
+ handle: async (event, data, metadata) => {
+ aggregateContent({ event, data });
+ if (data?.stepDetails.type === StepTypes.TOOL_CALLS) {
+ await emitEvent(res, streamId, { event, data });
+ } else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
+ await emitEvent(res, streamId, { event, data });
+ } else if (!metadata?.hide_sequential_outputs) {
+ await emitEvent(res, streamId, { event, data });
+ } else {
+ const agentName = metadata?.name ?? 'Agent';
+ const isToolCall = data?.stepDetails.type === StepTypes.TOOL_CALLS;
+ const action = isToolCall ? 'performing a task...' : 'thinking...';
+ await emitEvent(res, streamId, {
+ event: 'on_agent_update',
+ data: {
+ runId: metadata?.run_id,
+ message: `${agentName} is ${action}`,
+ },
+ });
+ }
+ },
+ },
+ [GraphEvents.ON_RUN_STEP_DELTA]: {
+ /**
+ * Handle ON_RUN_STEP_DELTA event.
+ * @param {string} event - The event name.
+ * @param {StreamEventData} data - The event data.
+ * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
+ */
+ handle: async (event, data, metadata) => {
+ aggregateContent({ event, data });
+ if (data?.delta.type === StepTypes.TOOL_CALLS) {
+ await emitEvent(res, streamId, { event, data });
+ } else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
+ await emitEvent(res, streamId, { event, data });
+ } else if (!metadata?.hide_sequential_outputs) {
+ await emitEvent(res, streamId, { event, data });
+ }
+ },
+ },
+ [GraphEvents.ON_RUN_STEP_COMPLETED]: {
+ /**
+ * Handle ON_RUN_STEP_COMPLETED event.
+ * @param {string} event - The event name.
+ * @param {StreamEventData & { result: ToolEndData }} data - The event data.
+ * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
+ */
+ handle: async (event, data, metadata) => {
+ aggregateContent({ event, data });
+ if (data?.result != null) {
+ await emitEvent(res, streamId, { event, data });
+ } else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
+ await emitEvent(res, streamId, { event, data });
+ } else if (!metadata?.hide_sequential_outputs) {
+ await emitEvent(res, streamId, { event, data });
+ }
+ },
+ },
+ [GraphEvents.ON_MESSAGE_DELTA]: {
+ /**
+ * Handle ON_MESSAGE_DELTA event.
+ * @param {string} event - The event name.
+ * @param {StreamEventData} data - The event data.
+ * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
+ */
+ handle: async (event, data, metadata) => {
+ aggregateContent({ event, data });
+ if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
+ await emitEvent(res, streamId, { event, data });
+ } else if (!metadata?.hide_sequential_outputs) {
+ await emitEvent(res, streamId, { event, data });
+ }
+ },
+ },
+ [GraphEvents.ON_REASONING_DELTA]: {
+ /**
+ * Handle ON_REASONING_DELTA event.
+ * @param {string} event - The event name.
+ * @param {StreamEventData} data - The event data.
+ * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
+ */
+ handle: async (event, data, metadata) => {
+ aggregateContent({ event, data });
+ if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
+ await emitEvent(res, streamId, { event, data });
+ } else if (!metadata?.hide_sequential_outputs) {
+ await emitEvent(res, streamId, { event, data });
+ }
+ },
+ },
+ };
+
+ if (toolExecuteOptions) {
+ handlers[GraphEvents.ON_TOOL_EXECUTE] = createToolExecuteHandler(toolExecuteOptions);
+ }
+
+ handlers[GraphEvents.ON_SUBAGENT_UPDATE] = {
+ /**
+ * Forwards subagent progress envelopes to the client stream, and
+ * (when a caller-owned aggregator map is provided) also folds each
+ * event into a per-tool-call `createContentAggregator`. The
+ * resulting `contentParts` are attached to the parent's `subagent`
+ * tool_call at message-save time so the child's reasoning / tool
+ * calls / final text survive a page refresh — in-memory Recoil
+ * atoms alone wouldn't persist that.
+ *
+ * Aggregation runs regardless of stream visibility (persistence +
+ * dialog depend on it), but the SSE forward respects
+ * `hide_sequential_outputs` the same way `ON_RUN_STEP`,
+ * `ON_MESSAGE_DELTA`, etc. do — so intermediate agents in a
+ * sequential chain don't leak their subagent activity when the
+ * chain is configured to suppress intermediates.
+ */
+ handle: async (event, data, metadata) => {
+ const isLastAgent = checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node);
+ const visible = isLastAgent || !metadata?.hide_sequential_outputs;
+ /**
+ * Gate BOTH aggregation (persistence) AND streaming on the same
+ * visibility rule. If we aggregated for a hidden intermediate
+ * agent, `finalizeSubagentContent` would still attach its
+ * child's reasoning / tool output to the saved message — so a
+ * page refresh would reveal activity that was intentionally
+ * suppressed live. Treat hide_sequential_outputs as a
+ * consistent "don't record" rule for subagent traces.
+ */
+ if (!visible) return;
+ if (subagentAggregatorsByToolCallId && data?.parentToolCallId) {
+ const key = data.parentToolCallId;
+ let aggregator = subagentAggregatorsByToolCallId.get(key);
+ if (!aggregator) {
+ aggregator = createContentAggregator();
+ subagentAggregatorsByToolCallId.set(key, aggregator);
+ }
+ try {
+ feedSubagentAggregator(aggregator, data);
+ } catch (err) {
+ logger.warn(
+ `[ON_SUBAGENT_UPDATE] Failed to aggregate phase "${data?.phase}" for tool_call ${key}: ${err?.message ?? err}`,
+ );
+ }
+ }
+ await emitEvent(res, streamId, { event, data });
+ },
+ };
+
+ 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 };
+
+ /** Guarded: no-op when the installed @librechat/agents predates the event */
+ if (GraphEvents.ON_CONTEXT_USAGE) {
+ handlers[GraphEvents.ON_CONTEXT_USAGE] = {
+ /**
+ * Forward per-model-call context usage snapshots to the client,
+ * honoring the same sequential-agent visibility gate as deltas.
+ * @param {string} event - The event name.
+ * @param {StreamEventData} data - The event data.
+ * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
+ */
+ handle: async (event, data, metadata) => {
+ if (
+ checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node) ||
+ !metadata?.hide_sequential_outputs
+ ) {
+ /** Capture the latest visible snapshot (last-wins) and how many usage
+ * events preceded it BEFORE awaiting the emit. `emitEvent` can yield
+ * (resumable SSE / Redis publish); with parallel runs active this
+ * call's own primary usage could land in `usageEmitSink` during that
+ * yield, pushing `latestUsageIndex` past the very event that proves the
+ * snapshot completed — the save path would then slice it away and drop
+ * a valid breakdown. The recorded index lets the save path persist only
+ * when a PRIMARY usage follows this snapshot (the snapshot's call
+ * actually invoked the model); a summarization detour emits a snapshot
+ * whose only following usage is tagged `summarization`, which a plain
+ * snapshot-count would over-count and wrongly drop. */
+ if (contextUsageSink) {
+ contextUsageSink.latest = data;
+ contextUsageSink.count = (contextUsageSink.count ?? 0) + 1;
+ contextUsageSink.latestUsageIndex = usageEmitSink?.length ?? 0;
+ }
+ await emitEvent(res, streamId, { event, data });
+ }
+ },
+ };
+ }
+
+ return handlers;
+}
+
+/**
+ * Helper to write attachment events either to res or to job emitter.
+ * Note: Attachments are not order-sensitive like deltas, so fire-and-forget is acceptable.
+ * @param {ServerResponse} res - The server response object
+ * @param {string | null} streamId - The stream ID for resumable mode, or null for standard mode
+ * @param {Object} attachment - The attachment data
+ */
+function writeAttachment(res, streamId, attachment) {
+ if (streamId) {
+ GenerationJobManager.emitChunk(streamId, { event: 'attachment', data: attachment });
+ } else {
+ res.write(`event: attachment\ndata: ${JSON.stringify(attachment)}\n\n`);
+ }
+}
+
+/**
+ * Predicate: is it safe to push an SSE write to the caller right now?
+ *
+ * In `streamId` (resumable) mode, writes go to the job emitter and the
+ * `res` state is irrelevant — always writable.
+ *
+ * In standard mode, the caller's `res` must have headers sent (the
+ * stream has been opened) and not yet be `writableEnded` (the response
+ * hasn't closed). Writing to a closed stream raises
+ * `ERR_STREAM_WRITE_AFTER_END`.
+ *
+ * Used by deferred preview emits in both `createToolEndCallback`
+ * (chat-completions) and `createResponsesToolEndCallback` (Open
+ * Responses) so the gate logic stays in one place. (Comprehensive
+ * review #3 on PR #12957.)
+ */
+function isStreamWritable(res, streamId) {
+ if (streamId) {
+ return true;
+ }
+ return !!res && res.headersSent && !res.writableEnded;
+}
+
+/**
+ * Emit an update for an attachment that was previously sent with
+ * `status: 'pending'`. Fire-and-forget: if the response stream has
+ * already closed (the agent finished generating before the deferred
+ * preview resolved) the frontend's React Query polling on
+ * `/api/files/:file_id/preview` picks up the resolved record on its
+ * next tick. Skipping the write in that case avoids
+ * `ERR_STREAM_WRITE_AFTER_END`.
+ *
+ * Reuses the `attachment` SSE event name with a discriminated payload:
+ * the frontend's `useAttachmentHandler` upserts by `file_id`, so a
+ * second event with the same id and `status: 'ready' | 'failed'`
+ * overwrites the pending placeholder in place. No new event type, no
+ * new client listener.
+ *
+ * @param {ServerResponse} res
+ * @param {string | null} streamId
+ * @param {Object} attachment - Updated attachment payload (must carry `file_id`).
+ */
+function writeAttachmentUpdate(res, streamId, attachment) {
+ if (!isStreamWritable(res, streamId)) {
+ return;
+ }
+ writeAttachment(res, streamId, attachment);
+}
+
+/**
+ *
+ * @param {Object} params
+ * @param {ServerRequest} params.req
+ * @param {ServerResponse} params.res
+ * @param {Promise[]} params.artifactPromises
+ * @param {string | null} [params.streamId] - The stream ID for resumable mode, or null for standard mode.
+ * @returns {ToolEndCallback} The tool end callback.
+ */
+function createToolEndCallback({ req, res, artifactPromises, streamId = null }) {
+ /**
+ * @type {ToolEndCallback}
+ */
+ return async (data, metadata) => {
+ const output = data?.output;
+ if (!output) {
+ return;
+ }
+
+ if (!output.artifact) {
+ return;
+ }
+
+ if (output.artifact[Tools.file_search]) {
+ artifactPromises.push(
+ (async () => {
+ const user = req.user;
+ const attachment = await processFileCitations({
+ user,
+ metadata,
+ appConfig: req.config,
+ toolArtifact: output.artifact,
+ toolCallId: output.tool_call_id,
+ });
+ if (!attachment) {
+ return null;
+ }
+ if (!streamId && !res.headersSent) {
+ return attachment;
+ }
+ writeAttachment(res, streamId, attachment);
+ return attachment;
+ })().catch((error) => {
+ logger.error('Error processing file citations:', error);
+ return null;
+ }),
+ );
+ }
+
+ if (output.artifact[Tools.ui_resources]) {
+ artifactPromises.push(
+ (async () => {
+ const attachment = {
+ type: Tools.ui_resources,
+ messageId: metadata.run_id,
+ toolCallId: output.tool_call_id,
+ conversationId: metadata.thread_id,
+ [Tools.ui_resources]: output.artifact[Tools.ui_resources].data,
+ };
+ if (!streamId && !res.headersSent) {
+ return attachment;
+ }
+ writeAttachment(res, streamId, attachment);
+ return attachment;
+ })().catch((error) => {
+ logger.error('Error processing artifact content:', error);
+ return null;
+ }),
+ );
+ }
+
+ if (output.artifact[Tools.web_search]) {
+ artifactPromises.push(
+ (async () => {
+ const attachment = {
+ type: Tools.web_search,
+ messageId: metadata.run_id,
+ toolCallId: output.tool_call_id,
+ conversationId: metadata.thread_id,
+ [Tools.web_search]: { ...output.artifact[Tools.web_search] },
+ };
+ if (!streamId && !res.headersSent) {
+ return attachment;
+ }
+ writeAttachment(res, streamId, attachment);
+ return attachment;
+ })().catch((error) => {
+ logger.error('Error processing artifact content:', error);
+ return null;
+ }),
+ );
+ }
+
+ if (output.artifact[Tools.memory]) {
+ artifactPromises.push(
+ (async () => {
+ const attachment = {
+ type: Tools.memory,
+ messageId: metadata.run_id,
+ toolCallId: output.tool_call_id,
+ conversationId: metadata.thread_id,
+ [Tools.memory]: output.artifact[Tools.memory],
+ };
+ if (!streamId && !res.headersSent) {
+ return attachment;
+ }
+ writeAttachment(res, streamId, attachment);
+ return attachment;
+ })().catch((error) => {
+ logger.error('Error processing memory artifact content:', error);
+ return null;
+ }),
+ );
+ }
+
+ if (output.artifact.content) {
+ /** @type {FormattedContent[]} */
+ const content = output.artifact.content;
+ for (let i = 0; i < content.length; i++) {
+ const part = content[i];
+ if (!part) {
+ continue;
+ }
+ if (part.type !== 'image_url') {
+ continue;
+ }
+ const { url } = part.image_url;
+ artifactPromises.push(
+ (async () => {
+ const filename = `${output.name}_img_${nanoid()}`;
+ const file_id = output.artifact.file_ids?.[i];
+ const file = await saveBase64Image(url, {
+ req,
+ file_id,
+ filename,
+ endpoint: metadata.provider,
+ context: FileContext.image_generation,
+ });
+ const fileMetadata = Object.assign(file, {
+ messageId: metadata.run_id,
+ toolCallId: output.tool_call_id,
+ conversationId: metadata.thread_id,
+ });
+ if (!streamId && !res.headersSent) {
+ return fileMetadata;
+ }
+
+ if (!fileMetadata) {
+ return null;
+ }
+
+ writeAttachment(res, streamId, fileMetadata);
+ return fileMetadata;
+ })().catch((error) => {
+ logger.error('Error processing artifact content:', error);
+ return null;
+ }),
+ );
+ }
+ return;
+ }
+
+ if (!isCodeArtifactToolOutput(output)) {
+ return;
+ }
+
+ if (!output.artifact.files) {
+ return;
+ }
+
+ for (const file of output.artifact.files) {
+ /* `inherited` files are unchanged passthroughs of inputs the caller
+ * already owns (skill files, prior session inputs, inherited
+ * .dirkeep markers). Skip post-processing: re-downloading with the
+ * user's session key 403s when the file is entity-scoped, and the
+ * input is already persisted at its origin. They remain available
+ * to subsequent calls via primeInvokedSkills / session inheritance. */
+ if (file.inherited) {
+ continue;
+ }
+ const { id, name } = file;
+ const toolCallId = output.tool_call_id;
+ artifactPromises.push(
+ (async () => {
+ const result = await processCodeOutput({
+ req,
+ id,
+ name,
+ messageId: metadata.run_id,
+ toolCallId,
+ conversationId: metadata.thread_id,
+ /**
+ * Use the FILE's `storage_session_id` (storage session),
+ * not the top-level artifact `session_id` (exec session).
+ * The codeapi worker reports two distinct ids on a tool
+ * result:
+ * - `artifact.session_id` is the EXEC session — the
+ * sandbox VM that ran the bash command. Files don't
+ * live there; it's torn down post-execution.
+ * - `file.storage_session_id` is the STORAGE session —
+ * the file-server bucket prefix where artifacts
+ * actually live and are served from.
+ * `processCodeOutput` builds `/download/{session_id}/{id}`,
+ * so passing the exec id resolves to a path the file-server
+ * doesn't know about and 404s. Fall back to artifact-level
+ * for older worker payloads that may not populate per-file
+ * ids.
+ */
+ session_id: file.storage_session_id ?? output.artifact.session_id,
+ });
+ const fileMetadata = result?.file ?? null;
+ const finalize = result?.finalize;
+ if (!fileMetadata) {
+ return null;
+ }
+ /* Initial emit: ship the attachment to the client immediately
+ * (carries `status: 'pending'` for office buckets so the UI
+ * shows "preparing preview…"). The agent's response stops
+ * blocking on extraction here.
+ *
+ * Use the shared `isStreamWritable` predicate rather than the
+ * narrower `streamId || res.headersSent` check that lived
+ * here before — a client disconnect mid-stream
+ * (`res.writableEnded`) would otherwise hit `res.write` and
+ * raise `ERR_STREAM_WRITE_AFTER_END` (caught by the outer
+ * IIFE catch but logged as noise). Same gate the Responses
+ * path uses below. */
+ if (isStreamWritable(res, streamId)) {
+ writeAttachment(res, streamId, fileMetadata);
+ }
+ /* Deferred preview rendering: extraction continues running
+ * even after the HTTP response closes. If the stream is still
+ * open when the preview resolves, push an `attachment`
+ * update event so the UI patches in place; otherwise React
+ * Query polling on `/api/files/:file_id/preview` picks it up.
+ *
+ * Spread the full updated record (mirroring the initial emit
+ * shape) and overlay `messageId`/`toolCallId` from the
+ * current run. The DB record preserves the original
+ * `messageId` across cross-turn filename reuse so
+ * `getCodeGeneratedFiles` can trace the file back to its
+ * original assistant message; routing the update SSE by the
+ * persisted id would land the patch on a stale message
+ * slot — turn-N's pending placeholder would stay stuck while
+ * turn-1's already-resolved attachment got re-merged.
+ * (Codex P1 review on PR #12957.) */
+ runPreviewFinalize({
+ finalize,
+ fileId: fileMetadata.file_id,
+ previewRevision: result?.previewRevision,
+ onResolved: (updated) => {
+ writeAttachmentUpdate(res, streamId, {
+ ...updated,
+ messageId: metadata.run_id,
+ toolCallId,
+ });
+ },
+ });
+ return fileMetadata;
+ })().catch((error) => {
+ logger.error('Error processing code output:', error);
+ return null;
+ }),
+ );
+ }
+ };
+}
+
+/**
+ * Helper to write attachment events in Open Responses format (librechat:attachment)
+ * @param {ServerResponse} res - The server response object
+ * @param {Object} tracker - The response tracker with sequence number
+ * @param {Object} attachment - The attachment data
+ * @param {Object} metadata - Additional metadata (messageId, conversationId)
+ */
+function writeResponsesAttachment(res, tracker, attachment, metadata) {
+ const sequenceNumber = tracker.nextSequence();
+ writeAttachmentEvent(res, sequenceNumber, attachment, {
+ messageId: metadata.run_id,
+ conversationId: metadata.thread_id,
+ });
+}
+
+/**
+ * Creates a tool end callback specifically for the Responses API.
+ * Emits attachments as `librechat:attachment` events per the Open Responses extension spec.
+ *
+ * @param {Object} params
+ * @param {ServerRequest} params.req
+ * @param {ServerResponse} params.res
+ * @param {Object} params.tracker - Response tracker with sequence number
+ * @param {Promise[]} params.artifactPromises
+ * @returns {ToolEndCallback} The tool end callback.
+ */
+function createResponsesToolEndCallback({ req, res, tracker, artifactPromises }) {
+ /**
+ * @type {ToolEndCallback}
+ */
+ return async (data, metadata) => {
+ const output = data?.output;
+ if (!output) {
+ return;
+ }
+
+ if (!output.artifact) {
+ return;
+ }
+
+ if (output.artifact[Tools.file_search]) {
+ artifactPromises.push(
+ (async () => {
+ const user = req.user;
+ const attachment = await processFileCitations({
+ user,
+ metadata,
+ appConfig: req.config,
+ toolArtifact: output.artifact,
+ toolCallId: output.tool_call_id,
+ });
+ if (!attachment) {
+ return null;
+ }
+ // For Responses API, emit attachment during streaming
+ if (res.headersSent && !res.writableEnded) {
+ writeResponsesAttachment(res, tracker, attachment, metadata);
+ }
+ return attachment;
+ })().catch((error) => {
+ logger.error('Error processing file citations:', error);
+ return null;
+ }),
+ );
+ }
+
+ if (output.artifact[Tools.ui_resources]) {
+ artifactPromises.push(
+ (async () => {
+ const attachment = {
+ type: Tools.ui_resources,
+ toolCallId: output.tool_call_id,
+ [Tools.ui_resources]: output.artifact[Tools.ui_resources].data,
+ };
+ // For Responses API, always emit attachment during streaming
+ if (res.headersSent && !res.writableEnded) {
+ writeResponsesAttachment(res, tracker, attachment, metadata);
+ }
+ return attachment;
+ })().catch((error) => {
+ logger.error('Error processing artifact content:', error);
+ return null;
+ }),
+ );
+ }
+
+ if (output.artifact[Tools.web_search]) {
+ artifactPromises.push(
+ (async () => {
+ const attachment = {
+ type: Tools.web_search,
+ toolCallId: output.tool_call_id,
+ [Tools.web_search]: { ...output.artifact[Tools.web_search] },
+ };
+ // For Responses API, always emit attachment during streaming
+ if (res.headersSent && !res.writableEnded) {
+ writeResponsesAttachment(res, tracker, attachment, metadata);
+ }
+ return attachment;
+ })().catch((error) => {
+ logger.error('Error processing artifact content:', error);
+ return null;
+ }),
+ );
+ }
+
+ if (output.artifact.content) {
+ /** @type {FormattedContent[]} */
+ const content = output.artifact.content;
+ for (let i = 0; i < content.length; i++) {
+ const part = content[i];
+ if (!part) {
+ continue;
+ }
+ if (part.type !== 'image_url') {
+ continue;
+ }
+ const { url } = part.image_url;
+ artifactPromises.push(
+ (async () => {
+ const filename = `${output.name}_img_${nanoid()}`;
+ const file_id = output.artifact.file_ids?.[i];
+ const file = await saveBase64Image(url, {
+ req,
+ file_id,
+ filename,
+ endpoint: metadata.provider,
+ context: FileContext.image_generation,
+ });
+ const fileMetadata = Object.assign(file, {
+ toolCallId: output.tool_call_id,
+ });
+
+ if (!fileMetadata) {
+ return null;
+ }
+
+ // For Responses API, emit attachment during streaming
+ if (res.headersSent && !res.writableEnded) {
+ const attachment = {
+ file_id: fileMetadata.file_id,
+ filename: fileMetadata.filename,
+ type: fileMetadata.type,
+ url: fileMetadata.filepath,
+ width: fileMetadata.width,
+ height: fileMetadata.height,
+ tool_call_id: output.tool_call_id,
+ };
+ writeResponsesAttachment(res, tracker, attachment, metadata);
+ }
+
+ return fileMetadata;
+ })().catch((error) => {
+ logger.error('Error processing artifact content:', error);
+ return null;
+ }),
+ );
+ }
+ return;
+ }
+
+ if (!isCodeArtifactToolOutput(output)) {
+ return;
+ }
+
+ if (!output.artifact.files) {
+ return;
+ }
+
+ for (const file of output.artifact.files) {
+ /* `inherited` files are unchanged passthroughs of inputs the caller
+ * already owns (skill files, prior session inputs, inherited
+ * .dirkeep markers). Skip post-processing: re-downloading with the
+ * user's session key 403s when the file is entity-scoped, and the
+ * input is already persisted at its origin. They remain available
+ * to subsequent calls via primeInvokedSkills / session inheritance. */
+ if (file.inherited) {
+ continue;
+ }
+ const { id, name } = file;
+ const toolCallId = output.tool_call_id;
+ artifactPromises.push(
+ (async () => {
+ const result = await processCodeOutput({
+ req,
+ id,
+ name,
+ messageId: metadata.run_id,
+ toolCallId,
+ conversationId: metadata.thread_id,
+ /**
+ * Use the FILE's `storage_session_id` (storage session),
+ * not the top-level artifact `session_id` (exec session).
+ * The codeapi worker reports two distinct ids on a tool
+ * result:
+ * - `artifact.session_id` is the EXEC session — the
+ * sandbox VM that ran the bash command. Files don't
+ * live there; it's torn down post-execution.
+ * - `file.storage_session_id` is the STORAGE session —
+ * the file-server bucket prefix where artifacts
+ * actually live and are served from.
+ * `processCodeOutput` builds `/download/{session_id}/{id}`,
+ * so passing the exec id resolves to a path the file-server
+ * doesn't know about and 404s. Fall back to artifact-level
+ * for older worker payloads that may not populate per-file
+ * ids.
+ */
+ session_id: file.storage_session_id ?? output.artifact.session_id,
+ });
+ const fileMetadata = result?.file ?? null;
+ const finalize = result?.finalize;
+ if (!fileMetadata) {
+ return null;
+ }
+
+ /* Initial emit (Open Responses extension format). The agent's
+ * response no longer blocks on extraction. */
+ if (isStreamWritable(res, null)) {
+ writeResponsesAttachment(
+ res,
+ tracker,
+ buildResponsesAttachment(fileMetadata, toolCallId),
+ metadata,
+ );
+ }
+
+ /* Deferred preview rendering: extract HTML in the background
+ * and emit a follow-up `librechat:attachment` with the same
+ * `file_id` so the client merges the resolved record over the
+ * pending placeholder. Fire-and-forget — survives response
+ * close; polling covers the post-close gap. */
+ runPreviewFinalize({
+ finalize,
+ fileId: fileMetadata.file_id,
+ previewRevision: result?.previewRevision,
+ onResolved: (updated) => {
+ if (!isStreamWritable(res, null)) {
+ return;
+ }
+ writeResponsesAttachment(
+ res,
+ tracker,
+ buildResponsesAttachment(updated, toolCallId),
+ metadata,
+ );
+ },
+ });
+
+ return fileMetadata;
+ })().catch((error) => {
+ logger.error('Error processing code output:', error);
+ return null;
+ }),
+ );
+ }
+ };
+}
+
+/**
+ * Project a file metadata record into the Open Responses attachment
+ * shape. Mirrors the legacy inline projection but adds `status` and
+ * `previewError` so deferred preview updates carry the lifecycle
+ * signal the client uses to upsert by `file_id`.
+ */
+function buildResponsesAttachment(fileMetadata, toolCallId) {
+ return {
+ file_id: fileMetadata.file_id,
+ filename: fileMetadata.filename,
+ type: fileMetadata.type,
+ url: fileMetadata.filepath,
+ width: fileMetadata.width,
+ height: fileMetadata.height,
+ tool_call_id: toolCallId,
+ text: fileMetadata.text ?? null,
+ textFormat: fileMetadata.textFormat ?? null,
+ status: fileMetadata.status,
+ previewError: fileMetadata.previewError,
+ };
+}
+
+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 = {
+ ModelEndHandler,
+ agentLogHandler,
+ agentLogHandlerObj,
+ getDefaultHandlers,
+ createToolEndCallback,
+ isStreamWritable,
+ markSummarizationUsage,
+ buildSummarizationHandlers,
+ createResponsesToolEndCallback,
+};
diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js
new file mode 100644
index 0000000..7cb2654
--- /dev/null
+++ b/api/server/controllers/agents/client.js
@@ -0,0 +1,2383 @@
+require('events').EventEmitter.defaultMaxListeners = 100;
+const { logger } = require('@librechat/data-schemas');
+const { getBufferString, HumanMessage } = require('@librechat/agents/langchain/messages');
+const {
+ createRun,
+ isEnabled,
+ checkAccess,
+ buildToolSet,
+ logToolError,
+ sanitizeTitle,
+ payloadParser,
+ createSafeUser,
+ initializeAgent,
+ resolveConfigHeaders,
+ countTokens,
+ getBalanceConfig,
+ omitTitleOptions,
+ getProviderConfig,
+ memoryInstructions,
+ createTokenCounter,
+ applyContextToAgent,
+ isMemoryAgentEnabled,
+ recordCollectedUsage,
+ sendEvent,
+ computeUsageCostUSD,
+ aggregateEmittedUsage,
+ resolveAgentTokenConfig,
+ buildPersistedContextUsage,
+ computeSummaryUsedTokens,
+ priorRunOutputTokens,
+ createSubagentUsageSink,
+ anyAgentReplaysReasoningContent,
+ GenerationJobManager,
+ getTransactionsConfig,
+ resolveRecursionLimit,
+ buildPendingAction,
+ toClientPendingAction,
+ computeAgentRequestFingerprint,
+ extractDiscoveredToolsFromHistory,
+ sanitizeResumeModelParameters,
+ pickResumeContext,
+ getApprovalTtlMs,
+ isHITLEnabled,
+ deleteAgentCheckpoint,
+ agentRequestsAskUserQuestion,
+ attachAskUserQuestionArgs,
+ createContentIndexOffsetHandlers,
+ getRequestMemories,
+ createMemoryProcessor,
+ agentHasInlineMemoryTools,
+ loadAgent: loadAgentFn,
+ createMultiAgentMapper,
+ filterMalformedContentParts,
+ countFormattedMessageTokens,
+ prependFileContext,
+ prependQuotes,
+ hydrateMissingIndexTokenCounts,
+ injectSkillPrimes,
+ collectFreshSkillPrimeNames,
+ isSkillPrimeMessage,
+ collectFileIds,
+ processTextWithTokenLimit,
+ buildAgentScopedContext,
+ buildSkillPrimeContentParts,
+ buildInitialToolSessions,
+ hasUrlContextTool,
+ appendYouTubeVideoParts,
+ resolveYouTubeInjectionConfig,
+ decrementPendingRequest,
+} = require('@librechat/api');
+const {
+ Callback,
+ Providers,
+ TitleMethod,
+ formatMessage,
+ formatAgentMessages,
+ createMetadataAggregator,
+} = require('@librechat/agents');
+const {
+ Constants,
+ UsageEvents,
+ Permissions,
+ VisionModes,
+ ContentTypes,
+ ApprovalEvents,
+ EModelEndpoint,
+ PermissionTypes,
+ AgentCapabilities,
+ isAgentsEndpoint,
+ isEphemeralAgentId,
+ removeNullishValues,
+ DEFAULT_MEMORY_MAX_INPUT_TOKENS,
+} = require('librechat-data-provider');
+const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
+const { encodeAndFormat } = require('~/server/services/Files/images/encode');
+const { createContextHandlers } = require('~/app/clients/prompts');
+const { resolveConfigServers } = require('~/server/services/MCP');
+const { getMCPServerTools } = require('~/server/services/Config');
+const BaseClient = require('~/app/clients/BaseClient');
+const { getMCPManager } = require('~/config');
+const db = require('~/models');
+
+const loadAgent = (params) => loadAgentFn(params, { getAgent: db.getAgent, getMCPServerTools });
+
+const MEMORY_INPUT_CHARS_PER_TOKEN = 8;
+
+class AgentClient extends BaseClient {
+ constructor(options = {}) {
+ super(null, options);
+ /** The current client class
+ * @type {string} */
+ this.clientName = EModelEndpoint.agents;
+
+ /** @deprecated @type {true} - Is a Chat Completion Request */
+ this.isChatCompletion = true;
+
+ /** @type {AgentRun} */
+ this.run;
+
+ /** Resolves with the agent run once `chatCompletion` initializes it (or
+ * `null` if initialization fails), letting immediate-mode title generation
+ * await the run instead of throwing when fired before the run exists.
+ * @type {Promise | null} */
+ this._runReady = null;
+ /** @type {((run: AgentRun | null) => void) | null} */
+ this._resolveRun = null;
+
+ const {
+ agentConfigs,
+ contentParts,
+ collectedUsage,
+ collectedThoughtSignatures,
+ artifactPromises,
+ maxContextTokens,
+ subagentAggregatorsByToolCallId,
+ contextUsageSink,
+ usageEmitSink,
+ ...clientOptions
+ } = options;
+
+ this.agentConfigs = agentConfigs;
+ this.maxContextTokens = maxContextTokens;
+ /** Latest visible context snapshot for this response, captured live by the
+ * ON_CONTEXT_USAGE handler; persisted on `metadata.contextUsage`.
+ * @type {{ latest: import('librechat-data-provider').TContextUsageEvent | null } | undefined} */
+ this.contextUsageSink = contextUsageSink;
+ /** Every emitted `on_token_usage` payload for this response (primary,
+ * summarization, sequential, and subagent); aggregated into the rollup
+ * persisted on `metadata.usage`.
+ * @type {Array | undefined} */
+ this.usageEmitSink = usageEmitSink;
+ /** @type {MessageContentComplex[]} */
+ this.contentParts = contentParts;
+ /** @type {Array} */
+ this.collectedUsage = collectedUsage;
+ /** Vertex Gemini 3 thought signatures captured during the run, keyed by
+ * `tool_call_id`. Persisted on `responseMessage.metadata.thoughtSignatures`
+ * and restored as `additional_kwargs.signatures` on subsequent turns to
+ * keep tool round-trips valid across DB reconstruction.
+ * @type {Record | undefined} */
+ this.collectedThoughtSignatures = collectedThoughtSignatures;
+ /** @type {ArtifactPromises} */
+ this.artifactPromises = artifactPromises;
+ /** Per-request map of `createContentAggregator` instances keyed by
+ * the parent's `tool_call_id`. `ON_SUBAGENT_UPDATE` events stream
+ * into each aggregator as they arrive; `finalizeSubagentContent`
+ * harvests `contentParts` onto the matching `subagent` tool_call
+ * so the child's full activity survives a page refresh. */
+ this.subagentAggregatorsByToolCallId = subagentAggregatorsByToolCallId ?? new Map();
+ /** In-flight `on_token_usage` emits from subagent child runs. The sink
+ * fires the emitter without awaiting, so chatCompletion's finally flushes
+ * these before returning — otherwise job cleanup can race the persist.
+ * @type {Promise[]} */
+ this.pendingSubagentEmits = [];
+ /** @type {AgentClientOptions} */
+ this.options = Object.assign({ endpoint: options.endpoint }, clientOptions);
+ /** @type {string} */
+ this.model = this.options.agent.model_parameters.model;
+ /** The key for the usage object's input tokens
+ * @type {string} */
+ this.inputTokensKey = 'input_tokens';
+ /** The key for the usage object's output tokens
+ * @type {string} */
+ this.outputTokensKey = 'output_tokens';
+ /** @type {UsageMetadata} */
+ this.usage;
+ /** @type {Record} */
+ this.indexTokenCountMap = {};
+ /** @type {Array> | null} */
+ this.memoryPayload = null;
+ /** @type {(messages: BaseMessage[]) => Promise} */
+ this.processMemory;
+ }
+
+ /**
+ * Returns the aggregated content parts for the current run.
+ * @returns {MessageContentComplex[]} */
+ getContentParts() {
+ return this.contentParts;
+ }
+
+ /**
+ * Harvest the `contentParts` from each per-subagent `createContentAggregator`
+ * instance and attach them onto the matching parent `subagent` tool_call
+ * as `subagent_content`. Runs once per message save (from
+ * `sendCompletion`'s `finally`) so the child's full reasoning / tool
+ * calls / final text survive a page refresh — the client-side Recoil
+ * atom is session-only. Aggregators keyed by a tool_call_id that never
+ * appeared in `contentParts` are discarded (no home to attach to).
+ */
+ finalizeSubagentContent() {
+ const buffer = this.subagentAggregatorsByToolCallId;
+ if (!buffer || buffer.size === 0 || !Array.isArray(this.contentParts)) {
+ return;
+ }
+ for (const part of this.contentParts) {
+ if (part?.type !== ContentTypes.TOOL_CALL) continue;
+ const toolCall = part[ContentTypes.TOOL_CALL];
+ if (!toolCall || toolCall.name !== Constants.SUBAGENT || !toolCall.id) continue;
+ const aggregator = buffer.get(toolCall.id);
+ if (!aggregator) continue;
+ try {
+ /** `createContentAggregator` returns a sparse array (undefined
+ * slots for indices that never received content). Strip those
+ * so the persisted shape is a clean `TMessageContentParts[]`. */
+ const parts = Array.isArray(aggregator.contentParts)
+ ? aggregator.contentParts.filter((p) => p != null)
+ : [];
+ if (parts.length > 0) {
+ toolCall.subagent_content = parts;
+ }
+ } catch (err) {
+ logger.warn(
+ `[AgentClient] Failed to attach subagent content for tool_call ${toolCall.id}: ${err?.message ?? err}`,
+ );
+ }
+ }
+ buffer.clear();
+ }
+
+ setOptions(_options) {}
+
+ /**
+ * `AgentClient` is not opinionated about vision requests, so we don't do anything here
+ * @param {MongoFile[]} attachments
+ */
+ checkVisionRequest() {}
+
+ getSaveOptions() {
+ let runOptions = {};
+ try {
+ runOptions = payloadParser(this.options) ?? {};
+ } catch (error) {
+ logger.error(
+ '[api/server/controllers/agents/client.js #getSaveOptions] Error parsing options',
+ error,
+ );
+ }
+
+ return removeNullishValues(
+ Object.assign(
+ {
+ spec: this.options.spec,
+ iconURL: this.options.iconURL,
+ chatProjectId: this.options.chatProjectId,
+ endpoint: this.options.endpoint,
+ agent_id: this.options.agent.id,
+ modelLabel: this.options.modelLabel,
+ resendFiles: this.options.resendFiles,
+ imageDetail: this.options.imageDetail,
+ maxContextTokens: this.maxContextTokens,
+ },
+ // TODO: PARSE OPTIONS BY PROVIDER, MAY CONTAIN SENSITIVE DATA
+ runOptions,
+ ),
+ );
+ }
+
+ /**
+ * Returns build message options. For AgentClient, agent-specific instructions
+ * are retrieved directly from agent objects in buildMessages, so this returns empty.
+ * @returns {Object} Empty options object
+ */
+ getBuildMessagesOptions() {
+ return {};
+ }
+
+ /**
+ *
+ * @param {TMessage} message
+ * @param {Array} attachments
+ * @returns {Promise>>}
+ */
+ async addImageURLs(message, attachments) {
+ const { files, image_urls } = await encodeAndFormat(
+ this.options.req,
+ attachments,
+ {
+ provider: this.options.agent.provider,
+ endpoint: this.options.endpoint,
+ },
+ VisionModes.agents,
+ );
+ message.image_urls = image_urls.length ? image_urls : undefined;
+ return files;
+ }
+
+ async buildMessages(messages, parentMessageId, _buildOptions, opts) {
+ /** Always pass mapMethod; getMessagesForConversation applies it only to messages with addedConvo flag */
+ const orderedMessages = this.constructor.getMessagesForConversation({
+ messages,
+ parentMessageId,
+ summary: this.shouldSummarize,
+ mapMethod: createMultiAgentMapper(this.options.agent, this.agentConfigs),
+ mapCondition: (message) => message.addedConvo === true,
+ });
+
+ let payload;
+ /** @type {number | undefined} */
+ let promptTokens;
+
+ /** Normalize instruction fields before applying per-run context. */
+ const normalizeInstructions = (agent) => {
+ agent.instructions = agent.instructions?.trim() || undefined;
+ agent.additional_instructions = agent.additional_instructions?.trim() || undefined;
+ return agent;
+ };
+
+ /** Collect all agents for unified processing while preserving stable/dynamic instruction fields. */
+ const allAgents = [
+ { agent: normalizeInstructions(this.options.agent), agentId: this.options.agent.id },
+ ...(this.agentConfigs?.size > 0
+ ? Array.from(this.agentConfigs.entries()).map(([agentId, agent]) => ({
+ agent: normalizeInstructions(agent),
+ agentId,
+ }))
+ : []),
+ ];
+ const sharedRunAttachmentIds = new Set();
+ if (this.options.attachments) {
+ const attachments = await this.options.attachments;
+ const latestMessage = orderedMessages[orderedMessages.length - 1];
+
+ for (const fileId of collectFileIds(attachments)) {
+ sharedRunAttachmentIds.add(fileId);
+ }
+
+ if (this.message_file_map) {
+ this.message_file_map[latestMessage.messageId] = attachments;
+ } else {
+ this.message_file_map = {
+ [latestMessage.messageId]: attachments,
+ };
+ }
+
+ await this.addFileContextToMessage(latestMessage, attachments);
+ const files = await this.processAttachments(latestMessage, attachments);
+
+ this.options.attachments = files;
+ }
+
+ /** Note: Bedrock uses legacy RAG API handling */
+ if (this.message_file_map && !isAgentsEndpoint(this.options.endpoint)) {
+ this.contextHandlers = createContextHandlers(
+ this.options.req,
+ orderedMessages[orderedMessages.length - 1].text,
+ );
+ }
+
+ /** @type {Record} */
+ const indexTokenCountMap = {};
+ /** @type {Record} */
+ const tokenCountMap = {};
+ const memoryPayload = [];
+ let hasFileContext = false;
+ let promptTokenTotal = 0;
+ const encoding = this.getEncoding();
+ const formattedMessages = orderedMessages.map((message, i) => {
+ const formattedMessage = formatMessage({
+ message,
+ userName: this.options?.name,
+ assistantName: this.options?.modelLabel,
+ });
+ const memoryFormattedMessage = formatMessage({
+ message,
+ userName: this.options?.name,
+ assistantName: this.options?.modelLabel,
+ });
+
+ /**
+ * Bind file context to the message it belongs to. Historical attachments
+ * are resent inline, so the current turn's text attachment must be inline
+ * too instead of living only in the dynamic system tail.
+ */
+ if (message.fileContext) {
+ hasFileContext = true;
+ prependFileContext(formattedMessage, message.fileContext);
+ }
+
+ /**
+ * Durably re-merge quoted excerpts into every user turn that carries them
+ * (current and historical) so the model receives the referenced context on
+ * every prompt and the token count matches what was persisted. Applied to
+ * the memory copy too so the canonical per-message count includes them.
+ */
+ if (Array.isArray(message.quotes) && message.quotes.length > 0) {
+ prependQuotes(formattedMessage, message.quotes);
+ prependQuotes(memoryFormattedMessage, message.quotes);
+ }
+
+ memoryPayload.push(memoryFormattedMessage);
+
+ const dbTokenCount = Number(orderedMessages[i].tokenCount);
+ const hasDbTokenCount = Number.isFinite(dbTokenCount) && dbTokenCount > 0;
+ /**
+ * Force a recount when the message carries quotes: a plain text-only
+ * "Save" edit recomputes `tokenCount` from `text` alone while leaving
+ * `message.quotes` persisted, so the stored count would undercount the
+ * quote block this turn prepends. Recounting from the quote-merged memory
+ * copy keeps context accounting accurate (and self-heals stale counts).
+ */
+ const needsCanonicalTokenCount =
+ !hasDbTokenCount ||
+ (this.isVisionModel && (message.image_urls || message.files)) ||
+ (Array.isArray(message.quotes) && message.quotes.length > 0);
+
+ let canonicalTokenCount = hasDbTokenCount ? dbTokenCount : 0;
+ if (needsCanonicalTokenCount) {
+ canonicalTokenCount = countFormattedMessageTokens(memoryFormattedMessage, encoding);
+ }
+
+ const promptMessageTokenCount = message.fileContext
+ ? countFormattedMessageTokens(formattedMessage, encoding)
+ : canonicalTokenCount;
+
+ /* If message has files, calculate image token cost */
+ if (this.message_file_map && this.message_file_map[message.messageId]) {
+ const attachments = this.message_file_map[message.messageId];
+ for (const file of attachments) {
+ if (file.embedded) {
+ this.contextHandlers?.processFile(file);
+ continue;
+ }
+ if (file.metadata?.codeEnvRef) {
+ continue;
+ }
+ }
+ }
+
+ const normalizedCanonicalTokenCount =
+ Number.isFinite(canonicalTokenCount) && canonicalTokenCount > 0 ? canonicalTokenCount : 0;
+ const normalizedPromptTokenCount =
+ Number.isFinite(promptMessageTokenCount) && promptMessageTokenCount > 0
+ ? promptMessageTokenCount
+ : 0;
+
+ orderedMessages[i].tokenCount = normalizedCanonicalTokenCount;
+ indexTokenCountMap[i] = normalizedPromptTokenCount;
+ promptTokenTotal += normalizedPromptTokenCount;
+
+ if (message.messageId) {
+ tokenCountMap[message.messageId] = normalizedCanonicalTokenCount;
+ }
+
+ 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 = needsCanonicalTokenCount ? normalizedCanonicalTokenCount : null;
+ const promptRecalced = message.fileContext ? normalizedPromptTokenCount : null;
+ logger.debug(
+ `[AgentClient] msg[${i}] ${role}${suffix} id=…${id} db=${dbTokenCount} needsRecount=${needsCanonicalTokenCount} recalced=${recalced} promptRecalced=${promptRecalced} tokens=${normalizedPromptTokenCount}`,
+ );
+ }
+
+ return formattedMessage;
+ });
+
+ /**
+ * Native YouTube -> video understanding: when Google `url_context` is enabled
+ * (resolved to the native `urlContext` provider tool), inject any YouTube URLs
+ * from the latest user turn as Gemini `fileData` video parts. The URL Context
+ * tool cannot read YouTube, so this routes those links through the video path
+ * while other URLs still flow through `urlContext`. Done after token counting
+ * (video tokens are reported by the provider) and only on the LLM payload, so
+ * the memory copy and persisted message are untouched.
+ */
+ const latestOrdered = orderedMessages[orderedMessages.length - 1];
+ const provider = this.options.agent?.provider;
+ if (
+ latestOrdered?.isCreatedByUser === true &&
+ (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) &&
+ hasUrlContextTool(this.options.agent?.tools)
+ ) {
+ const latestFormatted = formattedMessages[formattedMessages.length - 1];
+ /** Use the resolved run model (model_parameters override) rather than the saved base model. */
+ const resolvedModel =
+ this.options.agent?.model_parameters?.model ?? this.options.agent?.model;
+ const { max, mimeType } = resolveYouTubeInjectionConfig({
+ provider,
+ model: resolvedModel,
+ });
+ latestFormatted.content = appendYouTubeVideoParts({
+ enabled: true,
+ text: latestOrdered.text,
+ content: latestFormatted.content,
+ max,
+ mimeType,
+ });
+ }
+
+ payload = formattedMessages;
+ this.memoryPayload = hasFileContext ? memoryPayload : null;
+ messages = orderedMessages;
+ promptTokens = promptTokenTotal;
+
+ /**
+ * Build shared run context - applies to ALL agents in the run.
+ * Request attachment file context is already bound inline to the latest
+ * user message above; only side-channel context belongs here.
+ * Memory context is handled separately and applied per-agent based on config.
+ */
+ const sharedRunContextParts = [];
+
+ /** Augmented prompt from RAG/context handlers */
+ if (this.contextHandlers) {
+ this.augmentedPrompt = await this.contextHandlers.createContext();
+ if (this.augmentedPrompt) {
+ sharedRunContextParts.push(this.augmentedPrompt);
+ }
+ }
+
+ /** Memory context (user preferences/memories). Keyed context (with memory
+ * keys + token metadata) is reserved for agents that can call
+ * `delete_memory`; everyone else gets the unkeyed values only. */
+ const memories = await this.useMemory();
+ const buildMemoryContext = (text) =>
+ text ? `${memoryInstructions}\n\n# Existing memory about the user:\n${text}` : undefined;
+ const memoryContext = buildMemoryContext(memories?.withoutKeys);
+ const keyedMemoryContext = buildMemoryContext(memories?.withKeys);
+
+ const sharedRunContext = sharedRunContextParts.join('\n\n');
+ const memoryAgentEnabled = isMemoryAgentEnabled(this.options.req.config?.memory);
+
+ const agentScopedContext = await buildAgentScopedContext({
+ agentIds: allAgents.map(({ agentId }) => agentId),
+ attachmentsByAgentId: this.options.agentContextAttachmentsByAgentId,
+ sharedRunAttachmentIds,
+ req: this.options.req,
+ tokenCountFn: (text) => countTokens(text),
+ });
+
+ /** Preserve prompt token counts for graph formatting and pruning. */
+ this.indexTokenCountMap = indexTokenCountMap;
+
+ /** 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 = {
+ prompt: payload,
+ tokenCountMap,
+ promptTokens,
+ messages,
+ };
+
+ if (promptTokens >= 0 && typeof opts?.getReqData === 'function') {
+ opts.getReqData({ promptTokens });
+ }
+
+ /**
+ * Apply context to all agents.
+ * Stable agent/MCP instructions stay on `instructions`; shared runtime context
+ * is appended to `additional_instructions` as the dynamic system tail.
+ *
+ * NOTE: This intentionally mutates agent objects in place. The agentConfigs Map
+ * holds references to config objects that will be passed to the graph runtime.
+ */
+ const ephemeralAgent = this.options.req.body.ephemeralAgent;
+ const mcpManager = getMCPManager();
+
+ const configServers = await resolveConfigServers(this.options.req);
+
+ await Promise.all(
+ allAgents.map(({ agent, agentId }) => {
+ const agentRunContextParts = [sharedRunContext];
+ const agentHasMemory = agentHasInlineMemoryTools(agent);
+ const agentMemoryContext = agentHasMemory ? keyedMemoryContext : memoryContext;
+ if (
+ agentMemoryContext &&
+ (agentId === this.options.agent.id || memoryAgentEnabled || agentHasMemory)
+ ) {
+ agentRunContextParts.push(agentMemoryContext);
+ }
+ const scopedContext = agentScopedContext.get(agentId);
+ if (scopedContext) {
+ agentRunContextParts.push(scopedContext);
+ }
+
+ return applyContextToAgent({
+ agent,
+ agentId,
+ logger,
+ mcpManager,
+ configServers,
+ sharedRunContext: agentRunContextParts.filter(Boolean).join('\n\n'),
+ ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined,
+ });
+ }),
+ );
+
+ return result;
+ }
+
+ /**
+ * Creates a promise that resolves with the memory promise result or undefined after a timeout
+ * @param {Promise<(TAttachment | null)[] | undefined>} memoryPromise - The memory promise to await
+ * @param {number} timeoutMs - Timeout in milliseconds (default: 3000)
+ * @returns {Promise<(TAttachment | null)[] | undefined>}
+ */
+ async awaitMemoryWithTimeout(memoryPromise, timeoutMs = 3000) {
+ if (!memoryPromise) {
+ return;
+ }
+
+ try {
+ const timeoutPromise = new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('Memory processing timeout')), timeoutMs),
+ );
+
+ const attachments = await Promise.race([memoryPromise, timeoutPromise]);
+ return attachments;
+ } catch (error) {
+ if (error.message === 'Memory processing timeout') {
+ logger.warn('[AgentClient] Memory processing timed out after 3 seconds');
+ } else {
+ logger.error('[AgentClient] Error processing memory:', error);
+ }
+ return;
+ }
+ }
+
+ /**
+ * @returns {Promise<{ withKeys?: string; withoutKeys?: string } | undefined>}
+ */
+ async useMemory() {
+ const user = this.options.req.user;
+ if (user.personalization?.memories === false) {
+ return;
+ }
+ const hasAccess = await checkAccess({
+ user,
+ permissionType: PermissionTypes.MEMORIES,
+ permissions: [Permissions.USE],
+ getRoleByName: db.getRoleByName,
+ });
+
+ if (!hasAccess) {
+ logger.debug(
+ `[api/server/controllers/agents/client.js #useMemory] User ${user.id} does not have USE permission for memories`,
+ );
+ return;
+ }
+ const appConfig = this.options.req.config;
+ const memoryConfig = appConfig.memory;
+ if (!memoryConfig || memoryConfig.disabled === true) {
+ return;
+ }
+
+ const userId = this.options.req.user.id + '';
+ this.processMemory = undefined;
+
+ if (!isMemoryAgentEnabled(memoryConfig)) {
+ try {
+ const { withKeys, withoutKeys } = await getRequestMemories({
+ req: this.options.req,
+ userId,
+ getFormattedMemories: db.getFormattedMemories,
+ });
+ return { withKeys, withoutKeys };
+ } catch (error) {
+ logger.error(
+ '[api/server/controllers/agents/client.js #useMemory] Error loading memories',
+ error,
+ );
+ return;
+ }
+ }
+
+ /** @type {Agent} */
+ let prelimAgent;
+ const allowedProviders = new Set(
+ appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders,
+ );
+ try {
+ if (memoryConfig.agent?.id != null && memoryConfig.agent.id !== this.options.agent.id) {
+ prelimAgent = await loadAgent({
+ req: this.options.req,
+ agent_id: memoryConfig.agent.id,
+ endpoint: EModelEndpoint.agents,
+ });
+ } else if (memoryConfig.agent?.id != null) {
+ prelimAgent = this.options.agent;
+ } else if (
+ memoryConfig.agent?.id == null &&
+ memoryConfig.agent?.model != null &&
+ memoryConfig.agent?.provider != null
+ ) {
+ prelimAgent = { id: Constants.EPHEMERAL_AGENT_ID, ...memoryConfig.agent };
+ }
+ } catch (error) {
+ logger.error(
+ '[api/server/controllers/agents/client.js #useMemory] Error loading agent for memory',
+ error,
+ );
+ }
+
+ if (!prelimAgent) {
+ return;
+ }
+
+ /** Forward the same `execute_code` capability gate the chat flow uses —
+ * memory agents are unlikely to list `execute_code`, but if one does,
+ * Phase 8 relies on this flag to expand the string into
+ * `bash_tool` + `read_file` (pre-Phase 8 the legacy `execute_code`
+ * tool registered unconditionally; without this passthrough the
+ * memory path would silently lose code-execution tooling). */
+ const memoryCapabilities = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities);
+ const agent = await initializeAgent(
+ {
+ req: this.options.req,
+ res: this.options.res,
+ agent: prelimAgent,
+ allowedProviders,
+ endpointOption: {
+ endpoint: !isEphemeralAgentId(prelimAgent.id)
+ ? EModelEndpoint.agents
+ : memoryConfig.agent?.provider,
+ },
+ codeEnvAvailable: memoryCapabilities.has(AgentCapabilities.execute_code),
+ },
+ {
+ getFiles: db.getFiles,
+ getUserKey: db.getUserKey,
+ getConvoFiles: db.getConvoFiles,
+ updateFilesUsage: db.updateFilesUsage,
+ getUserKeyValues: db.getUserKeyValues,
+ getToolFilesByIds: db.getToolFilesByIds,
+ getCodeGeneratedFiles: db.getCodeGeneratedFiles,
+ filterFilesByAgentAccess,
+ },
+ );
+
+ if (!agent) {
+ logger.warn(
+ '[api/server/controllers/agents/client.js #useMemory] No agent found for memory',
+ memoryConfig,
+ );
+ return;
+ }
+
+ const llmConfig = Object.assign(
+ {
+ provider: agent.provider,
+ model: agent.model,
+ },
+ agent.model_parameters,
+ );
+
+ /** @type {import('@librechat/api').MemoryConfig} */
+ const config = {
+ validKeys: memoryConfig.validKeys,
+ instructions: agent.instructions,
+ llmConfig,
+ tokenLimit: memoryConfig.tokenLimit,
+ };
+
+ const messageId = this.responseMessageId + '';
+ const conversationId = this.conversationId + '';
+ const streamId = this.options.req?._resumableStreamId || null;
+ const [withoutKeys, processMemory] = await createMemoryProcessor({
+ userId,
+ config,
+ messageId,
+ streamId,
+ conversationId,
+ memoryMethods: {
+ setMemory: db.setMemory,
+ deleteMemory: db.deleteMemory,
+ getFormattedMemories: db.getFormattedMemories,
+ },
+ res: this.options.res,
+ user: createSafeUser(this.options.req.user),
+ });
+
+ this.processMemory = processMemory;
+ let withKeys = withoutKeys;
+ try {
+ ({ withKeys } = await getRequestMemories({
+ req: this.options.req,
+ userId,
+ getFormattedMemories: db.getFormattedMemories,
+ }));
+ } catch (error) {
+ logger.error(
+ '[api/server/controllers/agents/client.js #useMemory] Error loading keyed memories',
+ error,
+ );
+ }
+ return { withKeys, withoutKeys };
+ }
+
+ /**
+ * Filters out image URLs from message content
+ * @param {BaseMessage} message - The message to filter
+ * @returns {BaseMessage} - A new message with image URLs removed
+ */
+ filterImageUrls(message) {
+ if (!message.content || typeof message.content === 'string') {
+ return message;
+ }
+
+ if (Array.isArray(message.content)) {
+ const filteredContent = message.content.filter(
+ (part) => part.type !== ContentTypes.IMAGE_URL,
+ );
+
+ if (filteredContent.length === 1 && filteredContent[0].type === ContentTypes.TEXT) {
+ const MessageClass = message.constructor;
+ return new MessageClass({
+ content: filteredContent[0].text,
+ additional_kwargs: message.additional_kwargs,
+ });
+ }
+
+ const MessageClass = message.constructor;
+ return new MessageClass({
+ content: filteredContent,
+ additional_kwargs: message.additional_kwargs,
+ });
+ }
+
+ return message;
+ }
+
+ /**
+ * @param {BaseMessage[]} messages
+ * @returns {Promise}
+ */
+ async runMemory(messages) {
+ try {
+ if (this.processMemory == null) {
+ return;
+ }
+ const appConfig = this.options.req.config;
+ const memoryConfig = appConfig.memory;
+ const messageWindowSize = memoryConfig?.messageWindowSize ?? 5;
+
+ /**
+ * Strip skill-primed meta messages before memory extraction. The primes
+ * sit next to the latest user message and carry large SKILL.md bodies,
+ * so letting them into the window would crowd out real chat turns and
+ * pollute extracted memories with synthetic instruction content the
+ * user never typed.
+ */
+ const chatMessages = messages.filter((m) => !isSkillPrimeMessage(m));
+
+ let messagesToProcess = [...chatMessages];
+ if (chatMessages.length > messageWindowSize) {
+ for (let i = chatMessages.length - messageWindowSize; i >= 0; i--) {
+ const potentialWindow = chatMessages.slice(i, i + messageWindowSize);
+ if (potentialWindow[0]?.role === 'user') {
+ messagesToProcess = [...potentialWindow];
+ break;
+ }
+ }
+
+ if (messagesToProcess.length === chatMessages.length) {
+ messagesToProcess = [...chatMessages.slice(-messageWindowSize)];
+ }
+ }
+
+ const filteredMessages = messagesToProcess.map((msg) => this.filterImageUrls(msg));
+ const bufferString = getBufferString(filteredMessages);
+ const configuredMaxInputTokens = Number.isFinite(memoryConfig?.maxInputTokens)
+ ? Math.floor(memoryConfig.maxInputTokens)
+ : undefined;
+ const maxInputTokens =
+ configuredMaxInputTokens != null && configuredMaxInputTokens > 0
+ ? configuredMaxInputTokens
+ : DEFAULT_MEMORY_MAX_INPUT_TOKENS;
+ const maxInputChars = maxInputTokens * MEMORY_INPUT_CHARS_PER_TOKEN;
+ const isCharTruncated = bufferString.length > maxInputChars;
+ const memoryInput = `# Current Chat:\n\n${
+ isCharTruncated
+ ? `[Earlier chat content omitted due to memory input limit]\n\n${bufferString.slice(
+ -maxInputChars,
+ )}`
+ : bufferString
+ }`;
+ const {
+ text: limitedMemoryInput,
+ tokenCount,
+ wasTruncated,
+ } = await processTextWithTokenLimit({
+ text: memoryInput,
+ tokenLimit: maxInputTokens,
+ tokenCountFn: (text) => countTokens(text),
+ preserve: 'end',
+ });
+ if (isCharTruncated || wasTruncated) {
+ logger.warn('[MemoryAgent] Memory input truncated before processing', {
+ tokenCount,
+ messageId: this.responseMessageId,
+ conversationId: this.conversationId,
+ maxInputTokens,
+ wasTruncated,
+ maxInputChars,
+ originalLength: bufferString.length,
+ });
+ }
+ const bufferMessage = new HumanMessage(limitedMemoryInput);
+ return await this.processMemory([bufferMessage]);
+ } catch (error) {
+ logger.error('Memory Agent failed to process memory', error);
+ }
+ }
+
+ /** @type {sendCompletion} */
+ async sendCompletion(payload, opts = {}) {
+ await this.chatCompletion({
+ payload,
+ onProgress: opts.onProgress,
+ userMCPAuthMap: opts.userMCPAuthMap,
+ abortController: opts.abortController,
+ });
+
+ const completion = filterMalformedContentParts(this.contentParts);
+ const metadata = this.buildResponseMetadata();
+ return metadata ? { completion, metadata } : { completion };
+ }
+
+ /**
+ * Assembles the response message `metadata`: Vertex thought signatures plus
+ * the persisted context breakdown (Part A) and the usage/cost rollup (Part B),
+ * which rebuild the gauge breakdown and branch/total cost across reloads.
+ * Returns undefined when nothing was captured.
+ * @returns {{
+ * thoughtSignatures?: Record,
+ * contextUsage?: import('librechat-data-provider').TContextUsageEvent,
+ * usage?: import('librechat-data-provider').TResponseUsage,
+ * } | undefined}
+ */
+ buildResponseMetadata() {
+ /** @type {{
+ * thoughtSignatures?: Record,
+ * contextUsage?: import('librechat-data-provider').TContextUsageEvent,
+ * usage?: import('librechat-data-provider').TResponseUsage,
+ * }} */
+ const metadata = {};
+ const signatures = this.collectedThoughtSignatures;
+ if (signatures && Object.keys(signatures).length > 0) {
+ metadata.thoughtSignatures = signatures;
+ }
+ const usageEvents = this.usageEmitSink ?? [];
+ /** Persist the breakdown only when the latest snapshot's OWN run completed —
+ * i.e. a PRIMARY usage event (usage_type == null) from that run's id arrived
+ * AFTER the snapshot. Matching by run id keeps `completedOutputTokens` a real
+ * post-snapshot delta even when parallel/direct runs interleave (A snapshot →
+ * B snapshot → A usage must NOT persist B's snapshot with A's output); an
+ * interrupted final call that emits no usage falls back to the per-message
+ * estimate. It still keeps the post-summary snapshot: the summarization detour
+ * emits an extra snapshot whose following primary usage shares that run's id,
+ * which the old snapshot-count guard miscounted and wrongly dropped. Events
+ * without a run id (older lib / resume) match any snapshot for back-compat. */
+ const latestSnapshot = this.contextUsageSink?.latest;
+ const latestSnapshotUsageIndex = this.contextUsageSink?.latestUsageIndex ?? 0;
+ const latestSnapshotRunId = latestSnapshot?.runId;
+ const hasPrimaryAfterSnapshot = usageEvents
+ .slice(latestSnapshotUsageIndex)
+ .some(
+ (event) =>
+ event.usage_type == null &&
+ (latestSnapshotRunId == null ||
+ event.runId == null ||
+ event.runId === latestSnapshotRunId),
+ );
+ if (latestSnapshot && hasPrimaryAfterSnapshot) {
+ metadata.contextUsage = buildPersistedContextUsage(latestSnapshot, usageEvents);
+ }
+ /** Lightweight summarization marker — persisted whenever this turn compacted
+ * the context, INDEPENDENT of the snapshot guard above. When the client has
+ * no usable snapshot on the branch and falls back to the per-message
+ * estimate, it caps the discarded pre-summary history at this baseline
+ * instead of re-summing it (the gauge otherwise reads 100% forever). Shared
+ * with the abort save path via `computeSummaryUsedTokens`. Subtract the
+ * response's earlier tool-loop outputs (the primaries that preceded the
+ * latest snapshot, same run): those tokens are inside the snapshot baseline
+ * AND in the response `tokenCount` the client estimate adds on top, so
+ * leaving them in the marker double-counts them on a multi-call turn. */
+ const priorOutputTokens = priorRunOutputTokens(
+ usageEvents,
+ latestSnapshotUsageIndex,
+ latestSnapshotRunId,
+ );
+ const summaryUsedTokens = computeSummaryUsedTokens(latestSnapshot, priorOutputTokens);
+ if (summaryUsedTokens != null) {
+ metadata.summaryUsedTokens = summaryUsedTokens;
+ }
+ const usage = aggregateEmittedUsage(usageEvents);
+ if (usage) {
+ metadata.usage = usage;
+ }
+ return Object.keys(metadata).length > 0 ? metadata : undefined;
+ }
+
+ /**
+ * Resolves the endpoint token config for a usage item by its producing agent
+ * (multi-endpoint graphs: connected agents + subagents). A known agent's
+ * config is authoritative — including `undefined`, which prices with built-in
+ * rates (e.g. a non-custom agent in a custom-primary graph). Only an
+ * untagged/unknown agent falls back to the primary config, so single-endpoint
+ * graphs are unchanged.
+ * @param {UsageMetadata} usage
+ * @returns {import('@librechat/api').EndpointTokenConfig | undefined}
+ */
+ resolveAgentEndpointTokenConfig(usage) {
+ return resolveAgentTokenConfig({
+ agentId: usage?.agentId,
+ byAgentId: this.options.endpointTokenConfigByAgentId,
+ fallback: this.options.endpointTokenConfig,
+ });
+ }
+
+ /**
+ * @param {Object} params
+ * @param {string} [params.model]
+ * @param {string} [params.context='message']
+ * @param {AppConfig['balance']} [params.balance]
+ * @param {AppConfig['transactions']} [params.transactions]
+ * @param {UsageMetadata[]} [params.collectedUsage=this.collectedUsage]
+ */
+ async recordCollectedUsage({
+ model,
+ balance,
+ transactions,
+ context = 'message',
+ collectedUsage = this.collectedUsage,
+ }) {
+ const result = await recordCollectedUsage(
+ {
+ 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,
+ conversationId: this.conversationId,
+ collectedUsage,
+ model: model ?? this.model ?? this.options.agent.model_parameters.model,
+ context,
+ messageId: this.responseMessageId,
+ balance,
+ transactions,
+ endpointTokenConfig: this.options.endpointTokenConfig,
+ resolveEndpointTokenConfig: (usage) => this.resolveAgentEndpointTokenConfig(usage),
+ },
+ );
+
+ if (result) {
+ this.usage = result;
+ }
+ }
+
+ /**
+ * Get stream usage as returned by this client's API response.
+ * @returns {UsageMetadata} The stream usage object.
+ */
+ getStreamUsage() {
+ return this.usage;
+ }
+
+ /**
+ * Builds the subagent usage emitter for {@link createSubagentUsageSink}.
+ * Streams each billed child-run usage to the client as an `on_token_usage`
+ * event tagged `subagent` (folds into session cost/totals, not the live
+ * gauge), with the authoritative cost when `interface.contextCost` is on.
+ * Returns undefined when there's no stream to write to.
+ * @param {AppConfig} [appConfig]
+ * @returns {((usage: UsageMetadata) => void) | undefined}
+ */
+ buildSubagentUsageEmitter(appConfig) {
+ const res = this.options.res;
+ const streamId = this.options.req?._resumableStreamId || null;
+ if (!res && !streamId) {
+ return undefined;
+ }
+ const includeCost = appConfig?.interfaceConfig?.contextCost === true;
+ return (usage) => {
+ const data = {
+ input_tokens: usage.input_tokens,
+ output_tokens: usage.output_tokens,
+ total_tokens: usage.total_tokens,
+ input_token_details: this.subagentCacheDetails(usage),
+ model: usage.model,
+ provider: usage.provider,
+ usage_type: 'subagent',
+ runId: this.responseMessageId,
+ /** Unique per collected entry (post-push length) for resume dedupe */
+ seq: this.collectedUsage.length,
+ /** Price with the SUBAGENT's own endpoint token config (its endpoint may
+ * differ from the parent's); `usage.agentId` is tagged by the sink. */
+ cost: includeCost
+ ? computeUsageCostUSD(
+ usage,
+ { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
+ this.resolveAgentEndpointTokenConfig(usage),
+ )
+ : undefined,
+ };
+ /** Fold into the response's usage rollup (synchronously, regardless of
+ * emit success) so the persisted total matches the live session, which
+ * also folds subagent usage into its cost/totals. */
+ if (this.usageEmitSink) {
+ this.usageEmitSink.push(data);
+ }
+ /** The sink fires this without awaiting, so retain the promise and flush
+ * it in chatCompletion's finally — emitChunk persists (HSET) before
+ * publishing, and job cleanup must not race that persist or resumed
+ * clients miss billed subagent usage. */
+ const emit = (async () => {
+ try {
+ if (streamId) {
+ await GenerationJobManager.emitChunk(streamId, {
+ event: UsageEvents.ON_TOKEN_USAGE,
+ data,
+ });
+ } else {
+ sendEvent(res, { event: UsageEvents.ON_TOKEN_USAGE, data });
+ }
+ } catch (err) {
+ logger.warn('[AgentClient] Failed to emit subagent usage', err);
+ }
+ })();
+ this.pendingSubagentEmits.push(emit);
+ return emit;
+ };
+ }
+
+ /** Normalizes a subagent usage event's cache token details for emission. */
+ subagentCacheDetails(usage) {
+ const cache_creation =
+ usage.input_token_details?.cache_creation ?? usage.cache_creation_input_tokens;
+ const cache_read = usage.input_token_details?.cache_read ?? usage.cache_read_input_tokens;
+ if (cache_creation == null && cache_read == null) {
+ return undefined;
+ }
+ return { cache_creation, cache_read };
+ }
+
+ /**
+ * @param {TMessage} responseMessage
+ * @returns {number}
+ */
+ getTokenCountForResponse({ content }) {
+ return countFormattedMessageTokens({ role: 'assistant', content }, this.getEncoding());
+ }
+
+ /**
+ * @param {object} params
+ * @param {string | ChatCompletionMessageParam[]} params.payload
+ * @param {Record>} [params.userMCPAuthMap]
+ * @param {AbortController} [params.abortController]
+ */
+ /**
+ * @deprecated Agent Chain — strip hidden intermediate sequential-agent content
+ * before persistence, keeping only the last part + tool_call parts. Mirrors the
+ * chat path so a HITL resume doesn't persist/emit intermediate outputs the
+ * agent's `hide_sequential_outputs` setting is meant to hide.
+ */
+ applyHideSequentialOutputsFilter() {
+ if (!this.options.agent?.hide_sequential_outputs || !Array.isArray(this.contentParts)) {
+ return;
+ }
+ this.contentParts = this.contentParts.filter(
+ (part, index) =>
+ index >= this.contentParts.length - 1 ||
+ part.type === ContentTypes.TOOL_CALL ||
+ part.tool_call_ids,
+ );
+ }
+
+ /**
+ * Surface any human-in-the-loop interrupt the SDK captured during the most
+ * recent `processStream` / `resume`. When the run paused for tool approval (or
+ * an ask-user question), mark the job `requires_action`, persist the pending
+ * review record, and emit it to live clients — then set `this.pendingApproval`
+ * so the controller leaves the turn unfinalized for the resume route to continue.
+ *
+ * No-op when the run completed without an interrupt, or when the job was aborted
+ * between the interrupt firing and this mark (a late interrupt must not pause a
+ * dead job — the atomic `pause` transition returns false and we drop it).
+ *
+ * @param {AgentRun} run
+ * @param {string} [streamId]
+ */
+ async handleRunInterrupt(run, streamId) {
+ if (!streamId || typeof run?.getInterrupt !== 'function') {
+ return;
+ }
+ const interrupt = run.getInterrupt();
+ if (!interrupt?.payload) {
+ return;
+ }
+
+ const appConfig = this.options.req?.config;
+ const checkpointerCfg = appConfig?.endpoints?.[EModelEndpoint.agents]?.checkpointer;
+ // Persist the resolved model parameters (temperature, max tokens, custom endpoint
+ // params, …) so an ephemeral-agent resume continues with the SAME settings the run
+ // paused on. The resume payload omits them and they aren't part of the fingerprint, so
+ // without this the rebuilt ephemeral run falls back to defaults. (Saved agents source
+ // these from the DB record server-side, so this is belt-and-suspenders for them.)
+ // Sanitized: the resolved params are the llmConfig, which carries provider secrets
+ // (apiKey, credentials) and gateway config — resume re-resolves those server-side.
+ const resumeContext = pickResumeContext(this.options.req?.body);
+ const resolvedModelParameters = sanitizeResumeModelParameters(
+ this.options.agent?.model_parameters,
+ );
+ if (resolvedModelParameters) {
+ resumeContext.model_parameters = resolvedModelParameters;
+ }
+ // Persist the question onto the paused ask tool_call's args NOW: an
+ // abandoned/expired/stopped pause never reaches the answer-resume stamp,
+ // and the streamed args were dropped (name-less chunks) — without this the
+ // unfinished turn saves an empty ask part the record card can't render.
+ if (interrupt.payload?.type === 'ask_user_question' && Array.isArray(this.contentParts)) {
+ const stamped = attachAskUserQuestionArgs(this.contentParts, interrupt.payload.question);
+ if (stamped !== this.contentParts) {
+ this.contentParts.length = 0;
+ this.contentParts.push(...stamped);
+ }
+ }
+ const pendingAction = buildPendingAction(interrupt.payload, {
+ streamId,
+ conversationId: this.conversationId,
+ // runId mirrors the LangGraph checkpoint namespace when the SDK provides it
+ // (its documented meaning), falling back to the response message id.
+ runId: interrupt.checkpointNs ?? this.responseMessageId,
+ responseMessageId: this.responseMessageId,
+ interruptId: interrupt.interruptId,
+ // thread_id was bound to conversationId at run config (config.configurable);
+ // fall back to it when the SDK doesn't echo threadId on the interrupt.
+ threadId: interrupt.threadId ?? this.conversationId,
+ ttlMs: getApprovalTtlMs(checkpointerCfg),
+ // Pin the graph-determining request fields so resume can't rebuild this paused
+ // run on a different agent/tool set (esp. ephemeral agents, whose agent_id is
+ // undefined so the id guard can't tell two configs apart).
+ requestFingerprint: computeAgentRequestFingerprint(this.options.req?.body ?? {}),
+ // Persist those same fields verbatim so the resume route can REPLAY them — a
+ // reload/cross-replica resume can't reconstruct the ephemeral config client-side,
+ // so the server restores it and rebuilds the same graph (and the fingerprint matches).
+ resumeContext,
+ });
+
+ // Job-replacement guard: streamId == conversationId is reused per conversation, so a
+ // newer request can replace this run's job. If this (older) run hits an interrupt
+ // after a replacement, pausing would flip the NEWER job to requires_action with this
+ // stale run's pending action, blocking fresh work behind the wrong approval. Only
+ // pause when the live job is still the one THIS run created (mirrors request.js).
+ if (this.jobCreatedAt != null) {
+ const liveJob = await GenerationJobManager.getJobStore().getJob(streamId);
+ if (!liveJob || liveJob.createdAt !== this.jobCreatedAt) {
+ logger.debug(`[AgentClient] Interrupt fired but job ${streamId} was replaced; not pausing`);
+ return;
+ }
+ }
+
+ const paused = await GenerationJobManager.approvals.pause(streamId, pendingAction);
+ if (!paused) {
+ logger.debug(
+ `[AgentClient] Interrupt fired but job ${streamId} was not running; not pausing`,
+ );
+ return;
+ }
+
+ // Capture deferred tools discovered (via tool_search) earlier in THIS turn so resume
+ // can replay them into createRun. The resumed graph is rebuilt with `messages: []`
+ // (state comes from the checkpoint), so the in-turn tool_search results that mark a
+ // deferred tool discovered aren't present there — without this the paused deferred
+ // tool would be missing from the rebuilt schema-only toolMap and resume would fail
+ // with "unknown tool". Inert for non-deferred turns (the set comes back empty).
+ try {
+ const runMessages =
+ typeof run.getRunMessages === 'function' ? run.getRunMessages() : undefined;
+ if (Array.isArray(runMessages) && runMessages.length > 0) {
+ const discovered = extractDiscoveredToolsFromHistory(runMessages);
+ if (discovered.size > 0) {
+ await GenerationJobManager.updateMetadata(streamId, {
+ discoveredTools: Array.from(discovered),
+ });
+ }
+ }
+ } catch (err) {
+ logger.warn(
+ `[AgentClient] Failed to capture discovered tools for resume on ${streamId}`,
+ err?.message ?? err,
+ );
+ }
+
+ this.pendingApproval = pendingAction;
+ // Release the concurrency slot this request held the MOMENT the turn is durably
+ // paused — before the approval card is emitted — so the user's `/resume` can
+ // re-acquire one immediately. Otherwise a fast Approve races the HTTP-driver
+ // teardown (request.js pause branch / resume.js finally) that would otherwise
+ // release it, and `/resume` 429s under LIMIT_CONCURRENT_MESSAGES. Idempotent via
+ // the flag; if it fails here, the teardown still releases (it checks the flag).
+ if (!this.pendingRequestReleased) {
+ try {
+ await decrementPendingRequest(this.options.req?.user?.id);
+ this.pendingRequestReleased = true;
+ } catch (err) {
+ logger.error(`[AgentClient] Failed to release request slot on pause ${streamId}`, err);
+ }
+ }
+ await GenerationJobManager.emitChunk(streamId, {
+ event: ApprovalEvents.ON_PENDING_ACTION,
+ data: toClientPendingAction(pendingAction),
+ });
+ logger.debug(
+ `[AgentClient] Paused ${streamId} for ${interrupt.payload.type} (action ${pendingAction.actionId})`,
+ );
+ }
+
+ async chatCompletion({ payload, userMCPAuthMap, abortController = null }) {
+ /** @type {Partial} */
+ let config;
+ /** @type {ReturnType} */
+ let run;
+ /** @type {Promise<(TAttachment | null)[] | undefined>} */
+ let memoryPromise;
+ const appConfig = this.options.req.config;
+ const balanceConfig = getBalanceConfig(appConfig);
+ const transactionsConfig = getTransactionsConfig(appConfig);
+ try {
+ if (!abortController) {
+ abortController = new AbortController();
+ }
+
+ /** @type {AppConfig['endpoints']['agents']} */
+ const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
+
+ config = {
+ runName: 'AgentRun',
+ configurable: {
+ thread_id: this.conversationId,
+ last_agent_index: this.agentConfigs?.size ?? 0,
+ user_id: this.user ?? this.options.req.user?.id,
+ hide_sequential_outputs: this.options.agent.hide_sequential_outputs,
+ requestBody: {
+ messageId: this.responseMessageId,
+ conversationId: this.conversationId,
+ parentMessageId: this.parentMessageId,
+ },
+ user: createSafeUser(this.options.req.user),
+ },
+ recursionLimit: resolveRecursionLimit(agentsEConfig, this.options.agent),
+ signal: abortController.signal,
+ streamMode: 'values',
+ version: 'v2',
+ };
+
+ const toolSet = buildToolSet(this.options.agent);
+ const tokenCounter = createTokenCounter(this.getEncoding());
+
+ /** Pre-resolve invoked skill bodies + re-prime files before formatting messages */
+ const skillPrimeResult = this.options.primeInvokedSkills
+ ? await this.options.primeInvokedSkills(payload)
+ : undefined;
+
+ /**
+ * Seed `Graph.sessions` with code-env files primed across every
+ * reachable agent (primary, handoff/addedConvo, and nested
+ * subagents) plus skill-priming output. The merge logic and its
+ * run-wide semantics live in `buildInitialToolSessions`; see that
+ * helper's doc for why this is intentionally NOT per-agent.
+ */
+ const initialSessions = buildInitialToolSessions({
+ skillSessions: skillPrimeResult?.initialSessions,
+ agents: [this.options.agent, ...(this.agentConfigs ? this.agentConfigs.values() : [])],
+ });
+
+ /**
+ * Reconstruct `reasoning_content` on prior tool-call turns: DeepSeek
+ * thinking-mode (#13366) or custom endpoints opting in via
+ * `customParams.includeReasoningHistory` (e.g. Xiaomi MiMo, Kimi).
+ * Walks subagents too — the opted-in endpoint may appear only as a
+ * nested subagent, not the primary or a top-level handoff agent.
+ */
+ const needsReasoningContentFormat = anyAgentReplaysReasoningContent([
+ this.options.agent,
+ ...(this.agentConfigs ? Array.from(this.agentConfigs.values()) : []),
+ ]);
+ /**
+ * Skills primed fresh this turn — manual ($ popover) and always-apply
+ * (frontmatter). `injectSkillPrimes` (below) splices their SKILL.md
+ * bodies in, so `formatAgentMessages` must NOT also reconstruct the
+ * same names from a historical `skill` tool_call — otherwise the body
+ * lands twice and a prompt-cache marker can pin to the duplicated
+ * synthetic prefix. Names NOT primed this turn still reconstruct from
+ * history, preserving sticky manual re-priming across turns.
+ */
+ const manualSkillPrimes = this.options.agent?.manualSkillPrimes;
+ const alwaysApplySkillPrimes = this.options.agent?.alwaysApplySkillPrimes;
+ const freshSkillPrimeNames = collectFreshSkillPrimeNames({
+ manualSkillPrimes,
+ alwaysApplySkillPrimes,
+ });
+ const formatOptions =
+ needsReasoningContentFormat || freshSkillPrimeNames.size > 0
+ ? {
+ ...(needsReasoningContentFormat ? { preserveReasoningContent: true } : {}),
+ ...(freshSkillPrimeNames.size > 0
+ ? { skipSkillBodyNames: freshSkillPrimeNames }
+ : {}),
+ }
+ : undefined;
+ let {
+ messages: initialMessages,
+ indexTokenCountMap,
+ summary: initialSummary,
+ boundaryTokenAdjustment,
+ } = formatAgentMessages(
+ payload,
+ this.indexTokenCountMap,
+ toolSet,
+ skillPrimeResult?.skills,
+ formatOptions,
+ );
+ if (boundaryTokenAdjustment) {
+ logger.debug(
+ `[AgentClient] Boundary token adjustment: ${boundaryTokenAdjustment.original} → ${boundaryTokenAdjustment.adjusted} (${boundaryTokenAdjustment.remainingChars}/${boundaryTokenAdjustment.totalChars} chars)`,
+ );
+ }
+
+ /**
+ * Skill priming — both manual ($ popover) and always-apply (frontmatter).
+ *
+ * Splice + index-shift logic lives in `injectSkillPrimes`
+ * (packages/api/src/agents/skills.ts) so the delicate position math
+ * can be unit-tested in TS without standing up AgentClient. The
+ * resolver enforces a combined ceiling (manual-first, always-apply
+ * truncated first when over cap) before reaching here; the splice
+ * re-applies the cap as defense-in-depth. Runs for both single-
+ * agent and multi-agent runs; how primes interact with handoff /
+ * added-convo agents' per-agent state is an agents-SDK concern,
+ * not this layer's to gate.
+ *
+ * `manualSkillPrimes` / `alwaysApplySkillPrimes` are resolved above
+ * (used to build `freshSkillPrimeNames` for dedupe against historical
+ * skill reconstruction).
+ */
+ if (
+ (manualSkillPrimes && manualSkillPrimes.length > 0) ||
+ (alwaysApplySkillPrimes && alwaysApplySkillPrimes.length > 0)
+ ) {
+ const primeResult = injectSkillPrimes({
+ initialMessages,
+ indexTokenCountMap,
+ manualSkillPrimes,
+ alwaysApplySkillPrimes,
+ });
+ indexTokenCountMap = primeResult.indexTokenCountMap;
+ if (primeResult.inserted > 0) {
+ const manualNames = (manualSkillPrimes ?? []).map((p) => p.name);
+ const alwaysApplyNames = (alwaysApplySkillPrimes ?? []).map((p) => p.name);
+ logger.debug(
+ `[AgentClient] Primed ${primeResult.inserted} skill(s) at message index ${primeResult.insertIdx} — manual: [${manualNames.join(', ')}], always-apply: [${alwaysApplyNames.join(', ')}]`,
+ );
+ }
+ if (primeResult.alwaysApplyDropped > 0) {
+ logger.warn(
+ `[AgentClient] Dropped ${primeResult.alwaysApplyDropped} always-apply prime(s) to stay within MAX_PRIMED_SKILLS_PER_TURN.`,
+ );
+ }
+ }
+
+ 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,
+ });
+
+ const memoryMessages =
+ this.processMemory && this.memoryPayload
+ ? formatAgentMessages(
+ this.memoryPayload,
+ undefined,
+ toolSet,
+ skillPrimeResult?.skills,
+ formatOptions,
+ ).messages
+ : initialMessages;
+
+ /**
+ * @param {BaseMessage[]} messages
+ */
+ const runAgents = async (messages) => {
+ const agents = [this.options.agent];
+ // Include additional agents when:
+ // - agentConfigs has agents (from addedConvo parallel execution or agent handoffs)
+ // - Agents without incoming edges become start nodes and run in parallel automatically
+ if (this.agentConfigs && this.agentConfigs.size > 0) {
+ agents.push(...this.agentConfigs.values());
+ }
+
+ // 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) =>
+ // agent.model_parameters.model.match(regex),
+ // );
+ // if (noSystemMessages === true && systemContent?.length) {
+ // const latestMessageContent = _messages.pop().content;
+ // if (typeof latestMessageContent !== 'string') {
+ // latestMessageContent[0].text = [systemContent, latestMessageContent[0].text].join('\n');
+ // _messages.push(new HumanMessage({ content: latestMessageContent }));
+ // } else {
+ // const text = [systemContent, latestMessageContent].join('\n');
+ // _messages.push(new HumanMessage(text));
+ // }
+ // }
+ // let messages = _messages;
+ // if (agent.useLegacyContent === true) {
+ // messages = formatContentStrings(messages);
+ // }
+ // if (
+ // agent.model_parameters?.clientOptions?.defaultHeaders?.['anthropic-beta']?.includes(
+ // 'prompt-caching',
+ // )
+ // ) {
+ // messages = addCacheControl(messages);
+ // }
+
+ if (this.processMemory) {
+ memoryPromise = this.runMemory(memoryMessages);
+ }
+
+ /** 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,
+ // This controller implements the full HITL pause/resume lifecycle (handleRunInterrupt
+ // persists the pending action; the /resume route rebuilds + continues the run), so it
+ // opts into the tool-approval wiring. Non-resumable callers (OpenAI-compat, Responses)
+ // leave this off so an approval-gated tool can't pause where there's no resume path.
+ hitlCapable: true,
+ indexTokenCountMap,
+ initialSummary,
+ initialSessions,
+ calibrationRatio,
+ runId: this.responseMessageId,
+ signal: abortController.signal,
+ customHandlers: this.options.eventHandlers,
+ requestBody: config.configurable.requestBody,
+ user: createSafeUser(this.options.req?.user),
+ tenantId: this.options.req?.user?.tenantId,
+ summarizationConfig: appConfig?.summarization,
+ appConfig,
+ tokenCounter,
+ /** Bills subagent child-run model calls — child graphs execute
+ * outside the streamEvents loop, so ModelEndHandler never sees
+ * them. Entries land in collectedUsage tagged
+ * `usage_type: 'subagent'` and are spent by recordCollectedUsage.
+ * The sink also streams each as an `on_token_usage` event so the
+ * gauge's session cost/totals include billed subagent usage (the
+ * `subagent` tag keeps it out of the live context meter). */
+ subagentUsageSink: createSubagentUsageSink(
+ this.collectedUsage,
+ this.buildSubagentUsageEmitter(appConfig),
+ ),
+ });
+
+ if (!run) {
+ throw new Error('Failed to create run');
+ }
+
+ this.run = run;
+ if (this._resolveRun) {
+ this._resolveRun(run);
+ this._resolveRun = null;
+ }
+
+ const streamId = this.options.req?._resumableStreamId;
+ if (streamId && run.Graph) {
+ GenerationJobManager.setGraph(streamId, run.Graph);
+ }
+
+ if (userMCPAuthMap != null) {
+ config.configurable.userMCPAuthMap = userMCPAuthMap;
+ }
+
+ /** @deprecated Agent Chain */
+ config.configurable.last_agent_id = agents[agents.length - 1].id;
+
+ // HITL: clear any checkpoint orphaned by a prior paused turn in this
+ // conversation (one that expired or was aborted while paused) so this fresh
+ // turn starts clean instead of rehydrating a stale interrupt — thread_id is
+ // the stable conversationId. No-op when HITL is off or nothing is orphaned.
+ // Deliberately UNCONDITIONAL per HITL turn: any cheaper gate (job metadata,
+ // a Redis flag) can go stale across replicas/restarts and skip the prune
+ // exactly when an orphan exists, while these are two indexed, usually-empty
+ // deleteMany ops — correctness over a micro-optimization.
+ // The gate mirrors createRun's checkpointer condition: the approval policy
+ // OR an ask_user_question-capable agent (which attaches a checkpointer
+ // WITHOUT the approval policy) — an ask pause abandoned via job replacement
+ // or Stop would otherwise rehydrate here and silently duplicate context.
+ if (
+ streamId &&
+ (isHITLEnabled(agentsEConfig?.toolApproval) || agents.some(agentRequestsAskUserQuestion))
+ ) {
+ await deleteAgentCheckpoint(this.conversationId, agentsEConfig?.checkpointer);
+ }
+
+ await run.processStream({ messages }, config, {
+ callbacks: {
+ [Callback.TOOL_ERROR]: logToolError,
+ },
+ });
+
+ // HITL: if the run paused for tool approval, mark the job
+ // `requires_action` + emit the prompt and leave the turn unfinalized
+ // (the resume route continues it). No-op when the run completed.
+ await this.handleRunInterrupt(run, streamId);
+
+ config.signal = null;
+ };
+
+ await runAgents(initialMessages);
+
+ /**
+ * Surface a completed `skill` tool_call content part per *manually*-
+ * primed skill so the existing `SkillCall` frontend renderer shows
+ * a "Skill X loaded" card on the assistant response. Applied after
+ * the graph finishes to avoid clashing with the aggregator's own
+ * per-step content indexing. Prepended (not appended) so cards sit
+ * above the model's output — priming ran before the turn, the
+ * reply follows.
+ *
+ * Always-apply primes intentionally do NOT emit assistant-side
+ * cards. `extractInvokedSkillsFromPayload` scans history for
+ * `skill` tool_calls and feeds `primeInvokedSkills`, which is
+ * Phase 3's sticky-re-prime path — that's the right behavior for
+ * manual (user picked `$skill` once; re-prime on every subsequent
+ * turn from history). For always-apply, `resolveAlwaysApplySkills`
+ * already re-primes every turn from fresh DB state, so persisting
+ * the card would cause the skill body to get primed twice per
+ * turn starting on turn 2. The user-facing acknowledgement for
+ * always-apply lives on the user bubble as the pinned
+ * `SkillPills` row (`message.alwaysAppliedSkills`), which
+ * is the durable signal the user wants: "this skill auto-primes".
+ *
+ * Live streaming display of manual user-bubble pills is handled
+ * by `SkillPills` reading `message.manualSkills`. No
+ * separate SSE emit is needed here; trying to stream a mid-run
+ * tool_call at index 0 collided with the LLM's first text
+ * content, while emitting at a sparse offset pushed the card
+ * below the reply on finalize. Post-run unshift keeps the final
+ * responseMessage.content in the right order.
+ */
+ const manualPrimed = this.options.agent?.manualSkillPrimes ?? [];
+ if (manualPrimed.length > 0) {
+ const runId = this.responseMessageId ?? 'skill-prime';
+ const manualParts = buildSkillPrimeContentParts(manualPrimed, { runId });
+ this.contentParts.unshift(...manualParts);
+ }
+
+ this.applyHideSequentialOutputsFilter();
+ } catch (err) {
+ if (abortController.signal.aborted) {
+ logger.debug(
+ '[api/server/controllers/agents/client.js #sendCompletion] Operation aborted by user',
+ { conversationId: this.conversationId, name: err?.name, code: err?.code },
+ );
+ } else {
+ logger.error(
+ '[api/server/controllers/agents/client.js #sendCompletion] Unhandled error type',
+ err,
+ );
+ this.contentParts.push({
+ type: ContentTypes.ERROR,
+ [ContentTypes.ERROR]: `An error occurred while processing the request${err?.message ? `: ${err.message}` : ''}`,
+ });
+ }
+ } 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;
+ }
+
+ this.finalizeSubagentContent();
+
+ /** Flush subagent usage emits the sink fired without awaiting, so their
+ * persist/publish completes before we return and the job is cleaned up
+ * (resumed clients read this persisted usage). */
+ if (this.pendingSubagentEmits.length > 0) {
+ await Promise.allSettled(this.pendingSubagentEmits);
+ this.pendingSubagentEmits = [];
+ }
+
+ try {
+ const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
+ if (attachments && attachments.length > 0) {
+ this.artifactPromises.push(...attachments);
+ }
+
+ /** Skip token spending if aborted - the abort handler (abortMiddleware.js) handles it
+ This prevents double-spending when user aborts via `/api/agents/chat/abort` */
+ const wasAborted = abortController?.signal?.aborted;
+ if (!wasAborted) {
+ await this.recordCollectedUsage({
+ context: 'message',
+ balance: balanceConfig,
+ transactions: transactionsConfig,
+ });
+ } else {
+ logger.debug(
+ '[api/server/controllers/agents/client.js #chatCompletion] Skipping token spending - handled by abort middleware',
+ );
+ }
+ } catch (err) {
+ logger.error(
+ '[api/server/controllers/agents/client.js #chatCompletion] Error in cleanup phase',
+ err,
+ );
+ }
+ if (this._resolveRun) {
+ this._resolveRun(this.run ?? null);
+ this._resolveRun = null;
+ }
+
+ // HITL: a non-paused turn deliberately prunes nothing here. The lazy checkpointer
+ // (LazyMongoSaver) never persists a clean-exit checkpoint, so there is
+ // nothing this turn left to delete. A checkpoint orphaned by a PRIOR abandoned pause
+ // is cleared by the pre-run prune (before processStream) on the next turn, with the
+ // Mongo TTL as the backstop. Dropping this post-completion prune also removes its
+ // job-replacement race: an older run's late finally can no longer delete a newer
+ // paused run's checkpoint, because there is no longer a clean-path prune to race.
+
+ run = null;
+ config = null;
+ memoryPromise = null;
+ }
+ }
+
+ /**
+ * Resume a run that paused for human-in-the-loop review.
+ *
+ * The original run lives in a detached background task that exits when the run
+ * pauses, so resume REBUILDS the run on a fresh graph bound to the same
+ * `thread_id` (= conversationId) and the durable checkpointer. LangGraph rehydrates
+ * the paused graph state from the checkpoint; `run.resume(value)` re-enters the
+ * interrupted node with the user's decision. State comes from the checkpoint, so
+ * no message history is rebuilt here — `createRun` only needs the agent(s) to
+ * reconstruct the graph structure.
+ *
+ * `seedContent` is the content streamed before the pause (the assistant message +
+ * its tool call). In Redis mode the job store's append log already spans the pause,
+ * so the finalized message is complete regardless; seeding keeps the in-memory store
+ * complete too. The run drives events through the same `streamId`, so the client's
+ * open SSE receives the continuation live.
+ *
+ * Unlike `chatCompletion`, this does NOT prune the checkpoint in its `finally` — the
+ * resume controller owns checkpoint lifecycle (it must also clean up on failures that
+ * happen before this method runs, and keep the checkpoint on a re-pause).
+ *
+ * @param {object} params
+ * @param {Agents.ToolApprovalDecisionMap | { answer: string }} params.resumeValue
+ * @param {Array} [params.seedContent] - content aggregated before the pause
+ * @param {AbortController} [params.abortController]
+ * @param {Pick} [params.commandOptions]
+ */
+ async resumeCompletion({
+ resumeValue,
+ seedContent = [],
+ abortController = null,
+ commandOptions,
+ userMCPAuthMap,
+ discoveredToolNames,
+ }) {
+ /** @type {Partial} */
+ let config;
+ /** @type {ReturnType} */
+ let run;
+ const appConfig = this.options.req.config;
+ const balanceConfig = getBalanceConfig(appConfig);
+ const transactionsConfig = getTransactionsConfig(appConfig);
+ try {
+ if (!abortController) {
+ abortController = new AbortController();
+ }
+
+ /** @type {AppConfig['endpoints']['agents']} */
+ const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
+
+ config = {
+ runName: 'AgentRun',
+ configurable: {
+ thread_id: this.conversationId,
+ last_agent_index: this.agentConfigs?.size ?? 0,
+ user_id: this.user ?? this.options.req.user?.id,
+ hide_sequential_outputs: this.options.agent.hide_sequential_outputs,
+ requestBody: {
+ messageId: this.responseMessageId,
+ conversationId: this.conversationId,
+ parentMessageId: this.parentMessageId,
+ },
+ user: createSafeUser(this.options.req.user),
+ },
+ recursionLimit: resolveRecursionLimit(agentsEConfig, this.options.agent),
+ signal: abortController.signal,
+ streamMode: 'values',
+ version: 'v2',
+ };
+
+ // Seed pre-pause content so the in-memory job store reports the complete turn
+ // (Redis aggregates across the pause via its append log; this covers in-memory).
+ if (Array.isArray(seedContent) && seedContent.length > 0) {
+ this.contentParts.push(...seedContent);
+ }
+
+ const tokenCounter = createTokenCounter(this.getEncoding());
+ const agents = [this.options.agent];
+ if (this.agentConfigs && this.agentConfigs.size > 0) {
+ agents.push(...this.agentConfigs.values());
+ }
+
+ // Re-prime skill files invoked in the pre-pause segment (mirrors the normal path's
+ // `primeInvokedSkills(payload)`), so an approved code/file-backed tool keeps the
+ // injected skill-file session refs instead of running without them. The pre-pause
+ // content carries the `skill` tool_calls, so it stands in for the message payload.
+ let skillSessions;
+ if (
+ typeof this.options.primeInvokedSkills === 'function' &&
+ Array.isArray(seedContent) &&
+ seedContent.length > 0
+ ) {
+ try {
+ const primed = await this.options.primeInvokedSkills([
+ { role: 'assistant', content: seedContent },
+ ]);
+ skillSessions = primed?.initialSessions;
+ } catch (err) {
+ logger.warn(
+ '[api/server/controllers/agents/client.js #resumeCompletion] Failed to re-prime skill sessions',
+ err?.message ?? err,
+ );
+ }
+ }
+
+ // Seed code-env / skill tool sessions so an approved code/file/skill-backed tool
+ // runs with the same uploaded-file context the pre-pause run had — the rebuilt
+ // graph otherwise has no `Graph.sessions` entries (especially cross-replica).
+ const initialSessions = buildInitialToolSessions({ skillSessions, agents });
+
+ run = await createRun({
+ agents,
+ // State (messages, tool calls) is rehydrated from the checkpoint by
+ // run.resume; createRun only needs the agents to rebuild the graph.
+ messages: [],
+ // The resumed run can pause AGAIN (another tool, a follow-up question), and this
+ // controller owns that lifecycle, so it must keep the HITL wiring on the rebuilt run.
+ hitlCapable: true,
+ // Replay deferred tools discovered before the pause. With `messages: []` the
+ // discovery scan finds nothing, so a deferred tool the paused call targets
+ // would be absent from the rebuilt toolMap; these names (captured at pause)
+ // force it back in. Undefined/empty for non-deferred turns — a harmless no-op.
+ discoveredToolNames,
+ initialSessions,
+ runId: this.responseMessageId,
+ signal: abortController.signal,
+ // The rebuilt graph numbers content indices from 0, but the aggregator was
+ // just seeded with the pre-pause parts at those same indices — shift every
+ // resumed step index past the seed, or the new output merges into (or, on a
+ // type mismatch, is silently dropped against) the pre-pause content.
+ customHandlers: createContentIndexOffsetHandlers(
+ this.options.eventHandlers,
+ Array.isArray(seedContent) ? seedContent : [],
+ ),
+ requestBody: config.configurable.requestBody,
+ user: createSafeUser(this.options.req?.user),
+ tenantId: this.options.req?.user?.tenantId,
+ summarizationConfig: appConfig?.summarization,
+ appConfig,
+ tokenCounter,
+ subagentUsageSink: createSubagentUsageSink(
+ this.collectedUsage,
+ this.buildSubagentUsageEmitter(appConfig),
+ ),
+ });
+
+ if (!run) {
+ throw new Error('Failed to create run for resume');
+ }
+
+ this.run = run;
+ if (this._resolveRun) {
+ this._resolveRun(run);
+ this._resolveRun = null;
+ }
+
+ const streamId = this.options.req?._resumableStreamId;
+ // Do NOT cache the rebuilt graph on resume: it was created with `messages: []`, so
+ // RedisJobStore.getContentParts() (which prefers a cached graph over reconstructing
+ // from the chunk log) would return only the resumed segment and drop the pre-pause
+ // assistant/tool-call content on a same-replica reload/status poll. Skipping it makes
+ // introspection fall back to the durable chunk reconstruction, which is complete.
+ // `setContentParts` still points the in-memory store at the seeded client content.
+ if (streamId && this.contentParts) {
+ GenerationJobManager.setContentParts(streamId, this.contentParts);
+ }
+
+ // Carry the user's MCP auth into the rebuilt run so an approved MCP tool executes
+ // with the same OAuth/user credentials it had before the pause.
+ if (userMCPAuthMap != null) {
+ config.configurable.userMCPAuthMap = userMCPAuthMap;
+ }
+
+ /** @deprecated Agent Chain */
+ config.configurable.last_agent_id = agents[agents.length - 1].id;
+
+ await run.resume(
+ resumeValue,
+ config,
+ { callbacks: { [Callback.TOOL_ERROR]: logToolError } },
+ commandOptions,
+ );
+
+ config.signal = null;
+
+ // The model may pause AGAIN (another tool needs approval, or a follow-up
+ // question). Re-arm the same interrupt gate so the cycle can repeat.
+ await this.handleRunInterrupt(run, streamId);
+
+ // Mirror chatCompletion: strip hidden intermediate sequential-agent content
+ // before resume finalize/re-pause persistence reads `this.contentParts`, so a
+ // resumed sequential chain doesn't persist/emit outputs hide_sequential_outputs
+ // is meant to hide.
+ this.applyHideSequentialOutputsFilter();
+ } catch (err) {
+ if (abortController.signal.aborted) {
+ logger.debug(
+ '[api/server/controllers/agents/client.js #resumeCompletion] Aborted by user',
+ {
+ conversationId: this.conversationId,
+ name: err?.name,
+ code: err?.code,
+ },
+ );
+ } else {
+ logger.error(
+ '[api/server/controllers/agents/client.js #resumeCompletion] Unhandled error',
+ err,
+ );
+ this.contentParts.push({
+ type: ContentTypes.ERROR,
+ [ContentTypes.ERROR]: `An error occurred while resuming the request${err?.message ? `: ${err.message}` : ''}`,
+ });
+ }
+ } finally {
+ 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;
+ }
+
+ this.finalizeSubagentContent();
+
+ if (this.pendingSubagentEmits.length > 0) {
+ await Promise.allSettled(this.pendingSubagentEmits);
+ this.pendingSubagentEmits = [];
+ }
+
+ try {
+ const wasAborted = abortController?.signal?.aborted;
+ if (!wasAborted) {
+ await this.recordCollectedUsage({
+ context: 'message',
+ balance: balanceConfig,
+ transactions: transactionsConfig,
+ });
+ }
+ } catch (err) {
+ logger.error(
+ '[api/server/controllers/agents/client.js #resumeCompletion] Error in cleanup phase',
+ err,
+ );
+ }
+ if (this._resolveRun) {
+ this._resolveRun(this.run ?? null);
+ this._resolveRun = null;
+ }
+ run = null;
+ config = null;
+ }
+ }
+
+ /**
+ * Resolves with the agent run once it is initialized, or `null` if
+ * initialization fails. Lets immediate-mode title generation await the run
+ * instead of throwing when fired before `chatCompletion` assigns `this.run`.
+ * Rejects promptly if the provided signal aborts before the run is ready.
+ * @param {AbortSignal} [signal]
+ * @returns {Promise}
+ */
+ _waitForRun(signal) {
+ if (this.run) {
+ return Promise.resolve(this.run);
+ }
+ if (!this._runReady) {
+ this._runReady = new Promise((resolve) => {
+ this._resolveRun = resolve;
+ });
+ }
+ if (!signal) {
+ return this._runReady;
+ }
+ if (signal.aborted) {
+ return Promise.reject(new Error('Aborted before run initialization'));
+ }
+ return new Promise((resolve, reject) => {
+ const onAbort = () => reject(new Error('Aborted before run initialization'));
+ signal.addEventListener('abort', onAbort, { once: true });
+ this._runReady.then((run) => {
+ signal.removeEventListener('abort', onAbort);
+ resolve(run);
+ });
+ });
+ }
+
+ /**
+ * @param {Object} params
+ * @param {string} params.text
+ * @param {AbortController} params.abortController
+ * @param {boolean} [params.immediate] When true, the title is generated as soon
+ * as the request is made — the run is awaited (instead of throwing) and the
+ * title derives from the user's input only (`contentParts` is empty).
+ */
+ async titleConvo({ text, abortController, immediate = false }) {
+ if (!this.run) {
+ if (!immediate) {
+ throw new Error('Run not initialized');
+ }
+ await this._waitForRun(abortController?.signal);
+ if (!this.run) {
+ logger.debug(
+ '[api/server/controllers/agents/client.js #titleConvo] Run unavailable for immediate title generation',
+ );
+ return;
+ }
+ }
+ const { handleLLMEnd, collected: collectedMetadata } = createMetadataAggregator();
+ const { req, agent } = this.options;
+
+ if (req?.body?.isTemporary) {
+ logger.debug(
+ `[api/server/controllers/agents/client.js #titleConvo] Skipping title generation for temporary conversation`,
+ );
+ return;
+ }
+
+ const appConfig = req.config;
+ let endpoint = agent.endpoint;
+
+ /** @type {import('@librechat/agents').ClientOptions} */
+ let clientOptions = {
+ model: agent.model || agent.model_parameters.model,
+ };
+
+ let titleProviderConfig = getProviderConfig({ provider: endpoint, appConfig });
+
+ /** @type {TEndpoint | undefined} */
+ const endpointConfig =
+ appConfig.endpoints?.all ??
+ appConfig.endpoints?.[endpoint] ??
+ titleProviderConfig.customEndpointConfig;
+ if (!endpointConfig) {
+ logger.debug(
+ `[api/server/controllers/agents/client.js #titleConvo] No endpoint config for "${endpoint}"`,
+ );
+ }
+
+ if (endpointConfig?.titleConvo === false) {
+ logger.debug(
+ `[api/server/controllers/agents/client.js #titleConvo] Title generation disabled for endpoint "${endpoint}"`,
+ );
+ return;
+ }
+
+ if (endpointConfig?.titleEndpoint && endpointConfig.titleEndpoint !== endpoint) {
+ try {
+ titleProviderConfig = getProviderConfig({
+ provider: endpointConfig.titleEndpoint,
+ appConfig,
+ });
+ endpoint = endpointConfig.titleEndpoint;
+ } catch (error) {
+ logger.warn(
+ `[api/server/controllers/agents/client.js #titleConvo] Error getting title endpoint config for "${endpointConfig.titleEndpoint}", falling back to default`,
+ error,
+ );
+ // Fall back to original provider config
+ endpoint = agent.endpoint;
+ titleProviderConfig = getProviderConfig({ provider: endpoint, appConfig });
+ }
+ }
+
+ if (
+ endpointConfig &&
+ endpointConfig.titleModel &&
+ endpointConfig.titleModel !== Constants.CURRENT_MODEL
+ ) {
+ clientOptions.model = endpointConfig.titleModel;
+ }
+
+ const options = await titleProviderConfig.getOptions({
+ req,
+ endpoint,
+ model_parameters: clientOptions,
+ db: {
+ getUserKey: db.getUserKey,
+ getUserKeyValues: db.getUserKeyValues,
+ },
+ });
+
+ let provider = options.provider ?? titleProviderConfig.overrideProvider ?? agent.provider;
+ if (
+ endpoint === EModelEndpoint.azureOpenAI &&
+ options.llmConfig?.azureOpenAIApiInstanceName == null
+ ) {
+ provider = Providers.OPENAI;
+ } else if (
+ endpoint === EModelEndpoint.azureOpenAI &&
+ options.llmConfig?.azureOpenAIApiInstanceName != null &&
+ provider !== Providers.AZURE
+ ) {
+ provider = Providers.AZURE;
+ }
+
+ /** @type {import('@librechat/agents').ClientOptions} */
+ clientOptions = { ...options.llmConfig };
+ if (options.configOptions) {
+ clientOptions.configuration = options.configOptions;
+ }
+
+ if (clientOptions.maxTokens != null) {
+ delete clientOptions.maxTokens;
+ }
+ if (clientOptions?.modelKwargs?.max_completion_tokens != null) {
+ delete clientOptions.modelKwargs.max_completion_tokens;
+ }
+ if (clientOptions?.modelKwargs?.max_output_tokens != null) {
+ delete clientOptions.modelKwargs.max_output_tokens;
+ }
+
+ /** `omitTitleOptions` drops the Anthropic `clientOptions` carrier (thinking,
+ * streaming, etc.), which would also drop its `defaultHeaders` — preserve the
+ * original `clientOptions` object so gateway/reverse-proxy metadata still
+ * reaches title requests (the proxy may require it for auth/routing). Restore
+ * the SAME object reference, not a copy: the Vertex `createClient` closure from
+ * `getLLMConfig` closes over this object, so `resolveConfigHeaders` must mutate
+ * the very object the client is built from. */
+ const anthropicClientOptions = clientOptions?.clientOptions;
+
+ clientOptions = Object.assign(
+ Object.fromEntries(
+ Object.entries(clientOptions).filter(([key]) => !omitTitleOptions.has(key)),
+ ),
+ );
+
+ if (anthropicClientOptions?.defaultHeaders != null && clientOptions.clientOptions == null) {
+ clientOptions.clientOptions = anthropicClientOptions;
+ }
+
+ if (
+ provider === Providers.GOOGLE &&
+ (endpointConfig?.titleMethod === TitleMethod.FUNCTIONS ||
+ endpointConfig?.titleMethod === TitleMethod.STRUCTURED)
+ ) {
+ clientOptions.json = true;
+ }
+
+ /** Resolve request-based headers across provider-specific header locations:
+ * OpenAI `configuration.defaultHeaders`, Anthropic `clientOptions.defaultHeaders`
+ * (preserved above), and Google `customHeaders`.
+ */
+ resolveConfigHeaders({
+ llmConfig: clientOptions,
+ user: createSafeUser(this.options.req?.user),
+ body: {
+ messageId: this.responseMessageId,
+ conversationId: this.conversationId,
+ parentMessageId: this.parentMessageId,
+ },
+ });
+
+ try {
+ const titleResult = await this.run.generateTitle({
+ provider,
+ clientOptions,
+ inputText: text,
+ contentParts: immediate ? [] : this.contentParts,
+ titleMethod: endpointConfig?.titleMethod,
+ titlePrompt: endpointConfig?.titlePrompt,
+ titlePromptTemplate: endpointConfig?.titlePromptTemplate,
+ chainOptions: {
+ runName: 'TitleRun',
+ signal: abortController.signal,
+ callbacks: [
+ {
+ handleLLMEnd,
+ },
+ ],
+ configurable: {
+ thread_id: this.conversationId,
+ user_id: this.user ?? this.options.req.user?.id,
+ },
+ },
+ });
+
+ const collectedUsage = collectedMetadata.map((item) => {
+ let input_tokens, output_tokens;
+
+ if (item.usage) {
+ input_tokens =
+ item.usage.prompt_tokens || item.usage.input_tokens || item.usage.inputTokens;
+ output_tokens =
+ item.usage.completion_tokens || item.usage.output_tokens || item.usage.outputTokens;
+ } else if (item.tokenUsage) {
+ input_tokens = item.tokenUsage.promptTokens;
+ output_tokens = item.tokenUsage.completionTokens;
+ } else if (item.usage_metadata) {
+ input_tokens = item.usage_metadata.input_tokens;
+ output_tokens = item.usage_metadata.output_tokens;
+ }
+
+ return {
+ input_tokens: input_tokens,
+ output_tokens: output_tokens,
+ };
+ });
+
+ const balanceConfig = getBalanceConfig(appConfig);
+ const transactionsConfig = getTransactionsConfig(appConfig);
+ await this.recordCollectedUsage({
+ collectedUsage,
+ context: 'title',
+ model: clientOptions.model,
+ balance: balanceConfig,
+ transactions: transactionsConfig,
+ messageId: this.responseMessageId,
+ }).catch((err) => {
+ logger.error(
+ '[api/server/controllers/agents/client.js #titleConvo] Error recording collected usage',
+ err,
+ );
+ });
+
+ return sanitizeTitle(titleResult.title);
+ } catch (err) {
+ logger.error('[api/server/controllers/agents/client.js #titleConvo] Error', err);
+ return;
+ }
+ }
+
+ /**
+ * @param {object} params
+ * @param {number} params.promptTokens
+ * @param {number} params.completionTokens
+ * @param {string} [params.model]
+ * @param {OpenAIUsageMetadata} [params.usage]
+ * @param {AppConfig['balance']} [params.balance]
+ * @param {string} [params.context='message']
+ * @returns {Promise}
+ */
+ async recordTokenUsage({
+ model,
+ usage,
+ balance,
+ promptTokens,
+ completionTokens,
+ context = 'message',
+ }) {
+ try {
+ await db.spendTokens(
+ {
+ model,
+ context,
+ balance,
+ messageId: this.responseMessageId,
+ conversationId: this.conversationId,
+ user: this.user ?? this.options.req.user?.id,
+ endpointTokenConfig: this.options.endpointTokenConfig,
+ },
+ { promptTokens, completionTokens },
+ );
+
+ if (
+ usage &&
+ typeof usage === 'object' &&
+ 'reasoning_tokens' in usage &&
+ typeof usage.reasoning_tokens === 'number'
+ ) {
+ await db.spendTokens(
+ {
+ model,
+ balance,
+ context: 'reasoning',
+ messageId: this.responseMessageId,
+ conversationId: this.conversationId,
+ user: this.user ?? this.options.req.user?.id,
+ endpointTokenConfig: this.options.endpointTokenConfig,
+ },
+ { completionTokens: usage.reasoning_tokens },
+ );
+ }
+ } catch (error) {
+ logger.error(
+ '[api/server/controllers/agents/client.js #recordTokenUsage] Error recording token usage',
+ error,
+ );
+ }
+ }
+
+ /** Anthropic Claude models use a distinct BPE tokenizer; all others default to o200k_base. */
+ getEncoding() {
+ if (this.model && this.model.toLowerCase().includes('claude')) {
+ return 'claude';
+ }
+ return 'o200k_base';
+ }
+}
+
+module.exports = AgentClient;
diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js
new file mode 100644
index 0000000..1646a44
--- /dev/null
+++ b/api/server/controllers/agents/client.test.js
@@ -0,0 +1,3122 @@
+const { Providers } = require('@librechat/agents');
+const { Constants, ContentTypes, EModelEndpoint } = require('librechat-data-provider');
+const AgentClient = require('./client');
+
+jest.mock('@librechat/agents', () => ({
+ ...jest.requireActual('@librechat/agents'),
+ createMetadataAggregator: () => ({
+ handleLLMEnd: jest.fn(),
+ collected: [],
+ }),
+}));
+
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ checkAccess: jest.fn(),
+ countFormattedMessageTokens: jest.fn(() => 42),
+ countTokens: jest.fn((text) => Math.ceil(String(text ?? '').length / 4)),
+ initializeAgent: jest.fn(),
+ createMemoryProcessor: jest.fn(),
+ isMemoryAgentEnabled: jest.fn((config) => {
+ if (!config || config.disabled === true) return false;
+ const agent = config.agent;
+ if (agent?.enabled !== true) return false;
+ return Boolean(agent.id || (agent.provider && agent.model));
+ }),
+ loadAgent: jest.fn(),
+}));
+
+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(),
+ getFormattedMemories: jest.fn(),
+}));
+
+// Mock getMCPManager
+const mockFormatInstructions = jest.fn();
+jest.mock('~/config', () => ({
+ getMCPManager: jest.fn(() => ({
+ formatInstructionsForContext: mockFormatInstructions,
+ })),
+}));
+
+describe('AgentClient - applyHideSequentialOutputsFilter', () => {
+ const textPart = (text) => ({ type: ContentTypes.TEXT, text });
+ const toolCallPart = (id) => ({ type: ContentTypes.TOOL_CALL, tool_call: { id } });
+
+ it('keeps only the last part + tool_call parts when hide_sequential_outputs is on', () => {
+ const ctx = {
+ options: { agent: { hide_sequential_outputs: true } },
+ contentParts: [
+ textPart('intermediate'),
+ toolCallPart('tc1'),
+ textPart('reasoning'),
+ textPart('final'),
+ ],
+ };
+ AgentClient.prototype.applyHideSequentialOutputsFilter.call(ctx);
+ expect(ctx.contentParts).toEqual([toolCallPart('tc1'), textPart('final')]);
+ });
+
+ it('is a no-op when hide_sequential_outputs is off', () => {
+ const parts = [textPart('a'), textPart('b')];
+ const ctx = { options: { agent: { hide_sequential_outputs: false } }, contentParts: parts };
+ AgentClient.prototype.applyHideSequentialOutputsFilter.call(ctx);
+ expect(ctx.contentParts).toEqual([textPart('a'), textPart('b')]);
+ });
+});
+
+describe('AgentClient - titleConvo', () => {
+ let client;
+ let mockRun;
+ let mockReq;
+ let mockRes;
+ let mockAgent;
+ let mockOptions;
+
+ beforeEach(() => {
+ // Reset all mocks
+ jest.clearAllMocks();
+
+ // Mock run object
+ mockRun = {
+ generateTitle: jest.fn().mockResolvedValue({
+ title: 'Generated Title',
+ }),
+ };
+
+ // Mock agent - with both endpoint and provider
+ mockAgent = {
+ id: 'agent-123',
+ endpoint: EModelEndpoint.openAI, // Use a valid provider as endpoint for getProviderConfig
+ provider: EModelEndpoint.openAI, // Add provider property
+ model_parameters: {
+ model: 'gpt-4',
+ },
+ };
+
+ // Mock request and response
+ mockReq = {
+ user: {
+ id: 'user-123',
+ },
+ body: {
+ model: 'gpt-4',
+ endpoint: EModelEndpoint.openAI,
+ key: null,
+ },
+ config: {
+ endpoints: {
+ [EModelEndpoint.openAI]: {
+ // Match the agent endpoint
+ titleModel: 'gpt-3.5-turbo',
+ titlePrompt: 'Custom title prompt',
+ titleMethod: 'structured',
+ titlePromptTemplate: 'Template: {{content}}',
+ },
+ },
+ },
+ };
+
+ mockRes = {};
+
+ // Mock options
+ mockOptions = {
+ req: mockReq,
+ res: mockRes,
+ agent: mockAgent,
+ endpointTokenConfig: {},
+ };
+
+ // Create client instance
+ client = new AgentClient(mockOptions);
+ client.run = mockRun;
+ client.responseMessageId = 'response-123';
+ client.conversationId = 'convo-123';
+ client.contentParts = [{ type: 'text', text: 'Test content' }];
+ client.recordCollectedUsage = jest.fn().mockResolvedValue(); // Mock as async function that resolves
+ });
+
+ describe('titleConvo method', () => {
+ it('should throw error if run is not initialized', async () => {
+ client.run = null;
+
+ await expect(
+ client.titleConvo({ text: 'Test', abortController: new AbortController() }),
+ ).rejects.toThrow('Run not initialized');
+ });
+
+ it('waits for the run in immediate mode instead of throwing', async () => {
+ client.run = null;
+ const abortController = new AbortController();
+
+ const titlePromise = client.titleConvo({ text: 'Test', abortController, immediate: true });
+
+ // Simulate `chatCompletion` assigning the run (client.js: `this.run = run`).
+ client.run = mockRun;
+ client._resolveRun(mockRun);
+
+ await titlePromise;
+ expect(mockRun.generateTitle).toHaveBeenCalled();
+ });
+
+ it('passes empty contentParts in immediate mode (title from the user input only)', async () => {
+ client.contentParts = [{ type: 'text', text: 'Streaming response so far' }];
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text: 'Hello there', abortController, immediate: true });
+
+ const call = mockRun.generateTitle.mock.calls[0][0];
+ expect(call.contentParts).toEqual([]);
+ expect(call.inputText).toBe('Hello there');
+ });
+
+ it('uses live contentParts in non-immediate (final) mode', async () => {
+ client.contentParts = [{ type: 'text', text: 'Full response' }];
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text: 'Hello there', abortController });
+
+ const call = mockRun.generateTitle.mock.calls[0][0];
+ expect(call.contentParts).toEqual([{ type: 'text', text: 'Full response' }]);
+ });
+
+ it('rejects promptly when aborted before the run initializes in immediate mode', async () => {
+ client.run = null;
+ const abortController = new AbortController();
+ abortController.abort();
+
+ await expect(
+ client.titleConvo({ text: 'Test', abortController, immediate: true }),
+ ).rejects.toThrow('Aborted before run initialization');
+ expect(mockRun.generateTitle).not.toHaveBeenCalled();
+ });
+
+ it('should use titlePrompt from endpoint config', async () => {
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ titlePrompt: 'Custom title prompt',
+ }),
+ );
+ });
+
+ it('should use titlePromptTemplate from endpoint config', async () => {
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ titlePromptTemplate: 'Template: {{content}}',
+ }),
+ );
+ });
+
+ it('should use titleMethod from endpoint config', async () => {
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: Providers.OPENAI,
+ titleMethod: 'structured',
+ }),
+ );
+ });
+
+ it('should use titleModel from endpoint config when provided', async () => {
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Check that generateTitle was called with correct clientOptions
+ const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
+ expect(generateTitleCall.clientOptions.model).toBe('gpt-3.5-turbo');
+ });
+
+ it('preserves Anthropic custom headers on title requests despite omitTitleOptions', async () => {
+ const prevKey = process.env.ANTHROPIC_API_KEY;
+ process.env.ANTHROPIC_API_KEY = 'sk-ant-test';
+ try {
+ const req = {
+ user: { id: 'user-123' },
+ body: { model: 'claude-sonnet-4-5', endpoint: EModelEndpoint.anthropic, key: null },
+ config: {
+ endpoints: {
+ [EModelEndpoint.anthropic]: {
+ headers: { 'X-Conversation-Id': '{{LIBRECHAT_BODY_CONVERSATIONID}}' },
+ },
+ },
+ },
+ };
+ const agent = {
+ id: 'agent-anthropic',
+ endpoint: EModelEndpoint.anthropic,
+ provider: EModelEndpoint.anthropic,
+ model_parameters: { model: 'claude-sonnet-4-5' },
+ };
+ const anthropicClient = new AgentClient({ req, res: {}, agent, endpointTokenConfig: {} });
+ anthropicClient.run = mockRun;
+ anthropicClient.responseMessageId = 'response-123';
+ anthropicClient.conversationId = 'convo-123';
+ anthropicClient.contentParts = [{ type: 'text', text: 'Test content' }];
+ anthropicClient.recordCollectedUsage = jest.fn().mockResolvedValue();
+
+ await anthropicClient.titleConvo({ text: 'Hello', abortController: new AbortController() });
+
+ const defaultHeaders =
+ mockRun.generateTitle.mock.calls[0][0].clientOptions?.clientOptions?.defaultHeaders;
+ // Custom header survives the `omitTitleOptions` strip and resolves the conversationId
+ expect(defaultHeaders?.['X-Conversation-Id']).toBe('convo-123');
+ // Provider-managed beta header is preserved alongside it
+ expect(defaultHeaders?.['anthropic-beta']).toBeDefined();
+ } finally {
+ if (prevKey === undefined) {
+ delete process.env.ANTHROPIC_API_KEY;
+ } else {
+ process.env.ANTHROPIC_API_KEY = prevKey;
+ }
+ }
+ });
+
+ it('should handle missing endpoint config gracefully', async () => {
+ // Remove endpoint config
+ mockReq.config = { endpoints: {} };
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ titlePrompt: undefined,
+ titlePromptTemplate: undefined,
+ titleMethod: undefined,
+ }),
+ );
+ });
+
+ it('should use agent model when titleModel is not provided', async () => {
+ // Remove titleModel from config
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.openAI]: {
+ titlePrompt: 'Custom title prompt',
+ titleMethod: 'structured',
+ titlePromptTemplate: 'Template: {{content}}',
+ // titleModel is omitted
+ },
+ },
+ };
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
+ expect(generateTitleCall.clientOptions.model).toBe('gpt-4'); // Should use agent's model
+ });
+
+ it('should not use titleModel when it equals CURRENT_MODEL constant', async () => {
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.openAI]: {
+ titleModel: Constants.CURRENT_MODEL,
+ titlePrompt: 'Custom title prompt',
+ titleMethod: 'structured',
+ titlePromptTemplate: 'Template: {{content}}',
+ },
+ },
+ };
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
+ expect(generateTitleCall.clientOptions.model).toBe('gpt-4'); // Should use agent's model
+ });
+
+ it('should pass all required parameters to generateTitle', async () => {
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ expect(mockRun.generateTitle).toHaveBeenCalledWith({
+ provider: expect.any(String),
+ inputText: text,
+ contentParts: client.contentParts,
+ clientOptions: expect.objectContaining({
+ model: 'gpt-3.5-turbo',
+ }),
+ titlePrompt: 'Custom title prompt',
+ titlePromptTemplate: 'Template: {{content}}',
+ titleMethod: 'structured',
+ chainOptions: expect.objectContaining({
+ signal: abortController.signal,
+ }),
+ });
+ });
+
+ it('should record collected usage after title generation', async () => {
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ expect(client.recordCollectedUsage).toHaveBeenCalledWith({
+ model: 'gpt-3.5-turbo',
+ context: 'title',
+ collectedUsage: expect.any(Array),
+ balance: {
+ enabled: false,
+ },
+ transactions: {
+ enabled: true,
+ },
+ messageId: 'response-123',
+ });
+ });
+
+ it('should return the generated title', async () => {
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ const result = await client.titleConvo({ text, abortController });
+
+ expect(result).toBe('Generated Title');
+ });
+
+ it('should sanitize the generated title by removing think blocks', async () => {
+ const titleWithThinkBlock = 'reasoning about the title User Hi Greeting';
+ mockRun.generateTitle.mockResolvedValue({
+ title: titleWithThinkBlock,
+ });
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ const result = await client.titleConvo({ text, abortController });
+
+ // Should remove the block and return only the clean title
+ expect(result).toBe('User Hi Greeting');
+ expect(result).not.toContain('');
+ expect(result).not.toContain(' ');
+ });
+
+ it('should return fallback title when sanitization results in empty string', async () => {
+ const titleOnlyThinkBlock = 'only reasoning no actual title ';
+ mockRun.generateTitle.mockResolvedValue({
+ title: titleOnlyThinkBlock,
+ });
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ const result = await client.titleConvo({ text, abortController });
+
+ // Should return the fallback title since sanitization would result in empty string
+ expect(result).toBe('Untitled Conversation');
+ });
+
+ it('should handle errors gracefully and return undefined', async () => {
+ mockRun.generateTitle.mockRejectedValue(new Error('Title generation failed'));
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ const result = await client.titleConvo({ text, abortController });
+
+ expect(result).toBeUndefined();
+ });
+
+ it('should skip title generation when titleConvo is set to false', async () => {
+ // Set titleConvo to false in endpoint config
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.openAI]: {
+ titleConvo: false,
+ titleModel: 'gpt-3.5-turbo',
+ titlePrompt: 'Custom title prompt',
+ titleMethod: 'structured',
+ titlePromptTemplate: 'Template: {{content}}',
+ },
+ },
+ };
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ const result = await client.titleConvo({ text, abortController });
+
+ // Should return undefined without generating title
+ expect(result).toBeUndefined();
+
+ // generateTitle should NOT have been called
+ expect(mockRun.generateTitle).not.toHaveBeenCalled();
+
+ // recordCollectedUsage should NOT have been called
+ expect(client.recordCollectedUsage).not.toHaveBeenCalled();
+ });
+
+ it('should skip title generation for temporary chats', async () => {
+ // Set isTemporary to true
+ mockReq.body.isTemporary = true;
+
+ const text = 'Test temporary chat';
+ const abortController = new AbortController();
+
+ const result = await client.titleConvo({ text, abortController });
+
+ // Should return undefined without generating title
+ expect(result).toBeUndefined();
+
+ // generateTitle should NOT have been called
+ expect(mockRun.generateTitle).not.toHaveBeenCalled();
+
+ // recordCollectedUsage should NOT have been called
+ expect(client.recordCollectedUsage).not.toHaveBeenCalled();
+ });
+
+ it('should skip title generation when titleConvo is false in all config', async () => {
+ // Set titleConvo to false in "all" config
+ mockReq.config = {
+ endpoints: {
+ all: {
+ titleConvo: false,
+ titleModel: 'gpt-4o-mini',
+ titlePrompt: 'All config title prompt',
+ titleMethod: 'completion',
+ titlePromptTemplate: 'All config template',
+ },
+ },
+ };
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ const result = await client.titleConvo({ text, abortController });
+
+ // Should return undefined without generating title
+ expect(result).toBeUndefined();
+
+ // generateTitle should NOT have been called
+ expect(mockRun.generateTitle).not.toHaveBeenCalled();
+
+ // recordCollectedUsage should NOT have been called
+ expect(client.recordCollectedUsage).not.toHaveBeenCalled();
+ });
+
+ it('should skip title generation when titleConvo is false for custom endpoint scenario', async () => {
+ // This test validates the behavior when customEndpointConfig (retrieved via
+ // getProviderConfig for custom endpoints) has titleConvo: false.
+ //
+ // The code path is:
+ // 1. endpoints?.all is checked (undefined in this test)
+ // 2. endpoints?.[endpoint] is checked (our test config)
+ // 3. Would fall back to titleProviderConfig.customEndpointConfig (for real custom endpoints)
+ //
+ // We simulate a custom endpoint scenario using a dynamically named endpoint config
+
+ // Create a unique endpoint name that represents a custom endpoint
+ const customEndpointName = 'customEndpoint';
+
+ // Configure the endpoint to have titleConvo: false
+ // This simulates what would be in customEndpointConfig for a real custom endpoint
+ mockReq.config = {
+ endpoints: {
+ // No 'all' config - so it will check endpoints[endpoint]
+ // This config represents what customEndpointConfig would contain
+ [customEndpointName]: {
+ titleConvo: false,
+ titleModel: 'custom-model-v1',
+ titlePrompt: 'Custom endpoint title prompt',
+ titleMethod: 'completion',
+ titlePromptTemplate: 'Custom template: {{content}}',
+ baseURL: 'https://api.custom-llm.com/v1',
+ apiKey: 'test-custom-key',
+ // Additional custom endpoint properties
+ models: {
+ default: ['custom-model-v1', 'custom-model-v2'],
+ },
+ },
+ },
+ };
+
+ // Set up agent to use our custom endpoint
+ // Use openAI as base but override with custom endpoint name for this test
+ mockAgent.endpoint = EModelEndpoint.openAI;
+ mockAgent.provider = EModelEndpoint.openAI;
+
+ // Override the endpoint in the config to point to our custom config
+ mockReq.config.endpoints[EModelEndpoint.openAI] =
+ mockReq.config.endpoints[customEndpointName];
+ delete mockReq.config.endpoints[customEndpointName];
+
+ const text = 'Test custom endpoint conversation';
+ const abortController = new AbortController();
+
+ const result = await client.titleConvo({ text, abortController });
+
+ // Should return undefined without generating title because titleConvo is false
+ expect(result).toBeUndefined();
+
+ // generateTitle should NOT have been called
+ expect(mockRun.generateTitle).not.toHaveBeenCalled();
+
+ // recordCollectedUsage should NOT have been called
+ expect(client.recordCollectedUsage).not.toHaveBeenCalled();
+ });
+
+ it('should pass titleEndpoint configuration to generateTitle', async () => {
+ // Mock the API key just for this test
+ const originalApiKey = process.env.ANTHROPIC_API_KEY;
+ process.env.ANTHROPIC_API_KEY = 'test-api-key';
+
+ // Add titleEndpoint to the config
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.openAI]: {
+ titleModel: 'gpt-3.5-turbo',
+ titleEndpoint: EModelEndpoint.anthropic,
+ titleMethod: 'structured',
+ titlePrompt: 'Custom title prompt',
+ titlePromptTemplate: 'Custom template',
+ },
+ },
+ };
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify generateTitle was called with the custom configuration
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ titleMethod: 'structured',
+ provider: Providers.ANTHROPIC,
+ titlePrompt: 'Custom title prompt',
+ titlePromptTemplate: 'Custom template',
+ }),
+ );
+
+ // Restore the original API key
+ if (originalApiKey) {
+ process.env.ANTHROPIC_API_KEY = originalApiKey;
+ } else {
+ delete process.env.ANTHROPIC_API_KEY;
+ }
+ });
+
+ it('should use all config when endpoint config is missing', async () => {
+ // Set 'all' config without endpoint-specific config
+ mockReq.config = {
+ endpoints: {
+ all: {
+ titleModel: 'gpt-4o-mini',
+ titlePrompt: 'All config title prompt',
+ titleMethod: 'completion',
+ titlePromptTemplate: 'All config template: {{content}}',
+ },
+ },
+ };
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify generateTitle was called with 'all' config values
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ titleMethod: 'completion',
+ titlePrompt: 'All config title prompt',
+ titlePromptTemplate: 'All config template: {{content}}',
+ }),
+ );
+
+ // Check that the model was set from 'all' config
+ const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
+ expect(generateTitleCall.clientOptions.model).toBe('gpt-4o-mini');
+ });
+
+ it('should prioritize all config over endpoint config for title settings', async () => {
+ // Set both endpoint and 'all' config
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.openAI]: {
+ titleModel: 'gpt-3.5-turbo',
+ titlePrompt: 'Endpoint title prompt',
+ titleMethod: 'structured',
+ // titlePromptTemplate is omitted to test fallback
+ },
+ all: {
+ titleModel: 'gpt-4o-mini',
+ titlePrompt: 'All config title prompt',
+ titleMethod: 'completion',
+ titlePromptTemplate: 'All config template',
+ },
+ },
+ };
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify 'all' config takes precedence over endpoint config
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ titleMethod: 'completion',
+ titlePrompt: 'All config title prompt',
+ titlePromptTemplate: 'All config template',
+ }),
+ );
+
+ // Check that the model was set from 'all' config
+ const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
+ expect(generateTitleCall.clientOptions.model).toBe('gpt-4o-mini');
+ });
+
+ it('should use all config with titleEndpoint and verify provider switch', async () => {
+ // Mock the API key for the titleEndpoint provider
+ const originalApiKey = process.env.ANTHROPIC_API_KEY;
+ process.env.ANTHROPIC_API_KEY = 'test-anthropic-key';
+
+ // Set comprehensive 'all' config with all new title options
+ mockReq.config = {
+ endpoints: {
+ all: {
+ titleConvo: true,
+ titleModel: 'claude-3-haiku-20240307',
+ titleMethod: 'completion', // Testing the new default method
+ titlePrompt: 'Generate a concise, descriptive title for this conversation',
+ titlePromptTemplate: 'Conversation summary: {{content}}',
+ titleEndpoint: EModelEndpoint.anthropic, // Should switch provider to Anthropic
+ },
+ },
+ };
+
+ const text = 'Test conversation about AI and machine learning';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify all config values were used
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: Providers.ANTHROPIC, // Critical: Verify provider switched to Anthropic
+ titleMethod: 'completion',
+ titlePrompt: 'Generate a concise, descriptive title for this conversation',
+ titlePromptTemplate: 'Conversation summary: {{content}}',
+ inputText: text,
+ contentParts: client.contentParts,
+ }),
+ );
+
+ // Verify the model was set from 'all' config
+ const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
+ expect(generateTitleCall.clientOptions.model).toBe('claude-3-haiku-20240307');
+
+ // Verify other client options are set correctly
+ expect(generateTitleCall.clientOptions).toMatchObject({
+ model: 'claude-3-haiku-20240307',
+ // Note: Anthropic's getOptions may set its own maxTokens value
+ });
+
+ // Restore the original API key
+ if (originalApiKey) {
+ process.env.ANTHROPIC_API_KEY = originalApiKey;
+ } else {
+ delete process.env.ANTHROPIC_API_KEY;
+ }
+ });
+
+ it('should test all titleMethod options from all config', async () => {
+ // Test each titleMethod: 'completion', 'functions', 'structured'
+ const titleMethods = ['completion', 'functions', 'structured'];
+
+ for (const method of titleMethods) {
+ // Clear previous calls
+ mockRun.generateTitle.mockClear();
+
+ // Set 'all' config with specific titleMethod
+ mockReq.config = {
+ endpoints: {
+ all: {
+ titleModel: 'gpt-4o-mini',
+ titleMethod: method,
+ titlePrompt: `Testing ${method} method`,
+ titlePromptTemplate: `Template for ${method}: {{content}}`,
+ },
+ },
+ };
+
+ const text = `Test conversation for ${method} method`;
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify the correct titleMethod was used
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ titleMethod: method,
+ titlePrompt: `Testing ${method} method`,
+ titlePromptTemplate: `Template for ${method}: {{content}}`,
+ }),
+ );
+ }
+ });
+
+ describe('Azure-specific title generation', () => {
+ let originalEnv;
+
+ beforeEach(() => {
+ // Reset mocks
+ jest.clearAllMocks();
+
+ // Save original environment variables
+ originalEnv = { ...process.env };
+
+ // Mock Azure API keys
+ process.env.AZURE_OPENAI_API_KEY = 'test-azure-key';
+ process.env.AZURE_API_KEY = 'test-azure-key';
+ process.env.EASTUS_API_KEY = 'test-eastus-key';
+ process.env.EASTUS2_API_KEY = 'test-eastus2-key';
+ });
+
+ afterEach(() => {
+ // Restore environment variables
+ process.env = originalEnv;
+ });
+
+ it('should use OPENAI provider for Azure serverless endpoints', async () => {
+ // Set up Azure endpoint with serverless config
+ mockAgent.endpoint = EModelEndpoint.azureOpenAI;
+ mockAgent.provider = EModelEndpoint.azureOpenAI;
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.azureOpenAI]: {
+ titleConvo: true,
+ titleModel: 'grok-3',
+ titleMethod: 'completion',
+ titlePrompt: 'Azure serverless title prompt',
+ streamRate: 35,
+ modelGroupMap: {
+ 'grok-3': {
+ group: 'Azure AI Foundry',
+ deploymentName: 'grok-3',
+ },
+ },
+ groupMap: {
+ 'Azure AI Foundry': {
+ apiKey: '${AZURE_API_KEY}',
+ baseURL: 'https://test.services.ai.azure.com/models',
+ version: '2024-05-01-preview',
+ serverless: true,
+ models: {
+ 'grok-3': {
+ deploymentName: 'grok-3',
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+ mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
+ mockReq.body.model = 'grok-3';
+
+ const text = 'Test Azure serverless conversation';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify provider was switched to OPENAI for serverless
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: Providers.OPENAI, // Should be OPENAI for serverless
+ titleMethod: 'completion',
+ titlePrompt: 'Azure serverless title prompt',
+ }),
+ );
+ });
+
+ it('should use AZURE provider for Azure endpoints with instanceName', async () => {
+ // Set up Azure endpoint
+ mockAgent.endpoint = EModelEndpoint.azureOpenAI;
+ mockAgent.provider = EModelEndpoint.azureOpenAI;
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.azureOpenAI]: {
+ titleConvo: true,
+ titleModel: 'gpt-4o',
+ titleMethod: 'structured',
+ titlePrompt: 'Azure instance title prompt',
+ streamRate: 35,
+ modelGroupMap: {
+ 'gpt-4o': {
+ group: 'eastus',
+ deploymentName: 'gpt-4o',
+ },
+ },
+ groupMap: {
+ eastus: {
+ apiKey: '${EASTUS_API_KEY}',
+ instanceName: 'region-instance',
+ version: '2024-02-15-preview',
+ models: {
+ 'gpt-4o': {
+ deploymentName: 'gpt-4o',
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+ mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
+ mockReq.body.model = 'gpt-4o';
+
+ const text = 'Test Azure instance conversation';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify provider remains AZURE with instanceName
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: Providers.AZURE,
+ titleMethod: 'structured',
+ titlePrompt: 'Azure instance title prompt',
+ }),
+ );
+ });
+
+ it('should handle Azure titleModel with CURRENT_MODEL constant', async () => {
+ // Set up Azure endpoint
+ mockAgent.endpoint = EModelEndpoint.azureOpenAI;
+ mockAgent.provider = EModelEndpoint.azureOpenAI;
+ mockAgent.model_parameters.model = 'gpt-4o-latest';
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.azureOpenAI]: {
+ titleConvo: true,
+ titleModel: Constants.CURRENT_MODEL,
+ titleMethod: 'functions',
+ streamRate: 35,
+ modelGroupMap: {
+ 'gpt-4o-latest': {
+ group: 'region-eastus',
+ deploymentName: 'gpt-4o-mini',
+ version: '2024-02-15-preview',
+ },
+ },
+ groupMap: {
+ 'region-eastus': {
+ apiKey: '${EASTUS2_API_KEY}',
+ instanceName: 'test-instance',
+ version: '2024-12-01-preview',
+ models: {
+ 'gpt-4o-latest': {
+ deploymentName: 'gpt-4o-mini',
+ version: '2024-02-15-preview',
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+ mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
+ mockReq.body.model = 'gpt-4o-latest';
+
+ const text = 'Test Azure current model';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify it uses the correct model when titleModel is CURRENT_MODEL
+ const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
+ // When CURRENT_MODEL is used with Azure, the model gets mapped to the deployment name
+ // In this case, 'gpt-4o-latest' is mapped to 'gpt-4o-mini' deployment
+ expect(generateTitleCall.clientOptions.model).toBe('gpt-4o-mini');
+ // Also verify that CURRENT_MODEL constant was not passed as the model
+ expect(generateTitleCall.clientOptions.model).not.toBe(Constants.CURRENT_MODEL);
+ });
+
+ it('should handle Azure with multiple model groups', async () => {
+ // Set up Azure endpoint
+ mockAgent.endpoint = EModelEndpoint.azureOpenAI;
+ mockAgent.provider = EModelEndpoint.azureOpenAI;
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.azureOpenAI]: {
+ titleConvo: true,
+ titleModel: 'o1-mini',
+ titleMethod: 'completion',
+ streamRate: 35,
+ modelGroupMap: {
+ 'gpt-4o': {
+ group: 'eastus',
+ deploymentName: 'gpt-4o',
+ },
+ 'o1-mini': {
+ group: 'region-eastus',
+ deploymentName: 'o1-mini',
+ },
+ 'codex-mini': {
+ group: 'codex-mini',
+ deploymentName: 'codex-mini',
+ },
+ },
+ groupMap: {
+ eastus: {
+ apiKey: '${EASTUS_API_KEY}',
+ instanceName: 'region-eastus',
+ version: '2024-02-15-preview',
+ models: {
+ 'gpt-4o': {
+ deploymentName: 'gpt-4o',
+ },
+ },
+ },
+ 'region-eastus': {
+ apiKey: '${EASTUS2_API_KEY}',
+ instanceName: 'region-eastus2',
+ version: '2024-12-01-preview',
+ models: {
+ 'o1-mini': {
+ deploymentName: 'o1-mini',
+ },
+ },
+ },
+ 'codex-mini': {
+ apiKey: '${AZURE_API_KEY}',
+ baseURL: 'https://example.cognitiveservices.azure.com/openai/',
+ version: '2025-04-01-preview',
+ serverless: true,
+ models: {
+ 'codex-mini': {
+ deploymentName: 'codex-mini',
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+ mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
+ mockReq.body.model = 'o1-mini';
+
+ const text = 'Test Azure multi-group conversation';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify correct model and provider are used
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: Providers.AZURE,
+ titleMethod: 'completion',
+ }),
+ );
+
+ const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
+ expect(generateTitleCall.clientOptions.model).toBe('o1-mini');
+ expect(generateTitleCall.clientOptions.maxTokens).toBeUndefined(); // o1 models shouldn't have maxTokens
+ });
+
+ it('should use all config as fallback for Azure endpoints', async () => {
+ // Set up Azure endpoint with minimal config
+ mockAgent.endpoint = EModelEndpoint.azureOpenAI;
+ mockAgent.provider = EModelEndpoint.azureOpenAI;
+ mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
+ mockReq.body.model = 'gpt-4';
+
+ // Set 'all' config as fallback with a serverless Azure config
+ mockReq.config = {
+ endpoints: {
+ all: {
+ titleConvo: true,
+ titleModel: 'gpt-4',
+ titleMethod: 'structured',
+ titlePrompt: 'Fallback title prompt from all config',
+ titlePromptTemplate: 'Template: {{content}}',
+ modelGroupMap: {
+ 'gpt-4': {
+ group: 'default-group',
+ deploymentName: 'gpt-4',
+ },
+ },
+ groupMap: {
+ 'default-group': {
+ apiKey: '${AZURE_API_KEY}',
+ baseURL: 'https://default.openai.azure.com/',
+ version: '2024-02-15-preview',
+ serverless: true,
+ models: {
+ 'gpt-4': {
+ deploymentName: 'gpt-4',
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+
+ const text = 'Test Azure with all config fallback';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify all config is used
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: Providers.OPENAI, // Should be OPENAI when no instanceName
+ titleMethod: 'structured',
+ titlePrompt: 'Fallback title prompt from all config',
+ titlePromptTemplate: 'Template: {{content}}',
+ }),
+ );
+ });
+ });
+ });
+
+ describe('getOptions method - GPT-5+ model handling', () => {
+ let mockReq;
+ let mockRes;
+ let mockAgent;
+ let mockOptions;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockAgent = {
+ id: 'agent-123',
+ endpoint: EModelEndpoint.openAI,
+ provider: EModelEndpoint.openAI,
+ model_parameters: {
+ model: 'gpt-5',
+ },
+ };
+
+ mockReq = {
+ app: {
+ locals: {},
+ },
+ user: {
+ id: 'user-123',
+ },
+ };
+
+ mockRes = {};
+
+ mockOptions = {
+ req: mockReq,
+ res: mockRes,
+ agent: mockAgent,
+ };
+
+ client = new AgentClient(mockOptions);
+ });
+
+ it('should move maxTokens to modelKwargs.max_completion_tokens for GPT-5 models', () => {
+ const clientOptions = {
+ model: 'gpt-5',
+ maxTokens: 2048,
+ temperature: 0.7,
+ };
+
+ // Simulate the getOptions logic that handles GPT-5+ models
+ if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
+ clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
+ clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
+ delete clientOptions.maxTokens;
+ }
+
+ expect(clientOptions.maxTokens).toBeUndefined();
+ expect(clientOptions.modelKwargs).toBeDefined();
+ expect(clientOptions.modelKwargs.max_completion_tokens).toBe(2048);
+ expect(clientOptions.temperature).toBe(0.7); // Other options should remain
+ });
+
+ it('should move maxTokens to modelKwargs.max_output_tokens for GPT-5 models with useResponsesApi', () => {
+ const clientOptions = {
+ model: 'gpt-5',
+ maxTokens: 2048,
+ temperature: 0.7,
+ useResponsesApi: true,
+ };
+
+ if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
+ clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
+ const paramName =
+ clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
+ clientOptions.modelKwargs[paramName] = clientOptions.maxTokens;
+ delete clientOptions.maxTokens;
+ }
+
+ expect(clientOptions.maxTokens).toBeUndefined();
+ expect(clientOptions.modelKwargs).toBeDefined();
+ expect(clientOptions.modelKwargs.max_output_tokens).toBe(2048);
+ expect(clientOptions.temperature).toBe(0.7); // Other options should remain
+ });
+
+ it('should handle GPT-5+ models with existing modelKwargs', () => {
+ const clientOptions = {
+ model: 'gpt-6',
+ maxTokens: 1500,
+ temperature: 0.8,
+ modelKwargs: {
+ customParam: 'value',
+ },
+ };
+
+ // Simulate the getOptions logic
+ if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
+ clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
+ clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
+ delete clientOptions.maxTokens;
+ }
+
+ expect(clientOptions.maxTokens).toBeUndefined();
+ expect(clientOptions.modelKwargs).toEqual({
+ customParam: 'value',
+ max_completion_tokens: 1500,
+ });
+ });
+
+ it('should not modify maxTokens for non-GPT-5+ models', () => {
+ const clientOptions = {
+ model: 'gpt-4',
+ maxTokens: 2048,
+ temperature: 0.7,
+ };
+
+ // Simulate the getOptions logic
+ if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
+ clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
+ clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
+ delete clientOptions.maxTokens;
+ }
+
+ // Should not be modified since it's GPT-4
+ expect(clientOptions.maxTokens).toBe(2048);
+ expect(clientOptions.modelKwargs).toBeUndefined();
+ });
+
+ it('should handle various GPT-5+ model formats', () => {
+ const testCases = [
+ { model: 'gpt-5.1', shouldTransform: true },
+ { model: 'gpt-5.1-chat-latest', shouldTransform: true },
+ { model: 'gpt-5.1-codex', shouldTransform: true },
+ { model: 'gpt-5', shouldTransform: true },
+ { model: 'gpt-5-turbo', shouldTransform: true },
+ { model: 'gpt-6', shouldTransform: true },
+ { model: 'gpt-7-preview', shouldTransform: true },
+ { model: 'gpt-8', shouldTransform: true },
+ { model: 'gpt-9-mini', shouldTransform: true },
+ { model: 'gpt-4', shouldTransform: false },
+ { model: 'gpt-4o', shouldTransform: false },
+ { model: 'gpt-3.5-turbo', shouldTransform: false },
+ { model: 'claude-3', shouldTransform: false },
+ ];
+
+ testCases.forEach(({ model, shouldTransform }) => {
+ const clientOptions = {
+ model,
+ maxTokens: 1000,
+ };
+
+ // Simulate the getOptions logic
+ if (
+ /\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
+ clientOptions.maxTokens != null
+ ) {
+ clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
+ clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
+ delete clientOptions.maxTokens;
+ }
+
+ if (shouldTransform) {
+ expect(clientOptions.maxTokens).toBeUndefined();
+ expect(clientOptions.modelKwargs?.max_completion_tokens).toBe(1000);
+ } else {
+ expect(clientOptions.maxTokens).toBe(1000);
+ expect(clientOptions.modelKwargs).toBeUndefined();
+ }
+ });
+ });
+
+ it('should not swap max token param for older models when using useResponsesApi', () => {
+ const testCases = [
+ { model: 'gpt-5.1', shouldTransform: true },
+ { model: 'gpt-5.1-chat-latest', shouldTransform: true },
+ { model: 'gpt-5.1-codex', shouldTransform: true },
+ { model: 'gpt-5', shouldTransform: true },
+ { model: 'gpt-5-turbo', shouldTransform: true },
+ { model: 'gpt-6', shouldTransform: true },
+ { model: 'gpt-7-preview', shouldTransform: true },
+ { model: 'gpt-8', shouldTransform: true },
+ { model: 'gpt-9-mini', shouldTransform: true },
+ { model: 'gpt-4', shouldTransform: false },
+ { model: 'gpt-4o', shouldTransform: false },
+ { model: 'gpt-3.5-turbo', shouldTransform: false },
+ { model: 'claude-3', shouldTransform: false },
+ ];
+
+ testCases.forEach(({ model, shouldTransform }) => {
+ const clientOptions = {
+ model,
+ maxTokens: 1000,
+ useResponsesApi: true,
+ };
+
+ if (
+ /\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
+ clientOptions.maxTokens != null
+ ) {
+ clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
+ const paramName =
+ clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
+ clientOptions.modelKwargs[paramName] = clientOptions.maxTokens;
+ delete clientOptions.maxTokens;
+ }
+
+ if (shouldTransform) {
+ expect(clientOptions.maxTokens).toBeUndefined();
+ expect(clientOptions.modelKwargs?.max_output_tokens).toBe(1000);
+ } else {
+ expect(clientOptions.maxTokens).toBe(1000);
+ expect(clientOptions.modelKwargs).toBeUndefined();
+ }
+ });
+ });
+
+ it('should not transform if maxTokens is null or undefined', () => {
+ const testCases = [
+ { model: 'gpt-5', maxTokens: null },
+ { model: 'gpt-5', maxTokens: undefined },
+ { model: 'gpt-6', maxTokens: 0 }, // Should transform even if 0
+ ];
+
+ testCases.forEach(({ model, maxTokens }, index) => {
+ const clientOptions = {
+ model,
+ maxTokens,
+ temperature: 0.7,
+ };
+
+ // Simulate the getOptions logic
+ if (
+ /\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
+ clientOptions.maxTokens != null
+ ) {
+ clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
+ clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
+ delete clientOptions.maxTokens;
+ }
+
+ if (index < 2) {
+ // null or undefined cases
+ expect(clientOptions.maxTokens).toBe(maxTokens);
+ expect(clientOptions.modelKwargs).toBeUndefined();
+ } else {
+ // 0 case - should transform
+ expect(clientOptions.maxTokens).toBeUndefined();
+ expect(clientOptions.modelKwargs?.max_completion_tokens).toBe(0);
+ }
+ });
+ });
+ });
+
+ describe('buildMessages with MCP server instructions', () => {
+ let client;
+ let mockReq;
+ let mockRes;
+ let mockAgent;
+ let mockOptions;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Reset the mock to default behavior
+ mockFormatInstructions.mockResolvedValue(
+ '# MCP Server Instructions\n\nTest MCP instructions here',
+ );
+
+ const { DynamicStructuredTool } = require('@librechat/agents/langchain/tools');
+
+ // Create mock MCP tools with the delimiter pattern
+ const mockMCPTool1 = new DynamicStructuredTool({
+ name: `tool1${Constants.mcp_delimiter}server1`,
+ description: 'Test MCP tool 1',
+ schema: {},
+ func: async () => 'result',
+ });
+
+ const mockMCPTool2 = new DynamicStructuredTool({
+ name: `tool2${Constants.mcp_delimiter}server2`,
+ description: 'Test MCP tool 2',
+ schema: {},
+ func: async () => 'result',
+ });
+
+ mockAgent = {
+ id: 'agent-123',
+ endpoint: EModelEndpoint.openAI,
+ provider: EModelEndpoint.openAI,
+ instructions: 'Base agent instructions',
+ model_parameters: {
+ model: 'gpt-4',
+ },
+ tools: [mockMCPTool1, mockMCPTool2],
+ };
+
+ mockReq = {
+ user: {
+ id: 'user-123',
+ },
+ body: {
+ endpoint: EModelEndpoint.openAI,
+ },
+ config: {},
+ };
+
+ mockRes = {};
+
+ mockOptions = {
+ req: mockReq,
+ res: mockRes,
+ agent: mockAgent,
+ endpoint: EModelEndpoint.agents,
+ };
+
+ client = new AgentClient(mockOptions);
+ client.conversationId = 'convo-123';
+ client.responseMessageId = 'response-123';
+ client.shouldSummarize = false;
+ client.maxContextTokens = 4096;
+ });
+
+ it('should await MCP instructions and not include [object Promise] in agent instructions', async () => {
+ // Set specific return value for this test
+ mockFormatInstructions.mockResolvedValue(
+ '# MCP Server Instructions\n\nUse these tools carefully',
+ );
+
+ const messages = [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Hello',
+ isCreatedByUser: true,
+ },
+ ];
+
+ await client.buildMessages(messages, null, {
+ instructions: 'Base instructions',
+ additional_instructions: null,
+ });
+
+ // Verify formatInstructionsForContext was called with correct server names
+ expect(mockFormatInstructions).toHaveBeenCalledWith(['server1', 'server2'], {});
+
+ // Verify the instructions do NOT contain [object Promise]
+ expect(client.options.agent.instructions).not.toContain('[object Promise]');
+
+ // Verify the instructions DO contain the MCP instructions
+ expect(client.options.agent.instructions).toContain('# MCP Server Instructions');
+ expect(client.options.agent.instructions).toContain('Use these tools carefully');
+
+ // Verify the base instructions are also included (from agent config, not buildOptions)
+ expect(client.options.agent.instructions).toContain('Base agent instructions');
+ });
+
+ it('should handle MCP instructions with ephemeral agent', async () => {
+ // Set specific return value for this test
+ mockFormatInstructions.mockResolvedValue(
+ '# Ephemeral MCP Instructions\n\nSpecial ephemeral instructions',
+ );
+
+ // Set up ephemeral agent with MCP servers
+ mockReq.body.ephemeralAgent = {
+ mcp: ['ephemeral-server1', 'ephemeral-server2'],
+ };
+
+ const messages = [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Test ephemeral',
+ isCreatedByUser: true,
+ },
+ ];
+
+ await client.buildMessages(messages, null, {
+ instructions: 'Ephemeral instructions',
+ additional_instructions: null,
+ });
+
+ // Verify formatInstructionsForContext was called with ephemeral server names
+ expect(mockFormatInstructions).toHaveBeenCalledWith(
+ ['ephemeral-server1', 'ephemeral-server2'],
+ {},
+ );
+
+ // Verify no [object Promise] in instructions
+ expect(client.options.agent.instructions).not.toContain('[object Promise]');
+
+ // Verify ephemeral MCP instructions are included
+ expect(client.options.agent.instructions).toContain('# Ephemeral MCP Instructions');
+ expect(client.options.agent.instructions).toContain('Special ephemeral instructions');
+ });
+
+ it('should handle empty MCP instructions gracefully', async () => {
+ // Set empty return value for this test
+ mockFormatInstructions.mockResolvedValue('');
+
+ const messages = [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Hello',
+ isCreatedByUser: true,
+ },
+ ];
+
+ await client.buildMessages(messages, null, {
+ instructions: 'Base instructions only',
+ additional_instructions: null,
+ });
+
+ // Verify the instructions still work without MCP content (from agent config, not buildOptions)
+ expect(client.options.agent.instructions).toBe('Base agent instructions');
+ expect(client.options.agent.instructions).not.toContain('[object Promise]');
+ });
+
+ it('should handle MCP instructions error gracefully', async () => {
+ // Set error return for this test
+ mockFormatInstructions.mockRejectedValue(new Error('MCP error'));
+
+ const messages = [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Hello',
+ isCreatedByUser: true,
+ },
+ ];
+
+ // Should not throw
+ await client.buildMessages(messages, null, {
+ instructions: 'Base instructions',
+ additional_instructions: null,
+ });
+
+ // Should still have base instructions without MCP content (from agent config, not buildOptions)
+ expect(client.options.agent.instructions).toContain('Base agent instructions');
+ expect(client.options.agent.instructions).not.toContain('[object Promise]');
+ });
+ });
+
+ describe('buildMessages with request and agent-scoped context attachments', () => {
+ let client;
+ let mockReq;
+ let mockRes;
+ let mockAgent;
+
+ const makeTextFile = (file_id, filename, text) => ({
+ user: 'user-123',
+ file_id,
+ filename,
+ filepath: `/uploads/${filename}`,
+ object: 'file',
+ type: 'text/plain',
+ bytes: text.length,
+ embedded: false,
+ usage: 0,
+ source: 'text',
+ text,
+ });
+
+ const makeUploadedFile = (file_id, filename, type) => ({
+ user: 'user-123',
+ file_id,
+ filename,
+ filepath: `/uploads/${filename}`,
+ object: 'file',
+ type,
+ bytes: 128,
+ embedded: false,
+ usage: 0,
+ source: 'local',
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockFormatInstructions.mockResolvedValue('');
+ require('@librechat/api').countFormattedMessageTokens.mockImplementation(() => 42);
+
+ mockAgent = {
+ id: 'primary-agent',
+ endpoint: EModelEndpoint.openAI,
+ provider: EModelEndpoint.openAI,
+ instructions: 'Primary instructions',
+ model_parameters: {
+ model: 'gpt-4',
+ },
+ tools: [],
+ };
+
+ mockReq = {
+ user: {
+ id: 'user-123',
+ personalization: {
+ memories: true,
+ },
+ },
+ body: {
+ endpoint: EModelEndpoint.openAI,
+ fileTokenLimit: 1000,
+ },
+ config: {
+ memory: {
+ disabled: true,
+ },
+ },
+ };
+ mockRes = {};
+
+ client = new AgentClient({
+ req: mockReq,
+ res: mockRes,
+ agent: mockAgent,
+ endpoint: EModelEndpoint.agents,
+ });
+ client.conversationId = 'convo-123';
+ client.responseMessageId = 'response-123';
+ client.shouldSummarize = false;
+ client.maxContextTokens = 4096;
+ client.useMemory = jest.fn().mockResolvedValue(undefined);
+ });
+
+ it.each([
+ ['CSV', 'csv-file', 'sample.csv', 'text/csv'],
+ [
+ 'XLSX',
+ 'xlsx-file',
+ 'sample.xlsx',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ ],
+ ])(
+ 'routes default-supported provider uploads like %s as request documents without custom file config',
+ async (_label, file_id, filename, type) => {
+ const currentFile = makeUploadedFile(file_id, filename, type);
+ const message = {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: `Read this ${filename}.`,
+ isCreatedByUser: true,
+ };
+
+ client.addDocuments = jest.fn(async (targetMessage, attachments) => {
+ targetMessage.documents = attachments.map((file) => ({
+ type: 'input_file',
+ filename: file.filename,
+ file_data: `data:${file.type};base64,Y29sMQox`,
+ }));
+ return attachments;
+ });
+
+ const files = await client.processAttachments(message, [currentFile]);
+
+ expect(client.addDocuments).toHaveBeenCalledWith(message, [currentFile]);
+ expect(message.documents).toEqual([
+ expect.objectContaining({
+ type: 'input_file',
+ filename,
+ }),
+ ]);
+ expect(files).toEqual([currentFile]);
+ },
+ );
+
+ it('places request context inline and applies each agent context doc only once', async () => {
+ const requestFile = makeTextFile('request-file', 'request.txt', 'Shared request context');
+ const primaryContext = makeTextFile(
+ 'primary-context',
+ 'primary.txt',
+ 'Primary private context',
+ );
+ const handoffContext = makeTextFile(
+ 'handoff-context',
+ 'handoff.txt',
+ 'Handoff private context',
+ );
+ const handoffAgent = {
+ id: 'handoff-agent',
+ endpoint: EModelEndpoint.openAI,
+ provider: EModelEndpoint.openAI,
+ instructions: 'Handoff instructions',
+ model_parameters: {
+ model: 'gpt-4',
+ },
+ tools: [],
+ };
+
+ client.options.attachments = [requestFile];
+ client.options.agentContextAttachmentsByAgentId = new Map([
+ ['primary-agent', [primaryContext]],
+ ['handoff-agent', [handoffContext]],
+ ]);
+ client.agentConfigs = new Map([['handoff-agent', handoffAgent]]);
+
+ const result = await client.buildMessages(
+ [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Use the available context.',
+ isCreatedByUser: true,
+ },
+ ],
+ 'msg-1',
+ {},
+ );
+
+ expect(result.prompt[0].content).toContain('Shared request context');
+
+ expect(mockAgent.additional_instructions).toContain('Primary private context');
+ expect(mockAgent.additional_instructions).not.toContain('Shared request context');
+ expect(mockAgent.additional_instructions).not.toContain('Handoff private context');
+
+ expect(handoffAgent.additional_instructions).toContain('Handoff private context');
+ expect(handoffAgent.additional_instructions).not.toContain('Shared request context');
+ expect(handoffAgent.additional_instructions).not.toContain('Primary private context');
+ });
+
+ it('places current request file context on the latest user message', async () => {
+ const currentFile = makeTextFile('current-file', 'current.txt', 'Current turn file body');
+ const previousFileContext =
+ 'Attached document(s):\n```md\n# "previous.txt"\nPrevious turn file body\n```';
+
+ client.options.attachments = [currentFile];
+
+ const result = await client.buildMessages(
+ [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'What is written here?',
+ isCreatedByUser: true,
+ fileContext: previousFileContext,
+ },
+ {
+ messageId: 'msg-2',
+ parentMessageId: 'msg-1',
+ sender: 'Assistant',
+ text: 'It describes the previous file.',
+ isCreatedByUser: false,
+ },
+ {
+ messageId: 'msg-3',
+ parentMessageId: 'msg-2',
+ sender: 'User',
+ text: 'What is written here?',
+ isCreatedByUser: true,
+ },
+ ],
+ 'msg-3',
+ {},
+ );
+
+ expect(result.prompt[0].content).toContain('Previous turn file body');
+ expect(result.prompt[2].content).toContain('Current turn file body');
+ expect(result.prompt[2].content).toContain('What is written here?');
+ expect(result.prompt[2].content).not.toContain('Previous turn file body');
+ expect(client.memoryPayload[2].content).toContain('What is written here?');
+ expect(client.memoryPayload[2].content).not.toContain('Current turn file body');
+ expect(mockAgent.additional_instructions ?? '').not.toContain('Current turn file body');
+ expect(result.prompt[2].content.indexOf('Current turn file body')).toBeLessThan(
+ result.prompt[2].content.indexOf('What is written here?'),
+ );
+ });
+
+ it('persists canonical token counts while counting request file context for the prompt', async () => {
+ const { countFormattedMessageTokens } = require('@librechat/api');
+ const currentFile = makeTextFile('current-file', 'current.txt', 'Current turn file body');
+
+ countFormattedMessageTokens.mockImplementation(({ content }) => {
+ const text = Array.isArray(content)
+ ? content.map((part) => part.text ?? part[ContentTypes.TEXT] ?? '').join('\n')
+ : String(content ?? '');
+ return text.includes('Current turn file body') ? 200 : 20;
+ });
+
+ client.options.attachments = [currentFile];
+
+ const result = await client.buildMessages(
+ [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'What is written here?',
+ isCreatedByUser: true,
+ },
+ ],
+ 'msg-1',
+ {},
+ );
+
+ expect(result.prompt[0].content).toContain('Current turn file body');
+ expect(result.tokenCountMap['msg-1']).toBe(20);
+ expect(result.promptTokens).toBe(200);
+ expect(client.indexTokenCountMap[0]).toBe(200);
+ expect(client.memoryPayload[0].content).toBe('What is written here?');
+ });
+
+ it('does not duplicate a file that is both request context and scoped context', async () => {
+ const sharedFile = makeTextFile('shared-file', 'shared.txt', 'Shared duplicate context');
+
+ client.options.attachments = [sharedFile];
+ client.options.agentContextAttachmentsByAgentId = new Map([['primary-agent', [sharedFile]]]);
+ client.agentConfigs = new Map();
+
+ const result = await client.buildMessages(
+ [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Use the available context.',
+ isCreatedByUser: true,
+ },
+ ],
+ 'msg-1',
+ {},
+ );
+
+ const inlineOccurrences = (result.prompt[0].content.match(/Shared duplicate context/g) ?? [])
+ .length;
+ expect(inlineOccurrences).toBe(1);
+ expect(mockAgent.additional_instructions ?? '').not.toContain('Shared duplicate context');
+ });
+
+ it('keeps direct chats with context-doc agents working without request attachments', async () => {
+ const primaryContext = makeTextFile(
+ 'primary-context',
+ 'primary.txt',
+ 'Direct primary context',
+ );
+
+ client.options.agentContextAttachmentsByAgentId = new Map([
+ ['primary-agent', [primaryContext]],
+ ]);
+ client.agentConfigs = new Map();
+
+ await client.buildMessages(
+ [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Answer from your context.',
+ isCreatedByUser: true,
+ },
+ ],
+ 'msg-1',
+ {},
+ );
+
+ expect(mockAgent.additional_instructions).toContain('Direct primary context');
+ });
+ });
+
+ describe('runMemory method', () => {
+ let client;
+ let mockReq;
+ let mockRes;
+ let mockAgent;
+ let mockOptions;
+ let mockProcessMemory;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockAgent = {
+ id: 'agent-123',
+ endpoint: EModelEndpoint.openAI,
+ provider: EModelEndpoint.openAI,
+ model_parameters: {
+ model: 'gpt-4',
+ },
+ };
+
+ mockReq = {
+ user: {
+ id: 'user-123',
+ personalization: {
+ memories: true,
+ },
+ },
+ };
+
+ // Mock getAppConfig for memory tests
+ mockReq.config = {
+ memory: {
+ messageWindowSize: 3,
+ },
+ };
+
+ mockRes = {};
+
+ mockOptions = {
+ req: mockReq,
+ res: mockRes,
+ agent: mockAgent,
+ };
+
+ mockProcessMemory = jest.fn().mockResolvedValue([]);
+
+ client = new AgentClient(mockOptions);
+ client.processMemory = mockProcessMemory;
+ client.conversationId = 'convo-123';
+ client.responseMessageId = 'response-123';
+ });
+
+ it('should filter out image URLs from message content', async () => {
+ const { HumanMessage, AIMessage } = require('@librechat/agents/langchain/messages');
+ const messages = [
+ new HumanMessage({
+ content: [
+ {
+ type: 'text',
+ text: 'What is in this image?',
+ },
+ {
+ type: 'image_url',
+ image_url: {
+ url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
+ detail: 'auto',
+ },
+ },
+ ],
+ }),
+ new AIMessage('I can see a small red pixel in the image.'),
+ new HumanMessage({
+ content: [
+ {
+ type: 'text',
+ text: 'What about this one?',
+ },
+ {
+ type: 'image_url',
+ image_url: {
+ url: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/',
+ detail: 'high',
+ },
+ },
+ ],
+ }),
+ ];
+
+ await client.runMemory(messages);
+
+ expect(mockProcessMemory).toHaveBeenCalledTimes(1);
+ const processedMessage = mockProcessMemory.mock.calls[0][0][0];
+
+ // Verify the buffer message was created
+ expect(processedMessage.constructor.name).toBe('HumanMessage');
+ expect(processedMessage.content).toContain('# Current Chat:');
+
+ // Verify that image URLs are not in the buffer string
+ expect(processedMessage.content).not.toContain('image_url');
+ expect(processedMessage.content).not.toContain('data:image');
+ expect(processedMessage.content).not.toContain('base64');
+
+ // Verify text content is preserved
+ expect(processedMessage.content).toContain('What is in this image?');
+ expect(processedMessage.content).toContain('I can see a small red pixel in the image.');
+ expect(processedMessage.content).toContain('What about this one?');
+ });
+
+ it('should handle messages with only text content', async () => {
+ const { HumanMessage, AIMessage } = require('@librechat/agents/langchain/messages');
+ const messages = [
+ new HumanMessage('Hello, how are you?'),
+ new AIMessage('I am doing well, thank you!'),
+ new HumanMessage('That is great to hear.'),
+ ];
+
+ await client.runMemory(messages);
+
+ expect(mockProcessMemory).toHaveBeenCalledTimes(1);
+ const processedMessage = mockProcessMemory.mock.calls[0][0][0];
+
+ expect(processedMessage.content).toContain('Hello, how are you?');
+ expect(processedMessage.content).toContain('I am doing well, thank you!');
+ expect(processedMessage.content).toContain('That is great to hear.');
+ });
+
+ it('should handle mixed content types correctly', async () => {
+ const { HumanMessage } = require('@librechat/agents/langchain/messages');
+ const { ContentTypes } = require('librechat-data-provider');
+
+ const messages = [
+ new HumanMessage({
+ content: [
+ {
+ type: 'text',
+ text: 'Here is some text',
+ },
+ {
+ type: ContentTypes.IMAGE_URL,
+ image_url: {
+ url: 'https://example.com/image.png',
+ },
+ },
+ {
+ type: 'text',
+ text: ' and more text',
+ },
+ ],
+ }),
+ ];
+
+ await client.runMemory(messages);
+
+ expect(mockProcessMemory).toHaveBeenCalledTimes(1);
+ const processedMessage = mockProcessMemory.mock.calls[0][0][0];
+
+ // Should contain text parts but not image URLs
+ expect(processedMessage.content).toContain('Here is some text');
+ expect(processedMessage.content).toContain('and more text');
+ expect(processedMessage.content).not.toContain('example.com/image.png');
+ expect(processedMessage.content).not.toContain('IMAGE_URL');
+ });
+
+ it('should preserve original messages without mutation', async () => {
+ const { HumanMessage } = require('@librechat/agents/langchain/messages');
+ const originalContent = [
+ {
+ type: 'text',
+ text: 'Original text',
+ },
+ {
+ type: 'image_url',
+ image_url: {
+ url: 'data:image/png;base64,ABC123',
+ },
+ },
+ ];
+
+ const messages = [
+ new HumanMessage({
+ content: [...originalContent],
+ }),
+ ];
+
+ await client.runMemory(messages);
+
+ // Verify original message wasn't mutated
+ expect(messages[0].content).toHaveLength(2);
+ expect(messages[0].content[1].type).toBe('image_url');
+ expect(messages[0].content[1].image_url.url).toBe('data:image/png;base64,ABC123');
+ });
+
+ it('should handle message window size correctly', async () => {
+ const { HumanMessage, AIMessage } = require('@librechat/agents/langchain/messages');
+ const messages = [
+ new HumanMessage('Message 1'),
+ new AIMessage('Response 1'),
+ new HumanMessage('Message 2'),
+ new AIMessage('Response 2'),
+ new HumanMessage('Message 3'),
+ new AIMessage('Response 3'),
+ ];
+
+ // Window size is set to 3 in mockReq
+ await client.runMemory(messages);
+
+ expect(mockProcessMemory).toHaveBeenCalledTimes(1);
+ const processedMessage = mockProcessMemory.mock.calls[0][0][0];
+
+ // Should only include last 3 messages due to window size
+ expect(processedMessage.content).toContain('Message 3');
+ expect(processedMessage.content).toContain('Response 3');
+ expect(processedMessage.content).not.toContain('Message 1');
+ expect(processedMessage.content).not.toContain('Response 1');
+ });
+
+ it('should cap memory input tokens and preserve recent content', async () => {
+ const { HumanMessage, AIMessage } = require('@librechat/agents/langchain/messages');
+ mockReq.config.memory.maxInputTokens = 12;
+ const messages = [
+ new HumanMessage(`OLDER_CONTENT ${'a'.repeat(600)}`),
+ new AIMessage('Intermediate response'),
+ new HumanMessage('Please remember LATEST_MEMORY_MARKER'),
+ ];
+
+ await client.runMemory(messages);
+
+ expect(mockProcessMemory).toHaveBeenCalledTimes(1);
+ const processedMessage = mockProcessMemory.mock.calls[0][0][0];
+
+ expect(processedMessage.content).toContain('LATEST_MEMORY_MARKER');
+ expect(processedMessage.content).not.toContain('OLDER_CONTENT');
+ expect(Math.ceil(processedMessage.content.length / 4)).toBeLessThanOrEqual(12);
+ });
+
+ it('should return early if processMemory is not set', async () => {
+ const { HumanMessage } = require('@librechat/agents/langchain/messages');
+ client.processMemory = null;
+
+ const result = await client.runMemory([new HumanMessage('Test')]);
+
+ expect(result).toBeUndefined();
+ expect(mockProcessMemory).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('getMessagesForConversation - mapMethod and mapCondition', () => {
+ const createMessage = (id, parentId, text, extras = {}) => ({
+ messageId: id,
+ parentMessageId: parentId,
+ text,
+ isCreatedByUser: false,
+ ...extras,
+ });
+
+ it('should apply mapMethod to all messages when mapCondition is not provided', () => {
+ const messages = [
+ createMessage('msg-1', null, 'First message'),
+ createMessage('msg-2', 'msg-1', 'Second message'),
+ createMessage('msg-3', 'msg-2', 'Third message'),
+ ];
+
+ const mapMethod = jest.fn((msg) => ({ ...msg, mapped: true }));
+
+ const result = AgentClient.getMessagesForConversation({
+ messages,
+ parentMessageId: 'msg-3',
+ mapMethod,
+ });
+
+ expect(result).toHaveLength(3);
+ expect(mapMethod).toHaveBeenCalledTimes(3);
+ result.forEach((msg) => {
+ expect(msg.mapped).toBe(true);
+ });
+ });
+
+ it('should apply mapMethod only to messages where mapCondition returns true', () => {
+ const messages = [
+ createMessage('msg-1', null, 'First message', { addedConvo: false }),
+ createMessage('msg-2', 'msg-1', 'Second message', { addedConvo: true }),
+ createMessage('msg-3', 'msg-2', 'Third message', { addedConvo: true }),
+ createMessage('msg-4', 'msg-3', 'Fourth message', { addedConvo: false }),
+ ];
+
+ const mapMethod = jest.fn((msg) => ({ ...msg, mapped: true }));
+ const mapCondition = (msg) => msg.addedConvo === true;
+
+ const result = AgentClient.getMessagesForConversation({
+ messages,
+ parentMessageId: 'msg-4',
+ mapMethod,
+ mapCondition,
+ });
+
+ expect(result).toHaveLength(4);
+ expect(mapMethod).toHaveBeenCalledTimes(2);
+
+ expect(result[0].mapped).toBeUndefined();
+ expect(result[1].mapped).toBe(true);
+ expect(result[2].mapped).toBe(true);
+ expect(result[3].mapped).toBeUndefined();
+ });
+
+ it('should not apply mapMethod when mapCondition returns false for all messages', () => {
+ const messages = [
+ createMessage('msg-1', null, 'First message', { addedConvo: false }),
+ createMessage('msg-2', 'msg-1', 'Second message', { addedConvo: false }),
+ ];
+
+ const mapMethod = jest.fn((msg) => ({ ...msg, mapped: true }));
+ const mapCondition = (msg) => msg.addedConvo === true;
+
+ const result = AgentClient.getMessagesForConversation({
+ messages,
+ parentMessageId: 'msg-2',
+ mapMethod,
+ mapCondition,
+ });
+
+ expect(result).toHaveLength(2);
+ expect(mapMethod).not.toHaveBeenCalled();
+ result.forEach((msg) => {
+ expect(msg.mapped).toBeUndefined();
+ });
+ });
+
+ it('should not call mapMethod when mapMethod is null', () => {
+ const messages = [
+ createMessage('msg-1', null, 'First message'),
+ createMessage('msg-2', 'msg-1', 'Second message'),
+ ];
+
+ const mapCondition = jest.fn(() => true);
+
+ const result = AgentClient.getMessagesForConversation({
+ messages,
+ parentMessageId: 'msg-2',
+ mapMethod: null,
+ mapCondition,
+ });
+
+ expect(result).toHaveLength(2);
+ expect(mapCondition).not.toHaveBeenCalled();
+ });
+
+ it('should handle mapCondition with complex logic', () => {
+ const messages = [
+ createMessage('msg-1', null, 'User message', { isCreatedByUser: true, addedConvo: true }),
+ createMessage('msg-2', 'msg-1', 'Assistant response', { addedConvo: true }),
+ createMessage('msg-3', 'msg-2', 'Another user message', { isCreatedByUser: true }),
+ createMessage('msg-4', 'msg-3', 'Another response', { addedConvo: true }),
+ ];
+
+ const mapMethod = jest.fn((msg) => ({ ...msg, processed: true }));
+ const mapCondition = (msg) => msg.addedConvo === true && !msg.isCreatedByUser;
+
+ const result = AgentClient.getMessagesForConversation({
+ messages,
+ parentMessageId: 'msg-4',
+ mapMethod,
+ mapCondition,
+ });
+
+ expect(result).toHaveLength(4);
+ expect(mapMethod).toHaveBeenCalledTimes(2);
+
+ expect(result[0].processed).toBeUndefined();
+ expect(result[1].processed).toBe(true);
+ expect(result[2].processed).toBeUndefined();
+ expect(result[3].processed).toBe(true);
+ });
+
+ it('should preserve message order after applying mapMethod with mapCondition', () => {
+ const messages = [
+ createMessage('msg-1', null, 'First', { addedConvo: true }),
+ createMessage('msg-2', 'msg-1', 'Second', { addedConvo: false }),
+ createMessage('msg-3', 'msg-2', 'Third', { addedConvo: true }),
+ ];
+
+ const mapMethod = (msg) => ({ ...msg, text: `[MAPPED] ${msg.text}` });
+ const mapCondition = (msg) => msg.addedConvo === true;
+
+ const result = AgentClient.getMessagesForConversation({
+ messages,
+ parentMessageId: 'msg-3',
+ mapMethod,
+ mapCondition,
+ });
+
+ expect(result[0].text).toBe('[MAPPED] First');
+ expect(result[1].text).toBe('Second');
+ expect(result[2].text).toBe('[MAPPED] Third');
+ });
+
+ it('should work with summary option alongside mapMethod and mapCondition', () => {
+ const messages = [
+ createMessage('msg-1', null, 'First', { addedConvo: false }),
+ createMessage('msg-2', 'msg-1', 'Second', {
+ summary: 'Summary of conversation',
+ addedConvo: true,
+ }),
+ createMessage('msg-3', 'msg-2', 'Third', { addedConvo: true }),
+ createMessage('msg-4', 'msg-3', 'Fourth', { addedConvo: false }),
+ ];
+
+ const mapMethod = jest.fn((msg) => ({ ...msg, mapped: true }));
+ const mapCondition = (msg) => msg.addedConvo === true;
+
+ const result = AgentClient.getMessagesForConversation({
+ messages,
+ parentMessageId: 'msg-4',
+ mapMethod,
+ mapCondition,
+ summary: true,
+ });
+
+ /** Traversal stops at msg-2 (has summary), so we get msg-4 -> msg-3 -> msg-2 */
+ expect(result).toHaveLength(3);
+ 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);
+ expect(result[2].mapped).toBeUndefined();
+ });
+
+ it('should handle empty messages array', () => {
+ const mapMethod = jest.fn();
+ const mapCondition = jest.fn();
+
+ const result = AgentClient.getMessagesForConversation({
+ messages: [],
+ parentMessageId: 'msg-1',
+ mapMethod,
+ mapCondition,
+ });
+
+ expect(result).toHaveLength(0);
+ expect(mapMethod).not.toHaveBeenCalled();
+ expect(mapCondition).not.toHaveBeenCalled();
+ });
+
+ it('should handle undefined mapCondition explicitly', () => {
+ const messages = [
+ createMessage('msg-1', null, 'First'),
+ createMessage('msg-2', 'msg-1', 'Second'),
+ ];
+
+ const mapMethod = jest.fn((msg) => ({ ...msg, mapped: true }));
+
+ const result = AgentClient.getMessagesForConversation({
+ messages,
+ parentMessageId: 'msg-2',
+ mapMethod,
+ mapCondition: undefined,
+ });
+
+ expect(result).toHaveLength(2);
+ expect(mapMethod).toHaveBeenCalledTimes(2);
+ result.forEach((msg) => {
+ expect(msg.mapped).toBe(true);
+ });
+ });
+ });
+
+ describe('buildMessages - memory context for parallel agents', () => {
+ let client;
+ let mockReq;
+ let mockRes;
+ let mockAgent;
+ let mockOptions;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockAgent = {
+ id: 'primary-agent',
+ name: 'Primary Agent',
+ endpoint: EModelEndpoint.openAI,
+ provider: EModelEndpoint.openAI,
+ instructions: 'Primary agent instructions',
+ model_parameters: {
+ model: 'gpt-4',
+ },
+ tools: [],
+ };
+
+ mockReq = {
+ user: {
+ id: 'user-123',
+ personalization: {
+ memories: true,
+ },
+ },
+ body: {
+ endpoint: EModelEndpoint.openAI,
+ },
+ config: {
+ memory: {
+ disabled: false,
+ },
+ },
+ };
+
+ mockRes = {};
+
+ mockOptions = {
+ req: mockReq,
+ res: mockRes,
+ agent: mockAgent,
+ endpoint: EModelEndpoint.agents,
+ };
+
+ client = new AgentClient(mockOptions);
+ client.conversationId = 'convo-123';
+ client.responseMessageId = 'response-123';
+ client.shouldSummarize = false;
+ client.maxContextTokens = 4096;
+ });
+
+ it('should only pass memory context to the primary agent by default', async () => {
+ const memoryContent = 'User prefers dark mode. User is a software developer.';
+ client.useMemory = jest
+ .fn()
+ .mockResolvedValue({ withKeys: memoryContent, withoutKeys: memoryContent });
+
+ const parallelAgent1 = {
+ id: 'parallel-agent-1',
+ name: 'Parallel Agent 1',
+ instructions: 'Parallel agent 1 instructions',
+ provider: EModelEndpoint.openAI,
+ };
+
+ const parallelAgent2 = {
+ id: 'parallel-agent-2',
+ name: 'Parallel Agent 2',
+ instructions: 'Parallel agent 2 instructions',
+ provider: EModelEndpoint.anthropic,
+ };
+
+ client.agentConfigs = new Map([
+ ['parallel-agent-1', parallelAgent1],
+ ['parallel-agent-2', parallelAgent2],
+ ]);
+
+ const messages = [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Hello',
+ isCreatedByUser: true,
+ },
+ ];
+
+ await client.buildMessages(messages, null, {
+ instructions: 'Base instructions',
+ additional_instructions: null,
+ });
+
+ expect(client.useMemory).toHaveBeenCalled();
+
+ expect(client.options.agent.instructions).toContain('Primary agent instructions');
+ expect(client.options.agent.instructions).not.toContain(memoryContent);
+ expect(client.options.agent.additional_instructions).toContain(memoryContent);
+
+ expect(parallelAgent1.instructions).toContain('Parallel agent 1 instructions');
+ expect(parallelAgent1.instructions).not.toContain(memoryContent);
+ expect(parallelAgent1.additional_instructions ?? '').not.toContain(memoryContent);
+
+ expect(parallelAgent2.instructions).toContain('Parallel agent 2 instructions');
+ expect(parallelAgent2.instructions).not.toContain(memoryContent);
+ expect(parallelAgent2.additional_instructions ?? '').not.toContain(memoryContent);
+ });
+
+ it('should pass memory context to parallel agents when automatic memory updates are enabled', async () => {
+ const memoryContent = 'User prefers dark mode. User is a software developer.';
+ client.useMemory = jest
+ .fn()
+ .mockResolvedValue({ withKeys: memoryContent, withoutKeys: memoryContent });
+ mockReq.config.memory.agent = {
+ enabled: true,
+ id: 'memory-agent',
+ };
+
+ const parallelAgent = {
+ id: 'parallel-agent-1',
+ name: 'Parallel Agent 1',
+ instructions: 'Parallel agent instructions',
+ provider: EModelEndpoint.openAI,
+ };
+
+ client.agentConfigs = new Map([['parallel-agent-1', parallelAgent]]);
+
+ const messages = [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Hello',
+ isCreatedByUser: true,
+ },
+ ];
+
+ await client.buildMessages(messages, null, {
+ instructions: 'Base instructions',
+ additional_instructions: null,
+ });
+
+ expect(client.options.agent.instructions).toContain('Primary agent instructions');
+ expect(client.options.agent.instructions).not.toContain(memoryContent);
+ expect(client.options.agent.additional_instructions).toContain(memoryContent);
+
+ expect(parallelAgent.instructions).toContain('Parallel agent instructions');
+ expect(parallelAgent.instructions).not.toContain(memoryContent);
+ expect(parallelAgent.additional_instructions).toContain(memoryContent);
+ });
+
+ it('should not modify parallel agents when no memory context is available', async () => {
+ client.useMemory = jest.fn().mockResolvedValue(undefined);
+
+ const parallelAgent = {
+ id: 'parallel-agent-1',
+ name: 'Parallel Agent 1',
+ instructions: 'Original parallel instructions',
+ provider: EModelEndpoint.openAI,
+ };
+
+ client.agentConfigs = new Map([['parallel-agent-1', parallelAgent]]);
+
+ const messages = [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Hello',
+ isCreatedByUser: true,
+ },
+ ];
+
+ await client.buildMessages(messages, null, {
+ instructions: 'Base instructions',
+ additional_instructions: null,
+ });
+
+ expect(parallelAgent.instructions).toBe('Original parallel instructions');
+ });
+
+ it('should handle parallel agents without existing instructions when memory stays primary-only', async () => {
+ const memoryContent = 'User is a data scientist.';
+ client.useMemory = jest
+ .fn()
+ .mockResolvedValue({ withKeys: memoryContent, withoutKeys: memoryContent });
+
+ const parallelAgentNoInstructions = {
+ id: 'parallel-agent-no-instructions',
+ name: 'Parallel Agent No Instructions',
+ provider: EModelEndpoint.openAI,
+ };
+
+ client.agentConfigs = new Map([
+ ['parallel-agent-no-instructions', parallelAgentNoInstructions],
+ ]);
+
+ const messages = [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Hello',
+ isCreatedByUser: true,
+ },
+ ];
+
+ await client.buildMessages(messages, null, {
+ instructions: null,
+ additional_instructions: null,
+ });
+
+ expect(client.options.agent.additional_instructions).toContain(memoryContent);
+ expect(parallelAgentNoInstructions.instructions).toBeUndefined();
+ expect(parallelAgentNoInstructions.additional_instructions ?? '').not.toContain(
+ memoryContent,
+ );
+ });
+
+ it('should not modify agentConfigs when none exist', async () => {
+ const memoryContent = 'User prefers concise responses.';
+ client.useMemory = jest
+ .fn()
+ .mockResolvedValue({ withKeys: memoryContent, withoutKeys: memoryContent });
+
+ client.agentConfigs = null;
+
+ const messages = [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Hello',
+ isCreatedByUser: true,
+ },
+ ];
+
+ await expect(
+ client.buildMessages(messages, null, {
+ instructions: 'Base instructions',
+ additional_instructions: null,
+ }),
+ ).resolves.not.toThrow();
+
+ expect(client.options.agent.additional_instructions).toContain(memoryContent);
+ });
+
+ it('should handle empty agentConfigs map', async () => {
+ const memoryContent = 'User likes detailed explanations.';
+ client.useMemory = jest
+ .fn()
+ .mockResolvedValue({ withKeys: memoryContent, withoutKeys: memoryContent });
+
+ client.agentConfigs = new Map();
+
+ const messages = [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Hello',
+ isCreatedByUser: true,
+ },
+ ];
+
+ await expect(
+ client.buildMessages(messages, null, {
+ instructions: 'Base instructions',
+ additional_instructions: null,
+ }),
+ ).resolves.not.toThrow();
+
+ expect(client.options.agent.additional_instructions).toContain(memoryContent);
+ });
+ });
+
+ describe('useMemory method - prelimAgent assignment', () => {
+ let client;
+ let mockReq;
+ let mockRes;
+ let mockAgent;
+ let mockOptions;
+ let mockCheckAccess;
+ let mockLoadAgent;
+ let mockInitializeAgent;
+ let mockCreateMemoryProcessor;
+ let mockGetFormattedMemories;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockAgent = {
+ id: 'agent-123',
+ endpoint: EModelEndpoint.openAI,
+ provider: EModelEndpoint.openAI,
+ instructions: 'Test instructions',
+ model: 'gpt-4',
+ model_parameters: {
+ model: 'gpt-4',
+ },
+ };
+
+ mockReq = {
+ user: {
+ id: 'user-123',
+ personalization: {
+ memories: true,
+ },
+ },
+ config: {
+ memory: {
+ agent: {
+ enabled: true,
+ id: 'agent-123',
+ },
+ },
+ endpoints: {
+ [EModelEndpoint.agents]: {
+ allowedProviders: [EModelEndpoint.openAI],
+ },
+ },
+ },
+ };
+
+ mockRes = {};
+
+ mockOptions = {
+ req: mockReq,
+ res: mockRes,
+ agent: mockAgent,
+ };
+
+ mockCheckAccess = require('@librechat/api').checkAccess;
+ mockLoadAgent = require('@librechat/api').loadAgent;
+ mockInitializeAgent = require('@librechat/api').initializeAgent;
+ mockCreateMemoryProcessor = require('@librechat/api').createMemoryProcessor;
+ mockGetFormattedMemories = require('~/models').getFormattedMemories;
+ mockGetFormattedMemories.mockResolvedValue({
+ withKeys: '',
+ withoutKeys: '',
+ totalTokens: 0,
+ });
+ });
+
+ it('should use current agent when memory config agent.id matches current agent id', async () => {
+ mockCheckAccess.mockResolvedValue(true);
+ mockInitializeAgent.mockResolvedValue({
+ ...mockAgent,
+ provider: EModelEndpoint.openAI,
+ });
+ mockCreateMemoryProcessor.mockResolvedValue([undefined, jest.fn()]);
+
+ client = new AgentClient(mockOptions);
+ client.conversationId = 'convo-123';
+ client.responseMessageId = 'response-123';
+
+ await client.useMemory();
+
+ expect(mockLoadAgent).not.toHaveBeenCalled();
+ expect(mockInitializeAgent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ agent: mockAgent,
+ }),
+ expect.any(Object),
+ );
+ });
+
+ it('should load different agent when memory config agent.id differs from current agent id', async () => {
+ const differentAgentId = 'different-agent-456';
+ const differentAgent = {
+ id: differentAgentId,
+ provider: EModelEndpoint.openAI,
+ model: 'gpt-4',
+ instructions: 'Different agent instructions',
+ };
+
+ mockReq.config.memory.agent.id = differentAgentId;
+
+ mockCheckAccess.mockResolvedValue(true);
+ mockLoadAgent.mockResolvedValue(differentAgent);
+ mockInitializeAgent.mockResolvedValue({
+ ...differentAgent,
+ provider: EModelEndpoint.openAI,
+ });
+ mockCreateMemoryProcessor.mockResolvedValue([undefined, jest.fn()]);
+
+ client = new AgentClient(mockOptions);
+ client.conversationId = 'convo-123';
+ client.responseMessageId = 'response-123';
+
+ await client.useMemory();
+
+ expect(mockLoadAgent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ agent_id: differentAgentId,
+ }),
+ expect.any(Object),
+ );
+ expect(mockInitializeAgent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ agent: differentAgent,
+ }),
+ expect.any(Object),
+ );
+ });
+
+ it('should return existing memories without auto-processing when memory agent is not enabled', async () => {
+ mockReq.config.memory = {
+ personalize: true,
+ };
+
+ mockCheckAccess.mockResolvedValue(true);
+ mockGetFormattedMemories.mockResolvedValue({
+ withKeys: 'food: likes pasta',
+ withoutKeys: 'likes pasta',
+ totalTokens: 3,
+ });
+
+ client = new AgentClient(mockOptions);
+ client.conversationId = 'convo-123';
+ client.responseMessageId = 'response-123';
+
+ const result = await client.useMemory();
+
+ expect(result).toEqual({ withKeys: 'food: likes pasta', withoutKeys: 'likes pasta' });
+ expect(mockGetFormattedMemories).toHaveBeenCalledWith({ userId: 'user-123' });
+ expect(mockInitializeAgent).not.toHaveBeenCalled();
+ expect(mockCreateMemoryProcessor).not.toHaveBeenCalled();
+ expect(client.processMemory).toBeUndefined();
+ });
+
+ it('should not initialize auto-processing when no memories exist', async () => {
+ mockReq.config.memory = {
+ personalize: true,
+ };
+
+ mockCheckAccess.mockResolvedValue(true);
+ mockGetFormattedMemories.mockResolvedValue({
+ withKeys: '',
+ withoutKeys: '',
+ totalTokens: 0,
+ });
+
+ client = new AgentClient(mockOptions);
+ client.conversationId = 'convo-123';
+ client.responseMessageId = 'response-123';
+
+ const result = await client.useMemory();
+
+ expect(result).toEqual({ withKeys: '', withoutKeys: '' });
+ expect(mockGetFormattedMemories).toHaveBeenCalledWith({ userId: 'user-123' });
+ expect(mockInitializeAgent).not.toHaveBeenCalled();
+ expect(mockCreateMemoryProcessor).not.toHaveBeenCalled();
+ expect(client.processMemory).toBeUndefined();
+ });
+
+ it('should return existing memories without auto-processing when memory agent config lacks explicit enablement', async () => {
+ mockReq.config.memory.agent = {
+ id: 'agent-123',
+ };
+
+ mockCheckAccess.mockResolvedValue(true);
+ mockGetFormattedMemories.mockResolvedValue({
+ withKeys: 'tone: concise',
+ withoutKeys: 'prefers concise answers',
+ totalTokens: 4,
+ });
+
+ client = new AgentClient(mockOptions);
+ client.conversationId = 'convo-123';
+ client.responseMessageId = 'response-123';
+
+ const result = await client.useMemory();
+
+ expect(result).toEqual({ withKeys: 'tone: concise', withoutKeys: 'prefers concise answers' });
+ expect(mockLoadAgent).not.toHaveBeenCalled();
+ expect(mockInitializeAgent).not.toHaveBeenCalled();
+ expect(mockCreateMemoryProcessor).not.toHaveBeenCalled();
+ });
+
+ it('should return undefined when loading memories fails without auto-processing', async () => {
+ const { logger } = require('@librechat/data-schemas');
+ const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger);
+ mockReq.config.memory = {
+ personalize: true,
+ };
+
+ mockCheckAccess.mockResolvedValue(true);
+ mockGetFormattedMemories.mockRejectedValue(new Error('DB connection failed'));
+
+ client = new AgentClient(mockOptions);
+ client.conversationId = 'convo-123';
+ client.responseMessageId = 'response-123';
+
+ const result = await client.useMemory();
+
+ expect(result).toBeUndefined();
+ expect(mockGetFormattedMemories).toHaveBeenCalledWith({ userId: 'user-123' });
+ expect(mockInitializeAgent).not.toHaveBeenCalled();
+ expect(mockCreateMemoryProcessor).not.toHaveBeenCalled();
+ expect(client.processMemory).toBeUndefined();
+ expect(errorSpy).toHaveBeenCalledWith(
+ '[api/server/controllers/agents/client.js #useMemory] Error loading memories',
+ expect.any(Error),
+ );
+ });
+
+ it('should create ephemeral agent when no id but model and provider are specified', async () => {
+ mockReq.config.memory = {
+ agent: {
+ enabled: true,
+ model: 'gpt-4',
+ provider: EModelEndpoint.openAI,
+ },
+ };
+
+ mockCheckAccess.mockResolvedValue(true);
+ mockInitializeAgent.mockResolvedValue({
+ id: Constants.EPHEMERAL_AGENT_ID,
+ model: 'gpt-4',
+ provider: EModelEndpoint.openAI,
+ });
+ mockCreateMemoryProcessor.mockResolvedValue([undefined, jest.fn()]);
+
+ client = new AgentClient(mockOptions);
+ client.conversationId = 'convo-123';
+ client.responseMessageId = 'response-123';
+
+ await client.useMemory();
+
+ expect(mockLoadAgent).not.toHaveBeenCalled();
+ expect(mockInitializeAgent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ agent: expect.objectContaining({
+ id: Constants.EPHEMERAL_AGENT_ID,
+ model: 'gpt-4',
+ provider: EModelEndpoint.openAI,
+ }),
+ }),
+ expect.any(Object),
+ );
+ });
+ });
+});
+
+describe('AgentClient - finalizeSubagentContent', () => {
+ /** Verifies the backend persistence path: per-subagent
+ * `createContentAggregator` instances (populated by the callbacks
+ * ON_SUBAGENT_UPDATE handler) have their `contentParts` harvested
+ * onto the matching parent `subagent` tool_call at message-save time
+ * so a page refresh shows the same activity the user saw live. */
+ const { GraphEvents } = jest.requireActual('@librechat/agents');
+ const { getDefaultHandlers } = require('./callbacks');
+
+ const makeClient = (subagentAggregatorsByToolCallId) => {
+ const client = new AgentClient({
+ req: { user: { id: 'u' }, body: {}, config: { endpoints: {} } },
+ res: {},
+ agent: {
+ id: 'agent',
+ endpoint: EModelEndpoint.openAI,
+ provider: EModelEndpoint.openAI,
+ model_parameters: { model: 'gpt-4' },
+ },
+ contentParts: [],
+ subagentAggregatorsByToolCallId,
+ });
+ return client;
+ };
+
+ const event = (phase, data, parentToolCallId = 'call_sub') => ({
+ runId: 'parent-run',
+ subagentRunId: 'child-run',
+ subagentType: 'self',
+ subagentAgentId: 'child',
+ parentToolCallId,
+ phase,
+ data,
+ timestamp: '2026-04-17T00:00:00Z',
+ });
+
+ /** Feeds a SubagentUpdateEvent sequence through the real
+ * `ON_SUBAGENT_UPDATE` handler so we exercise the same get-or-create
+ * aggregator logic the live request uses, rather than constructing
+ * aggregators directly in the test. */
+ const runSubagentEvents = async (events) => {
+ const map = new Map();
+ const handlers = getDefaultHandlers({
+ res: { write: jest.fn(), writableEnded: false },
+ aggregateContent: jest.fn(),
+ toolEndCallback: jest.fn(),
+ collectedUsage: [],
+ subagentAggregatorsByToolCallId: map,
+ });
+ const handler = handlers[GraphEvents.ON_SUBAGENT_UPDATE];
+ for (const e of events) {
+ await handler.handle(GraphEvents.ON_SUBAGENT_UPDATE, e);
+ }
+ return map;
+ };
+
+ it('attaches aggregated subagent_content to the matching subagent tool_call part', async () => {
+ const buffer = await runSubagentEvents([
+ event('run_step', {
+ id: 'step_msg',
+ index: 0,
+ stepDetails: { type: 'message_creation' },
+ }),
+ event('message_delta', {
+ id: 'step_msg',
+ delta: { content: [{ type: 'text', text: 'Hello ' }] },
+ }),
+ event('message_delta', {
+ id: 'step_msg',
+ delta: { content: [{ type: 'text', text: 'world!' }] },
+ }),
+ event('run_step', {
+ id: 'step_tool',
+ index: 1,
+ stepDetails: {
+ type: 'tool_calls',
+ tool_calls: [{ id: 'inner_1', name: 'calculator', args: '{}' }],
+ },
+ }),
+ event('run_step_completed', {
+ id: 'step_tool',
+ index: 1,
+ result: {
+ id: 'step_tool',
+ type: 'tool_call',
+ tool_call: {
+ id: 'inner_1',
+ name: 'calculator',
+ output: '4',
+ progress: 1,
+ },
+ },
+ }),
+ ]);
+
+ const client = makeClient(buffer);
+ client.contentParts = [
+ {
+ type: 'tool_call',
+ tool_call: {
+ id: 'call_sub',
+ name: Constants.SUBAGENT,
+ args: '{}',
+ output: 'final text',
+ progress: 1,
+ },
+ },
+ ];
+
+ client.finalizeSubagentContent();
+
+ const attached = client.contentParts[0].tool_call.subagent_content;
+ expect(Array.isArray(attached)).toBe(true);
+ expect(attached).toHaveLength(2);
+ expect(attached[0].type).toBe('text');
+ expect(attached[0].text).toBe('Hello world!');
+ expect(attached[1].type).toBe('tool_call');
+ expect(attached[1].tool_call.name).toBe('calculator');
+ expect(attached[1].tool_call.output).toBe('4');
+ /** Buffer drained so a second call (e.g. resumable retry) doesn't
+ * double-append. */
+ expect(buffer.size).toBe(0);
+ });
+
+ it('ignores tool_call parts whose name is not SUBAGENT', async () => {
+ const buffer = await runSubagentEvents([
+ event(
+ 'run_step',
+ {
+ id: 'step_msg',
+ index: 0,
+ stepDetails: { type: 'message_creation' },
+ },
+ 'call_regular',
+ ),
+ event(
+ 'message_delta',
+ {
+ id: 'step_msg',
+ delta: { content: [{ type: 'text', text: 'x' }] },
+ },
+ 'call_regular',
+ ),
+ ]);
+ const client = makeClient(buffer);
+ client.contentParts = [
+ {
+ type: 'tool_call',
+ tool_call: { id: 'call_regular', name: 'calculator', args: '{}' },
+ },
+ ];
+ client.finalizeSubagentContent();
+ expect(client.contentParts[0].tool_call.subagent_content).toBeUndefined();
+ });
+
+ it('is a safe no-op when the aggregator map is empty or missing', () => {
+ const client = makeClient(undefined);
+ client.contentParts = [
+ {
+ type: 'tool_call',
+ tool_call: { id: 'call_sub', name: Constants.SUBAGENT, args: '{}' },
+ },
+ ];
+ expect(() => client.finalizeSubagentContent()).not.toThrow();
+ expect(client.contentParts[0].tool_call.subagent_content).toBeUndefined();
+ });
+
+ it('discards aggregators keyed by a tool_call_id not present in contentParts', async () => {
+ const buffer = await runSubagentEvents([
+ event(
+ 'run_step',
+ {
+ id: 'step_msg',
+ index: 0,
+ stepDetails: { type: 'message_creation' },
+ },
+ 'call_missing',
+ ),
+ event(
+ 'message_delta',
+ {
+ id: 'step_msg',
+ delta: { content: [{ type: 'text', text: 'x' }] },
+ },
+ 'call_missing',
+ ),
+ ]);
+ const client = makeClient(buffer);
+ client.contentParts = [
+ {
+ type: 'tool_call',
+ tool_call: { id: 'call_other', name: Constants.SUBAGENT, args: '{}' },
+ },
+ ];
+ client.finalizeSubagentContent();
+ expect(client.contentParts[0].tool_call.subagent_content).toBeUndefined();
+ });
+
+ it('keeps per-parent tool_call aggregators isolated for parallel subagents', async () => {
+ const buffer = await runSubagentEvents([
+ event(
+ 'run_step',
+ {
+ id: 'step_a',
+ index: 0,
+ stepDetails: { type: 'message_creation' },
+ },
+ 'call_a',
+ ),
+ event(
+ 'message_delta',
+ { id: 'step_a', delta: { content: [{ type: 'text', text: 'A' }] } },
+ 'call_a',
+ ),
+ event(
+ 'run_step',
+ {
+ id: 'step_b',
+ index: 0,
+ stepDetails: { type: 'message_creation' },
+ },
+ 'call_b',
+ ),
+ event(
+ 'message_delta',
+ { id: 'step_b', delta: { content: [{ type: 'text', text: 'B' }] } },
+ 'call_b',
+ ),
+ ]);
+ const client = makeClient(buffer);
+ client.contentParts = [
+ { type: 'tool_call', tool_call: { id: 'call_a', name: Constants.SUBAGENT, args: '{}' } },
+ { type: 'tool_call', tool_call: { id: 'call_b', name: Constants.SUBAGENT, args: '{}' } },
+ ];
+ client.finalizeSubagentContent();
+ expect(client.contentParts[0].tool_call.subagent_content).toEqual([
+ expect.objectContaining({ type: 'text', text: 'A' }),
+ ]);
+ expect(client.contentParts[1].tool_call.subagent_content).toEqual([
+ expect.objectContaining({ type: 'text', text: 'B' }),
+ ]);
+ });
+});
diff --git a/api/server/controllers/agents/errors.js b/api/server/controllers/agents/errors.js
new file mode 100644
index 0000000..b16ce75
--- /dev/null
+++ b/api/server/controllers/agents/errors.js
@@ -0,0 +1,141 @@
+// errorHandler.js
+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 getLogStores = require('~/cache/getLogStores');
+const { getConvo } = require('~/models');
+
+/**
+ * @typedef {Object} ErrorHandlerContext
+ * @property {OpenAIClient} openai - The OpenAI client
+ * @property {string} run_id - The run ID
+ * @property {boolean} completedRun - Whether the run has completed
+ * @property {string} assistant_id - The assistant ID
+ * @property {string} conversationId - The conversation ID
+ * @property {string} parentMessageId - The parent message ID
+ * @property {string} responseMessageId - The response message ID
+ * @property {string} endpoint - The endpoint being used
+ * @property {string} cacheKey - The cache key for the current request
+ */
+
+/**
+ * @typedef {Object} ErrorHandlerDependencies
+ * @property {ServerRequest} req - The Express request object
+ * @property {Express.Response} res - The Express response object
+ * @property {() => ErrorHandlerContext} getContext - Function to get the current context
+ * @property {string} [originPath] - The origin path for the error handler
+ */
+
+/**
+ * Creates an error handler function with the given dependencies
+ * @param {ErrorHandlerDependencies} dependencies - The dependencies for the error handler
+ * @returns {(error: Error) => Promise} The error handler function
+ */
+const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/chat/' }) => {
+ const cache = getLogStores(CacheKeys.ABORT_KEYS);
+
+ /**
+ * Handles errors that occur during the chat process
+ * @param {Error} error - The error that occurred
+ * @returns {Promise}
+ */
+ return async (error) => {
+ const {
+ openai,
+ run_id,
+ endpoint,
+ cacheKey,
+ completedRun,
+ assistant_id,
+ conversationId,
+ parentMessageId,
+ responseMessageId,
+ } = getContext();
+
+ const defaultErrorMessage =
+ 'The Assistant run failed to initialize. Try sending a message in a new conversation.';
+ const messageData = {
+ assistant_id,
+ conversationId,
+ parentMessageId,
+ sender: 'System',
+ user: req.user.id,
+ shouldSaveMessage: false,
+ messageId: responseMessageId,
+ endpoint,
+ };
+
+ if (error.message === 'Run cancelled') {
+ return res.end();
+ } else if (error.message === 'Request closed' && completedRun) {
+ return;
+ } else if (error.message === 'Request closed') {
+ logger.debug(`[${originPath}] Request aborted on close`);
+ } else if (/Files.*are invalid/.test(error.message)) {
+ const errorMessage = `Files are invalid, or may not have uploaded yet.${
+ endpoint === 'azureAssistants'
+ ? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
+ : ''
+ }`;
+ return sendResponse(req, res, messageData, errorMessage);
+ } else if (error?.message?.includes('string too long')) {
+ return sendResponse(
+ req,
+ res,
+ messageData,
+ 'Message too long. The Assistants API has a limit of 32,768 characters per message. Please shorten it and try again.',
+ );
+ } else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
+ return sendResponse(req, res, messageData, error.message);
+ } else {
+ logger.error(`[${originPath}]`, error);
+ }
+
+ if (!openai || !run_id) {
+ return sendResponse(req, res, messageData, defaultErrorMessage);
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ try {
+ const status = await cache.get(cacheKey);
+ if (status === 'cancelled') {
+ logger.debug(`[${originPath}] Run already cancelled`);
+ return res.end();
+ }
+ await cache.delete(cacheKey);
+ } catch (error) {
+ logger.error(`[${originPath}] Error cancelling run`, error);
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ let run;
+ try {
+ await recordUsage({
+ ...run.usage,
+ model: run.model,
+ user: req.user.id,
+ conversationId,
+ });
+ } catch (error) {
+ logger.error(`[${originPath}] Error fetching or processing run`, error);
+ }
+
+ let finalEvent;
+ try {
+ finalEvent = {
+ final: true,
+ conversation: await getConvo(req.user.id, conversationId),
+ };
+ } catch (error) {
+ logger.error(`[${originPath}] Error finalizing error process`, error);
+ return sendResponse(req, res, messageData, 'The Assistant run failed');
+ }
+
+ return sendResponse(req, res, finalEvent);
+ };
+};
+
+module.exports = { createErrorHandler };
diff --git a/api/server/controllers/agents/filterAuthorizedTools.spec.js b/api/server/controllers/agents/filterAuthorizedTools.spec.js
new file mode 100644
index 0000000..677bccd
--- /dev/null
+++ b/api/server/controllers/agents/filterAuthorizedTools.spec.js
@@ -0,0 +1,923 @@
+const mongoose = require('mongoose');
+const { v4: uuidv4 } = require('uuid');
+const { Constants, actionDelimiter } = require('librechat-data-provider');
+const { agentSchema } = require('@librechat/data-schemas');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+
+const d = Constants.mcp_delimiter;
+
+const mockGetAllServerConfigs = jest.fn();
+const mockUserCanUseMCPServers = jest.fn();
+
+jest.mock('~/server/services/Config', () => ({
+ getCachedTools: jest.fn().mockResolvedValue({
+ web_search: true,
+ execute_code: true,
+ file_search: true,
+ }),
+}));
+
+jest.mock('~/config', () => ({
+ getMCPServersRegistry: jest.fn(() => ({
+ getAllServerConfigs: mockGetAllServerConfigs,
+ })),
+}));
+
+jest.mock('~/server/services/MCP', () => ({
+ resolveConfigServers: jest.fn().mockResolvedValue({}),
+ createMCPPermissionContext: jest.fn((req) => ({
+ canUseServers: (user) => mockUserCanUseMCPServers(user, req),
+ })),
+ userCanUseMCPServers: (...args) => mockUserCanUseMCPServers(...args),
+}));
+
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/images/avatar', () => ({
+ resizeAvatar: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/process', () => ({
+ filterFile: jest.fn(),
+}));
+
+jest.mock('~/server/services/PermissionService', () => ({
+ findAccessibleResources: jest.fn().mockResolvedValue([]),
+ findPubliclyAccessibleResources: jest.fn().mockResolvedValue([]),
+ grantPermission: jest.fn(),
+ hasPublicPermission: jest.fn().mockResolvedValue(false),
+ checkPermission: jest.fn().mockResolvedValue(true),
+}));
+
+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(() => ({
+ get: jest.fn(),
+ set: jest.fn(),
+ delete: jest.fn(),
+ })),
+}));
+
+const {
+ filterAuthorizedTools,
+ createAgent: createAgentHandler,
+ updateAgent: updateAgentHandler,
+ duplicateAgent: duplicateAgentHandler,
+ revertAgentVersion: revertAgentVersionHandler,
+} = require('./v1');
+
+const { getMCPServersRegistry } = require('~/config');
+
+let Agent;
+
+describe('MCP Tool Authorization', () => {
+ let mongoServer;
+ let mockReq;
+ let mockRes;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+ Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
+ }, 20000);
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await Agent.deleteMany({});
+ jest.clearAllMocks();
+
+ getMCPServersRegistry.mockImplementation(() => ({
+ getAllServerConfigs: mockGetAllServerConfigs,
+ }));
+ mockGetAllServerConfigs.mockResolvedValue({
+ authorizedServer: { type: 'sse', url: 'https://authorized.example.com' },
+ anotherServer: { type: 'sse', url: 'https://another.example.com' },
+ });
+ mockUserCanUseMCPServers.mockResolvedValue(true);
+
+ mockReq = {
+ user: {
+ id: new mongoose.Types.ObjectId().toString(),
+ role: 'USER',
+ },
+ body: {},
+ params: {},
+ query: {},
+ app: { locals: { fileStrategy: 'local' } },
+ };
+
+ mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+ });
+
+ describe('filterAuthorizedTools', () => {
+ const availableTools = { web_search: true, custom_tool: true };
+ const userId = 'test-user-123';
+ const testUser = { id: userId, role: 'USER' };
+
+ test('should keep authorized MCP tools and strip unauthorized ones', async () => {
+ const result = await filterAuthorizedTools({
+ tools: [`toolA${d}authorizedServer`, `toolB${d}forbiddenServer`, 'web_search'],
+ userId,
+ user: testUser,
+ availableTools,
+ });
+
+ expect(result).toContain(`toolA${d}authorizedServer`);
+ expect(result).toContain('web_search');
+ expect(result).not.toContain(`toolB${d}forbiddenServer`);
+ });
+
+ test('should strip MCP tools when user lacks MCP server use permission', async () => {
+ mockUserCanUseMCPServers.mockResolvedValue(false);
+
+ const result = await filterAuthorizedTools({
+ tools: [
+ `toolA${d}authorizedServer`,
+ `${Constants.mcp_all}${d}authorizedServer`,
+ 'web_search',
+ ],
+ userId,
+ user: testUser,
+ availableTools,
+ });
+
+ expect(result).toEqual(['web_search']);
+ expect(mockUserCanUseMCPServers).toHaveBeenCalledWith({ id: userId, role: 'USER' });
+ expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
+ });
+
+ test('should strip MCP tools when user context is missing', async () => {
+ mockUserCanUseMCPServers.mockResolvedValueOnce(false);
+
+ const result = await filterAuthorizedTools({
+ tools: [`toolA${d}authorizedServer`, 'web_search'],
+ userId,
+ availableTools,
+ });
+
+ expect(result).toEqual(['web_search']);
+ expect(mockUserCanUseMCPServers).toHaveBeenCalledWith(undefined);
+ expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
+ });
+
+ test('should keep system tools without querying MCP registry', async () => {
+ const result = await filterAuthorizedTools({
+ tools: ['execute_code', 'file_search', 'web_search', 'memory'],
+ userId,
+ availableTools: {},
+ });
+
+ expect(result).toEqual(['execute_code', 'file_search', 'web_search', 'memory']);
+ expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
+ });
+
+ test('should not query MCP registry when no MCP tools are present', async () => {
+ const result = await filterAuthorizedTools({
+ tools: ['web_search', 'custom_tool'],
+ userId,
+ availableTools,
+ });
+
+ expect(result).toEqual(['web_search', 'custom_tool']);
+ expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
+ });
+
+ test('should filter all MCP tools when registry is uninitialized', async () => {
+ getMCPServersRegistry.mockImplementation(() => {
+ throw new Error('MCPServersRegistry has not been initialized.');
+ });
+
+ const result = await filterAuthorizedTools({
+ tools: [`toolA${d}someServer`, 'web_search'],
+ userId,
+ user: testUser,
+ availableTools,
+ });
+
+ expect(result).toEqual(['web_search']);
+ expect(result).not.toContain(`toolA${d}someServer`);
+ });
+
+ test('should handle mixed authorized and unauthorized MCP tools', async () => {
+ const result = await filterAuthorizedTools({
+ tools: [
+ 'web_search',
+ `search${d}authorizedServer`,
+ `attack${d}victimServer`,
+ 'execute_code',
+ `list${d}anotherServer`,
+ `steal${d}nonexistent`,
+ ],
+ userId,
+ user: testUser,
+ availableTools,
+ });
+
+ expect(result).toEqual([
+ 'web_search',
+ `search${d}authorizedServer`,
+ 'execute_code',
+ `list${d}anotherServer`,
+ ]);
+ });
+
+ test('should handle empty tools array', async () => {
+ const result = await filterAuthorizedTools({
+ tools: [],
+ userId,
+ availableTools,
+ });
+
+ expect(result).toEqual([]);
+ expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
+ });
+
+ test('should handle null/undefined tool entries gracefully', async () => {
+ const result = await filterAuthorizedTools({
+ tools: [null, undefined, '', 'web_search'],
+ userId,
+ availableTools,
+ });
+
+ expect(result).toEqual(['web_search']);
+ });
+
+ test('should call getAllServerConfigs with the correct userId', async () => {
+ await filterAuthorizedTools({
+ tools: [`tool${d}authorizedServer`],
+ userId: 'specific-user-id',
+ user: { id: 'specific-user-id', role: 'USER' },
+ availableTools,
+ });
+
+ 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,
+ user: testUser,
+ 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 () => {
+ await filterAuthorizedTools({
+ tools: [`tool1${d}authorizedServer`, `tool2${d}anotherServer`, `tool3${d}unknownServer`],
+ userId,
+ user: testUser,
+ availableTools,
+ });
+
+ expect(mockGetAllServerConfigs).toHaveBeenCalledTimes(1);
+ });
+
+ test('should preserve existing MCP tools when registry is unavailable', async () => {
+ getMCPServersRegistry.mockImplementation(() => {
+ throw new Error('MCPServersRegistry has not been initialized.');
+ });
+
+ const existingTools = [`toolA${d}serverA`, `toolB${d}serverB`];
+
+ const result = await filterAuthorizedTools({
+ tools: [...existingTools, `newTool${d}unknownServer`, 'web_search'],
+ userId,
+ user: testUser,
+ availableTools,
+ existingTools,
+ });
+
+ expect(result).toContain(`toolA${d}serverA`);
+ expect(result).toContain(`toolB${d}serverB`);
+ expect(result).toContain('web_search');
+ expect(result).not.toContain(`newTool${d}unknownServer`);
+ });
+
+ test('should still reject all MCP tools when registry is unavailable and no existingTools', async () => {
+ getMCPServersRegistry.mockImplementation(() => {
+ throw new Error('MCPServersRegistry has not been initialized.');
+ });
+
+ const result = await filterAuthorizedTools({
+ tools: [`toolA${d}serverA`, 'web_search'],
+ userId,
+ user: testUser,
+ availableTools,
+ });
+
+ expect(result).toEqual(['web_search']);
+ });
+
+ test('should not preserve malformed existing tools when registry is unavailable', async () => {
+ getMCPServersRegistry.mockImplementation(() => {
+ throw new Error('MCPServersRegistry has not been initialized.');
+ });
+
+ const malformedTool = `a${d}b${d}c`;
+ const result = await filterAuthorizedTools({
+ tools: [malformedTool, `legit${d}serverA`, 'web_search'],
+ userId,
+ user: testUser,
+ availableTools,
+ existingTools: [malformedTool, `legit${d}serverA`],
+ });
+
+ expect(result).toContain(`legit${d}serverA`);
+ expect(result).toContain('web_search');
+ expect(result).not.toContain(malformedTool);
+ });
+
+ test('should gate app-level MCP tools present in the global tool cache', async () => {
+ const appMcpTool = `appTool${d}authorizedServer`;
+ const forbiddenAppMcpTool = `appTool${d}forbiddenServer`;
+ const cacheWithMCPTools = {
+ ...availableTools,
+ [appMcpTool]: true,
+ [forbiddenAppMcpTool]: true,
+ };
+
+ const result = await filterAuthorizedTools({
+ tools: [appMcpTool, forbiddenAppMcpTool, 'web_search'],
+ userId,
+ user: testUser,
+ availableTools: cacheWithMCPTools,
+ });
+
+ expect(result).toContain(appMcpTool);
+ expect(result).toContain('web_search');
+ expect(result).not.toContain(forbiddenAppMcpTool);
+ });
+
+ test('should strip app-level MCP tools from the cache when user lacks MCP server use permission', async () => {
+ mockUserCanUseMCPServers.mockResolvedValue(false);
+ const appMcpTool = `appTool${d}authorizedServer`;
+ const cacheWithMCPTools = { ...availableTools, [appMcpTool]: true };
+
+ const result = await filterAuthorizedTools({
+ tools: [appMcpTool, 'web_search'],
+ userId,
+ user: testUser,
+ availableTools: cacheWithMCPTools,
+ });
+
+ expect(result).toEqual(['web_search']);
+ expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
+ });
+
+ test('should reject malformed MCP tool keys with multiple delimiters', async () => {
+ const result = await filterAuthorizedTools({
+ tools: [
+ `attack${d}victimServer${d}authorizedServer`,
+ `legit${d}authorizedServer`,
+ `a${d}b${d}c${d}d`,
+ 'web_search',
+ ],
+ userId,
+ user: testUser,
+ availableTools,
+ });
+
+ expect(result).toEqual([`legit${d}authorizedServer`, 'web_search']);
+ expect(result).not.toContainEqual(expect.stringContaining('victimServer'));
+ expect(result).not.toContainEqual(expect.stringContaining(`a${d}b`));
+ });
+ });
+
+ describe('createAgentHandler - MCP tool authorization', () => {
+ test('should strip unauthorized MCP tools on create', async () => {
+ mockReq.body = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'MCP Test Agent',
+ tools: ['web_search', `validTool${d}authorizedServer`, `attack${d}forbiddenServer`],
+ };
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+ const agent = mockRes.json.mock.calls[0][0];
+ expect(agent.tools).toContain('web_search');
+ expect(agent.tools).toContain(`validTool${d}authorizedServer`);
+ expect(agent.tools).not.toContain(`attack${d}forbiddenServer`);
+ });
+
+ test('should strip all MCP tools on create when user lacks MCP server use permission', async () => {
+ mockUserCanUseMCPServers.mockResolvedValue(false);
+ mockReq.body = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'MCP Denied Test Agent',
+ tools: [
+ 'web_search',
+ `validTool${d}authorizedServer`,
+ `${Constants.mcp_all}${d}authorizedServer`,
+ ],
+ };
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+ const agent = mockRes.json.mock.calls[0][0];
+ expect(agent.tools).toEqual(['web_search']);
+ expect(agent.mcpServerNames).toEqual([]);
+ });
+
+ test('should not 500 when MCP registry is uninitialized', async () => {
+ getMCPServersRegistry.mockImplementation(() => {
+ throw new Error('MCPServersRegistry has not been initialized.');
+ });
+
+ mockReq.body = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'MCP Uninitialized Test',
+ tools: [`tool${d}someServer`, 'web_search'],
+ };
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+ const agent = mockRes.json.mock.calls[0][0];
+ expect(agent.tools).toEqual(['web_search']);
+ });
+
+ test('should store mcpServerNames only for authorized servers', async () => {
+ mockReq.body = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'MCP Names Test',
+ tools: [`toolA${d}authorizedServer`, `toolB${d}forbiddenServer`],
+ };
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+ const agent = mockRes.json.mock.calls[0][0];
+ const agentInDb = await Agent.findOne({ id: agent.id });
+ expect(agentInDb.mcpServerNames).toContain('authorizedServer');
+ expect(agentInDb.mcpServerNames).not.toContain('forbiddenServer');
+ });
+ });
+
+ describe('updateAgentHandler - MCP tool authorization', () => {
+ let existingAgentId;
+ let existingAgentAuthorId;
+
+ beforeEach(async () => {
+ existingAgentAuthorId = new mongoose.Types.ObjectId();
+ const agent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Original Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: existingAgentAuthorId,
+ tools: ['web_search', `existingTool${d}authorizedServer`],
+ mcpServerNames: ['authorizedServer'],
+ versions: [
+ {
+ name: 'Original Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: ['web_search', `existingTool${d}authorizedServer`],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+ existingAgentId = agent.id;
+ });
+
+ test('should preserve existing MCP tools even if editor lacks access', async () => {
+ mockGetAllServerConfigs.mockResolvedValue({});
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ tools: ['web_search', `existingTool${d}authorizedServer`],
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
+ expect(updatedAgent.tools).toContain('web_search');
+ });
+
+ test('should reject newly added unauthorized MCP tools', async () => {
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ tools: ['web_search', `existingTool${d}authorizedServer`, `attack${d}forbiddenServer`],
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.tools).toContain('web_search');
+ expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
+ expect(updatedAgent.tools).not.toContain(`attack${d}forbiddenServer`);
+ });
+
+ test('should strip all MCP tools, including retained ones, when user lacks MCP server use permission', async () => {
+ mockUserCanUseMCPServers.mockResolvedValue(false);
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ tools: ['web_search', `existingTool${d}authorizedServer`, `newTool${d}anotherServer`],
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ // Permission revoked: update must not preserve stale MCP bindings, matching
+ // the create/duplicate/revert paths.
+ expect(updatedAgent.tools).toEqual(['web_search']);
+ expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
+ });
+
+ test('should strip retained MCP tools on an unrelated owner edit after permission revocation', async () => {
+ mockUserCanUseMCPServers.mockResolvedValue(false);
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ name: 'Renamed After Revocation',
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.tools).toEqual(['web_search']);
+ expect(updatedAgent.name).toBe('Renamed After Revocation');
+ });
+
+ test('should not strip shared agent MCP tools on unrelated editor changes after revocation', async () => {
+ mockUserCanUseMCPServers.mockResolvedValue(false);
+ mockReq.user.id = new mongoose.Types.ObjectId().toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ name: 'Shared Rename After Revocation',
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
+ expect(updatedAgent.name).toBe('Shared Rename After Revocation');
+ expect(agentInDb.tools).toContain(`existingTool${d}authorizedServer`);
+ expect(agentInDb.mcpServerNames).toEqual(['authorizedServer']);
+ });
+
+ test('should not strip shared agent MCP tools on frontend-style full tools save after revocation', async () => {
+ mockUserCanUseMCPServers.mockResolvedValue(false);
+ mockReq.user.id = new mongoose.Types.ObjectId().toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ name: 'Shared Full Save After Revocation',
+ tools: ['web_search', `existingTool${d}authorizedServer`],
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
+ expect(updatedAgent.name).toBe('Shared Full Save After Revocation');
+ expect(agentInDb.tools).toContain(`existingTool${d}authorizedServer`);
+ expect(agentInDb.mcpServerNames).toEqual(['authorizedServer']);
+ expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
+ });
+
+ test('should reject new shared-agent MCP tools after revocation while retaining existing MCP tools', async () => {
+ mockUserCanUseMCPServers.mockResolvedValue(false);
+ mockReq.user.id = new mongoose.Types.ObjectId().toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ tools: ['web_search', `existingTool${d}authorizedServer`, `newTool${d}anotherServer`],
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
+ expect(updatedAgent.tools).not.toContain(`newTool${d}anotherServer`);
+ expect(agentInDb.tools).toContain(`existingTool${d}authorizedServer`);
+ expect(agentInDb.tools).not.toContain(`newTool${d}anotherServer`);
+ expect(agentInDb.mcpServerNames).toEqual(['authorizedServer']);
+ expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
+ });
+
+ test('should not strip action tools whose operationId contains the MCP delimiter on revocation', async () => {
+ // `sync_mcp_state_action_...` contains the `_mcp_` substring but is a
+ // genuine OpenAPI action tool (isActionTool === true). Losing
+ // MCP_SERVERS.USE must not drop it — action use is unrelated to MCP.
+ const actionTool = `sync_mcp_state${actionDelimiter}api---example---com`;
+ await Agent.updateOne(
+ { id: existingAgentId },
+ { $set: { tools: ['web_search', actionTool] } },
+ );
+
+ mockUserCanUseMCPServers.mockResolvedValue(false);
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ name: 'Edited Without MCP Permission',
+ tools: ['web_search', actionTool],
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(updatedAgent.tools).toContain(actionTool);
+ expect(updatedAgent.tools).toContain('web_search');
+ expect(agentInDb.mcpServerNames).toEqual([]);
+ });
+
+ test('should allow adding authorized MCP tools', async () => {
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ tools: ['web_search', `existingTool${d}authorizedServer`, `newTool${d}anotherServer`],
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.tools).toContain(`newTool${d}anotherServer`);
+ });
+
+ test('should not query MCP registry when no new MCP tools added', async () => {
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ tools: ['web_search', `existingTool${d}authorizedServer`],
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
+ });
+
+ test('should preserve existing MCP tools when registry unavailable and user edits agent', async () => {
+ getMCPServersRegistry.mockImplementation(() => {
+ throw new Error('MCPServersRegistry has not been initialized.');
+ });
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ name: 'Renamed After Restart',
+ tools: ['web_search', `existingTool${d}authorizedServer`],
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
+ expect(updatedAgent.tools).toContain('web_search');
+ expect(updatedAgent.name).toBe('Renamed After Restart');
+ });
+
+ test('should preserve existing MCP tools when server not in configs (disconnected)', async () => {
+ mockGetAllServerConfigs.mockResolvedValue({});
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ name: 'Edited While Disconnected',
+ tools: ['web_search', `existingTool${d}authorizedServer`],
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
+ expect(updatedAgent.name).toBe('Edited While Disconnected');
+ });
+ });
+
+ describe('duplicateAgentHandler - MCP tool authorization', () => {
+ let sourceAgentId;
+ let sourceAgentAuthorId;
+
+ beforeEach(async () => {
+ sourceAgentAuthorId = new mongoose.Types.ObjectId();
+ const agent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Source Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: sourceAgentAuthorId,
+ tools: ['web_search', `tool${d}authorizedServer`, `tool${d}forbiddenServer`],
+ mcpServerNames: ['authorizedServer', 'forbiddenServer'],
+ versions: [
+ {
+ name: 'Source Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: ['web_search', `tool${d}authorizedServer`, `tool${d}forbiddenServer`],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+ sourceAgentId = agent.id;
+ });
+
+ test('should strip unauthorized MCP tools from duplicated agent', async () => {
+ mockGetAllServerConfigs.mockResolvedValue({
+ authorizedServer: { type: 'sse' },
+ });
+
+ mockReq.user.id = sourceAgentAuthorId.toString();
+ mockReq.params.id = sourceAgentId;
+
+ await duplicateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+ const { agent: newAgent } = mockRes.json.mock.calls[0][0];
+ expect(newAgent.id).not.toBe(sourceAgentId);
+ expect(newAgent.tools).toContain('web_search');
+ expect(newAgent.tools).toContain(`tool${d}authorizedServer`);
+ expect(newAgent.tools).not.toContain(`tool${d}forbiddenServer`);
+
+ const agentInDb = await Agent.findOne({ id: newAgent.id });
+ expect(agentInDb.mcpServerNames).toContain('authorizedServer');
+ expect(agentInDb.mcpServerNames).not.toContain('forbiddenServer');
+ });
+
+ test('should preserve source agent MCP tools when registry is unavailable', async () => {
+ getMCPServersRegistry.mockImplementation(() => {
+ throw new Error('MCPServersRegistry has not been initialized.');
+ });
+
+ mockReq.user.id = sourceAgentAuthorId.toString();
+ mockReq.params.id = sourceAgentId;
+
+ await duplicateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+ const { agent: newAgent } = mockRes.json.mock.calls[0][0];
+ expect(newAgent.tools).toContain('web_search');
+ expect(newAgent.tools).toContain(`tool${d}authorizedServer`);
+ expect(newAgent.tools).toContain(`tool${d}forbiddenServer`);
+ });
+ });
+
+ describe('revertAgentVersionHandler - MCP tool authorization', () => {
+ let existingAgentId;
+ let existingAgentAuthorId;
+
+ beforeEach(async () => {
+ existingAgentAuthorId = new mongoose.Types.ObjectId();
+ const agent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Reverted Agent V2',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: existingAgentAuthorId,
+ tools: ['web_search'],
+ versions: [
+ {
+ name: 'Reverted Agent V1',
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: ['web_search', `oldTool${d}revokedServer`],
+ createdAt: new Date(Date.now() - 10000),
+ updatedAt: new Date(Date.now() - 10000),
+ },
+ {
+ name: 'Reverted Agent V2',
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: ['web_search'],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+ existingAgentId = agent.id;
+ });
+
+ test('should strip unauthorized MCP tools after reverting to a previous version', async () => {
+ mockGetAllServerConfigs.mockResolvedValue({
+ authorizedServer: { type: 'sse' },
+ });
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = { version_index: 0 };
+
+ await revertAgentVersionHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+ const result = mockRes.json.mock.calls[0][0];
+ expect(result.tools).toContain('web_search');
+ expect(result.tools).not.toContain(`oldTool${d}revokedServer`);
+
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(agentInDb.tools).toContain('web_search');
+ expect(agentInDb.tools).not.toContain(`oldTool${d}revokedServer`);
+ });
+
+ test('should keep authorized MCP tools after revert', async () => {
+ await Agent.updateOne(
+ { id: existingAgentId },
+ { $set: { 'versions.0.tools': ['web_search', `tool${d}authorizedServer`] } },
+ );
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = { version_index: 0 };
+
+ await revertAgentVersionHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+ const result = mockRes.json.mock.calls[0][0];
+ expect(result.tools).toContain('web_search');
+ expect(result.tools).toContain(`tool${d}authorizedServer`);
+ });
+
+ test('should preserve version MCP tools when registry is unavailable on revert', async () => {
+ await Agent.updateOne(
+ { id: existingAgentId },
+ {
+ $set: {
+ 'versions.0.tools': [
+ 'web_search',
+ `validTool${d}authorizedServer`,
+ `otherTool${d}anotherServer`,
+ ],
+ },
+ },
+ );
+
+ getMCPServersRegistry.mockImplementation(() => {
+ throw new Error('MCPServersRegistry has not been initialized.');
+ });
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = { version_index: 0 };
+
+ await revertAgentVersionHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+ const result = mockRes.json.mock.calls[0][0];
+ expect(result.tools).toContain('web_search');
+ expect(result.tools).toContain(`validTool${d}authorizedServer`);
+ expect(result.tools).toContain(`otherTool${d}anotherServer`);
+
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(agentInDb.tools).toContain(`validTool${d}authorizedServer`);
+ expect(agentInDb.tools).toContain(`otherTool${d}anotherServer`);
+ });
+ });
+});
diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js
new file mode 100644
index 0000000..9000c24
--- /dev/null
+++ b/api/server/controllers/agents/openai.js
@@ -0,0 +1,1002 @@
+const { nanoid } = require('nanoid');
+const { logger } = require('@librechat/data-schemas');
+const { Callback, ToolEndHandler, formatAgentMessages } = require('@librechat/agents');
+const {
+ EModelEndpoint,
+ ResourceType,
+ PermissionBits,
+ hasPermissions,
+ AgentCapabilities,
+} = require('librechat-data-provider');
+const {
+ writeSSE,
+ createRun,
+ createChunk,
+ buildToolSet,
+ loadSkillStates,
+ sendFinalChunk,
+ createSafeUser,
+ validateRequest,
+ initializeAgent,
+ getBalanceConfig,
+ injectSkillPrimes,
+ extractManualSkills,
+ createErrorResponse,
+ recordCollectedUsage,
+ createSubagentUsageSink,
+ getTransactionsConfig,
+ resolveRecursionLimit,
+ findPiiMatchInMessages,
+ discoverConnectedAgents,
+ getRemoteAgentPermissions,
+ createToolExecuteHandler,
+ buildNonStreamingResponse,
+ createOpenAIStreamTracker,
+ resolveAgentScopedSkillIds,
+ createOpenAIContentAggregator,
+ isChatCompletionValidationFailure,
+} = require('@librechat/api');
+const {
+ buildSummarizationHandlers,
+ markSummarizationUsage,
+ createToolEndCallback,
+ agentLogHandlerObj,
+} = require('~/server/controllers/agents/callbacks');
+const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
+const {
+ findAccessibleResources,
+ getEffectivePermissions,
+} = require('~/server/services/PermissionService');
+const {
+ getSkillToolDeps,
+ getSkillDbMethods,
+ canAuthorSkillFiles,
+ withDeploymentSkillIds,
+ buildAgentToolContext,
+ enrichLoadedToolsWithAgentContext,
+} = require('~/server/services/Endpoints/agents/skillDeps');
+const { getModelsConfig } = require('~/server/controllers/ModelController');
+const { logViolation } = require('~/cache');
+const db = require('~/models');
+
+/**
+ * Creates a tool loader function for the agent.
+ * @param {AbortSignal} signal - The abort signal
+ * @param {boolean} [definitionsOnly=true] - When true, returns only serializable
+ * tool definitions without creating full tool instances (for event-driven mode)
+ */
+function createToolLoader(signal, definitionsOnly = true) {
+ return async function loadTools({
+ req,
+ res,
+ tools,
+ model,
+ agentId,
+ provider,
+ tool_options,
+ tool_resources,
+ }) {
+ const agent = { id: agentId, tools, provider, model, tool_options };
+ try {
+ return await loadAgentTools({
+ req,
+ res,
+ agent,
+ signal,
+ tool_resources,
+ definitionsOnly,
+ streamId: null, // No resumable stream for OpenAI compat
+ });
+ } catch (error) {
+ logger.error('Error loading tools for agent ' + agentId, error);
+ }
+ };
+}
+
+/**
+ * Convert content part to internal format
+ * @param {Object} part - Content part
+ * @returns {Object} Converted part
+ */
+function convertContentPart(part) {
+ if (part.type === 'text') {
+ return { type: 'text', text: part.text };
+ }
+ if (part.type === 'image_url') {
+ return { type: 'image_url', image_url: part.image_url };
+ }
+ return part;
+}
+
+/**
+ * Convert OpenAI messages to internal format
+ * @param {Array} messages - OpenAI format messages
+ * @returns {Array} Internal format messages
+ */
+function convertMessages(messages) {
+ return messages.map((msg) => {
+ let content;
+ if (typeof msg.content === 'string') {
+ content = msg.content;
+ } else if (msg.content) {
+ content = msg.content.map(convertContentPart);
+ } else {
+ content = '';
+ }
+
+ return {
+ role: msg.role,
+ content,
+ ...(msg.name && { name: msg.name }),
+ ...(msg.tool_calls && { tool_calls: msg.tool_calls }),
+ ...(msg.tool_call_id && { tool_call_id: msg.tool_call_id }),
+ };
+ });
+}
+
+/**
+ * Send an error response in OpenAI format
+ */
+function sendErrorResponse(res, statusCode, message, type = 'invalid_request_error', code = null) {
+ res.status(statusCode).json(createErrorResponse(message, type, code));
+}
+
+/**
+ * OpenAI-compatible chat completions controller for agents.
+ *
+ * POST /v1/chat/completions
+ *
+ * Request format:
+ * {
+ * "model": "agent_id_here",
+ * "messages": [{"role": "user", "content": "Hello!"}],
+ * "stream": true,
+ * "conversation_id": "optional",
+ * "parent_message_id": "optional"
+ * }
+ */
+const OpenAIChatCompletionController = async (req, res) => {
+ const appConfig = req.config;
+ const requestStartTime = Date.now();
+
+ const validation = validateRequest(req.body);
+ if (isChatCompletionValidationFailure(validation)) {
+ return sendErrorResponse(res, 400, validation.error);
+ }
+
+ const request = validation.request;
+ const agentId = request.model;
+
+ // Look up the agent
+ const agent = await db.getAgent({ id: agentId });
+ if (!agent) {
+ return sendErrorResponse(
+ res,
+ 404,
+ `Agent not found: ${agentId}`,
+ 'invalid_request_error',
+ 'model_not_found',
+ );
+ }
+
+ const piiHit = findPiiMatchInMessages(request.messages, appConfig?.messageFilter?.pii);
+ if (piiHit != null) {
+ return sendErrorResponse(
+ res,
+ 400,
+ `Message contains a ${piiHit.label}. Remove it and try again.`,
+ 'invalid_request_error',
+ 'message_filter_pii_block',
+ );
+ }
+
+ const responseId = `chatcmpl-${nanoid()}`;
+ const created = Math.floor(Date.now() / 1000);
+
+ /** @type {import('@librechat/api').OpenAIResponseContext} — key must be `requestId` to match the type used by createChunk/buildNonStreamingResponse */
+ const context = {
+ created,
+ requestId: responseId,
+ model: agentId,
+ };
+
+ logger.debug(
+ `[OpenAI API] Response ${responseId} started for agent ${agentId}, stream: ${request.stream}`,
+ );
+
+ // Set up abort controller
+ const abortController = new AbortController();
+
+ // Handle client disconnect
+ req.on('close', () => {
+ if (!abortController.signal.aborted) {
+ abortController.abort();
+ logger.debug('[OpenAI API] Client disconnected, aborting');
+ }
+ });
+
+ try {
+ if (request.conversation_id != null) {
+ if (typeof request.conversation_id !== 'string') {
+ return sendErrorResponse(
+ res,
+ 400,
+ 'conversation_id must be a string',
+ 'invalid_request_error',
+ );
+ }
+ if (!(await db.getConvo(req.user?.id, request.conversation_id))) {
+ return sendErrorResponse(res, 404, 'Conversation not found', 'invalid_request_error');
+ }
+ }
+
+ const conversationId = request.conversation_id ?? nanoid();
+ const parentMessageId = request.parent_message_id ?? null;
+
+ const agentsEConfig = appConfig?.endpoints?.[EModelEndpoint.agents];
+ const allowedProviders = new Set(agentsEConfig?.allowedProviders);
+
+ // Create tool loader
+ const loadTools = createToolLoader(abortController.signal);
+
+ // Initialize the agent first to check for disableStreaming
+ const endpointOption = {
+ endpoint: agent.provider,
+ model_parameters: agent.model_parameters ?? {},
+ };
+ const skillDbMethods = getSkillDbMethods();
+
+ // `filterFilesByAgentAccess` is intentionally omitted: it calls
+ // `checkPermission` with `resourceType: AGENT`, but this route
+ // authorizes callers through `REMOTE_AGENT` (via
+ // `getRemoteAgentPermissions`), so including it would silently drop
+ // owner-attached context files for any remote user who has
+ // `REMOTE_AGENT_VIEWER` but not direct `AGENT_VIEW`.
+ const dbMethods = {
+ getConvoFiles: db.getConvoFiles,
+ getFiles: db.getFiles,
+ getUserKey: db.getUserKey,
+ getMessages: db.getMessages,
+ updateFilesUsage: db.updateFilesUsage,
+ getUserKeyValues: db.getUserKeyValues,
+ getUserCodeFiles: db.getUserCodeFiles,
+ getToolFilesByIds: db.getToolFilesByIds,
+ getCodeGeneratedFiles: db.getCodeGeneratedFiles,
+ listSkillsByAccess: skillDbMethods.listSkillsByAccess,
+ listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
+ getSkillByName: skillDbMethods.getSkillByName,
+ };
+
+ const enabledCapabilities = new Set(agentsEConfig?.capabilities);
+ const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
+ const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true;
+ const accessibleSkillIds = skillsCapabilityEnabled
+ ? withDeploymentSkillIds(
+ await findAccessibleResources({
+ userId: req.user.id,
+ role: req.user.role,
+ resourceType: ResourceType.SKILL,
+ requiredPermissions: PermissionBits.VIEW,
+ }),
+ )
+ : [];
+ const editableSkillIds = skillsCapabilityEnabled
+ ? await findAccessibleResources({
+ userId: req.user.id,
+ role: req.user.role,
+ resourceType: ResourceType.SKILL,
+ requiredPermissions: PermissionBits.EDIT,
+ })
+ : [];
+ const skillCreateAllowed = skillsCapabilityEnabled
+ ? await getSkillToolDeps().canCreateSkill({ req })
+ : false;
+
+ const { skillStates, defaultActiveOnShare } = await loadSkillStates({
+ userId: req.user.id,
+ appConfig,
+ getUserById: db.getUserById,
+ accessibleSkillIds,
+ });
+
+ const manualSkills = extractManualSkills(req.body);
+
+ const primaryScopedSkillIds = resolveAgentScopedSkillIds({
+ agent,
+ accessibleSkillIds,
+ skillsCapabilityEnabled,
+ ephemeralSkillsToggle,
+ });
+ const primaryScopedEditableSkillIds = resolveAgentScopedSkillIds({
+ agent,
+ accessibleSkillIds: editableSkillIds,
+ skillsCapabilityEnabled,
+ ephemeralSkillsToggle,
+ });
+
+ const primaryConfig = await initializeAgent(
+ {
+ req,
+ res,
+ loadTools,
+ requestFiles: [],
+ conversationId,
+ parentMessageId,
+ agent,
+ endpointOption,
+ allowedProviders,
+ isInitialAgent: true,
+ accessibleSkillIds: primaryScopedSkillIds,
+ skillAuthoringAvailable: canAuthorSkillFiles({
+ agent,
+ scopedEditableSkillIds: primaryScopedEditableSkillIds,
+ skillCreateAllowed,
+ skillsCapabilityEnabled,
+ ephemeralSkillsToggle,
+ }),
+ codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
+ skillStates,
+ defaultActiveOnShare,
+ manualSkills,
+ },
+ dbMethods,
+ );
+
+ /**
+ * Per-agent tool-execution context map, keyed by agentId.
+ * Needed so the ON_TOOL_EXECUTE callback routes each sub-agent's tool calls
+ * to the correct toolRegistry / userMCPAuthMap / tool_resources.
+ * @type {Map>,
+ * tool_resources?: object,
+ * actionsEnabled?: boolean,
+ * }>}
+ */
+ const agentToolContexts = new Map();
+ agentToolContexts.set(
+ primaryConfig.id,
+ buildAgentToolContext({ agent, config: primaryConfig }),
+ );
+
+ // Only run BFS discovery (and pay `getModelsConfig` upfront) when the
+ // primary has edges to follow — the common API case is single-agent.
+ let handoffAgentConfigs = new Map();
+ let discoveredEdges = [];
+ let discoveredMCPAuthMap;
+ if (primaryConfig.edges?.length) {
+ const modelsConfig = await getModelsConfig(req);
+ ({
+ agentConfigs: handoffAgentConfigs,
+ edges: discoveredEdges,
+ userMCPAuthMap: discoveredMCPAuthMap,
+ } = await discoverConnectedAgents(
+ {
+ req,
+ res,
+ primaryConfig,
+ endpointOption,
+ allowedProviders,
+ modelsConfig,
+ loadTools,
+ requestFiles: [],
+ conversationId,
+ parentMessageId,
+ // The route enforces REMOTE_AGENT on the primary; every discovered
+ // sub-agent must clear the same sharing boundary, not the looser
+ // in-app AGENT one.
+ resourceType: ResourceType.REMOTE_AGENT,
+ computeAccessibleSkillIds: (handoffAgent) =>
+ resolveAgentScopedSkillIds({
+ agent: handoffAgent,
+ accessibleSkillIds,
+ skillsCapabilityEnabled,
+ ephemeralSkillsToggle,
+ }),
+ computeSkillAuthoringAvailable: (handoffAgent) =>
+ canAuthorSkillFiles({
+ agent: handoffAgent,
+ scopedEditableSkillIds: resolveAgentScopedSkillIds({
+ agent: handoffAgent,
+ accessibleSkillIds: editableSkillIds,
+ skillsCapabilityEnabled,
+ ephemeralSkillsToggle,
+ }),
+ skillCreateAllowed,
+ skillsCapabilityEnabled,
+ ephemeralSkillsToggle,
+ }),
+ skillStates,
+ defaultActiveOnShare,
+ /** @see DiscoverConnectedAgentsParams.codeEnvAvailable */
+ codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
+ },
+ {
+ getAgent: db.getAgent,
+ // Use `getRemoteAgentPermissions` so sub-agent authorization
+ // matches what the route's `createCheckRemoteAgentAccess`
+ // middleware does for the primary: AGENT owners with the SHARE
+ // bit are treated as remotely authorized even without an
+ // explicit REMOTE_AGENT grant.
+ checkPermission: async ({ userId, role, resourceId, requiredPermission }) => {
+ const permissions = await getRemoteAgentPermissions(
+ { getEffectivePermissions },
+ userId,
+ role,
+ resourceId,
+ );
+ return hasPermissions(permissions, requiredPermission);
+ },
+ logViolation,
+ db: dbMethods,
+ onAgentInitialized: (agentId, handoffAgent, config) => {
+ agentToolContexts.set(agentId, buildAgentToolContext({ agent: handoffAgent, config }));
+ },
+ initializeAgent,
+ },
+ ));
+ }
+
+ primaryConfig.edges = discoveredEdges;
+
+ // Determine if streaming is enabled (check both request and agent config)
+ const streamingDisabled = !!primaryConfig.model_parameters?.disableStreaming;
+ const isStreaming = request.stream === true && !streamingDisabled;
+
+ // Create tracker for streaming or aggregator for non-streaming
+ const tracker = isStreaming ? createOpenAIStreamTracker() : null;
+ const aggregator = isStreaming ? null : createOpenAIContentAggregator();
+
+ // Set up response for streaming
+ if (isStreaming) {
+ res.setHeader('Content-Type', 'text/event-stream');
+ res.setHeader('Cache-Control', 'no-cache');
+ res.setHeader('Connection', 'keep-alive');
+ res.setHeader('X-Accel-Buffering', 'no');
+ res.flushHeaders();
+
+ // Send initial chunk with role
+ const initialChunk = createChunk(context, { role: 'assistant' });
+ writeSSE(res, initialChunk);
+ }
+
+ // Create handler config for OpenAI streaming (only used when streaming)
+ const handlerConfig = isStreaming
+ ? {
+ res,
+ context,
+ tracker,
+ }
+ : null;
+
+ const collectedUsage = [];
+ /** @type {Promise[]} */
+ const artifactPromises = [];
+
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId: null });
+
+ /* Stable for the turn: the primary prime list is fixed once
+ `initializeAgent` resolves and is used as the fallback when a
+ specific agent context is unavailable. `codeEnvAvailable` is read
+ per-agent from the stored tool context (admin cap AND that
+ agent's `tools` list includes `execute_code`) — a skills-only
+ agent never gains sandbox access even if the admin enabled the
+ capability globally. */
+ const toolExecuteOptions = {
+ loadTools: async (toolNames, agentId) => {
+ const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {};
+ const result = await loadToolsForExecution({
+ req,
+ res,
+ toolNames,
+ agent: ctx.agent ?? agent,
+ signal: abortController.signal,
+ toolRegistry: ctx.toolRegistry,
+ mcpAvailableTools: ctx.mcpAvailableTools,
+ requestScopedConnections: ctx.requestScopedConnections,
+ userMCPAuthMap: ctx.userMCPAuthMap,
+ tool_resources: ctx.tool_resources,
+ actionsEnabled: ctx.actionsEnabled,
+ });
+ return enrichLoadedToolsWithAgentContext({
+ result,
+ req,
+ ctx,
+ });
+ },
+ toolEndCallback,
+ ...getSkillToolDeps(),
+ };
+
+ const summarizationConfig = appConfig?.summarization;
+
+ const openaiMessages = convertMessages(request.messages);
+
+ const toolSet = buildToolSet(primaryConfig);
+ const formatted = formatAgentMessages(openaiMessages, {}, toolSet);
+ const formattedMessages = formatted.messages;
+ const initialSummary = formatted.summary;
+ let indexTokenCountMap = formatted.indexTokenCountMap;
+
+ /**
+ * Inject manual + always-apply skill primes so the model sees SKILL.md
+ * bodies for this turn — parity with AgentClient's chat path. OpenAI-
+ * compatible streaming uses its own tracker/aggregator shape, so the
+ * LibreChat-style card SSE events don't apply here; only the
+ * message-context part carries over.
+ */
+ const manualSkillPrimes = primaryConfig.manualSkillPrimes;
+ const alwaysApplySkillPrimes = primaryConfig.alwaysApplySkillPrimes;
+ if (
+ (manualSkillPrimes && manualSkillPrimes.length > 0) ||
+ (alwaysApplySkillPrimes && alwaysApplySkillPrimes.length > 0)
+ ) {
+ const primeResult = injectSkillPrimes({
+ initialMessages: formattedMessages,
+ indexTokenCountMap,
+ manualSkillPrimes,
+ alwaysApplySkillPrimes,
+ });
+ indexTokenCountMap = primeResult.indexTokenCountMap;
+ /* Surface the cap-driven always-apply truncation at the controller
+ layer too — `injectSkillPrimes` already logs internally, but the
+ controller-level warn includes endpoint context so operators can
+ tell at a glance which path hit the cap. Mirrors AgentClient's
+ warn in `client.js`. */
+ if (primeResult.alwaysApplyDropped > 0) {
+ logger.warn(
+ `[OpenAI API] Dropped ${primeResult.alwaysApplyDropped} always-apply prime(s) to stay within MAX_PRIMED_SKILLS_PER_TURN.`,
+ );
+ }
+ }
+
+ /**
+ * Create a simple handler that processes data
+ */
+ const createHandler = (processor) => ({
+ handle: (_event, data) => {
+ if (processor) {
+ processor(data);
+ }
+ },
+ });
+
+ /**
+ * Stream text content in OpenAI format
+ */
+ const streamText = (text) => {
+ if (!text) {
+ return;
+ }
+ if (isStreaming) {
+ tracker.addText();
+ writeSSE(res, createChunk(context, { content: text }));
+ } else {
+ aggregator.addText(text);
+ }
+ };
+
+ /**
+ * Stream reasoning content in OpenAI format (OpenRouter convention)
+ */
+ const streamReasoning = (text) => {
+ if (!text) {
+ return;
+ }
+ if (isStreaming) {
+ tracker.addReasoning();
+ writeSSE(res, createChunk(context, { reasoning: text }));
+ } else {
+ aggregator.addReasoning(text);
+ }
+ };
+
+ // Event handlers for OpenAI-compatible streaming
+ const handlers = {
+ // Text content streaming
+ on_message_delta: createHandler((data) => {
+ const content = data?.delta?.content;
+ if (Array.isArray(content)) {
+ for (const part of content) {
+ if (part.type === 'text' && part.text) {
+ streamText(part.text);
+ }
+ }
+ }
+ }),
+
+ // Reasoning/thinking content streaming
+ on_reasoning_delta: createHandler((data) => {
+ const content = data?.delta?.content;
+ if (Array.isArray(content)) {
+ for (const part of content) {
+ const text = part.think || part.text;
+ if (text) {
+ streamReasoning(text);
+ }
+ }
+ }
+ }),
+
+ // Tool call initiation - streams id and name (from on_run_step)
+ on_run_step: createHandler((data) => {
+ const stepDetails = data?.stepDetails;
+ if (stepDetails?.type === 'tool_calls' && stepDetails.tool_calls) {
+ for (const tc of stepDetails.tool_calls) {
+ const toolIndex = data.index ?? 0;
+ const toolId = tc.id ?? '';
+ const toolName = tc.name ?? '';
+ const toolCall = {
+ id: toolId,
+ type: 'function',
+ function: { name: toolName, arguments: '' },
+ };
+
+ // Track tool call in tracker or aggregator
+ if (isStreaming) {
+ if (!tracker.toolCalls.has(toolIndex)) {
+ tracker.toolCalls.set(toolIndex, toolCall);
+ }
+ // Stream initial tool call chunk (like OpenAI does)
+ writeSSE(
+ res,
+ createChunk(context, {
+ tool_calls: [{ index: toolIndex, ...toolCall }],
+ }),
+ );
+ } else {
+ if (!aggregator.toolCalls.has(toolIndex)) {
+ aggregator.toolCalls.set(toolIndex, toolCall);
+ }
+ }
+ }
+ }
+ }),
+
+ // Tool call argument streaming (from on_run_step_delta)
+ on_run_step_delta: createHandler((data) => {
+ const delta = data?.delta;
+ if (delta?.type === 'tool_calls' && delta.tool_calls) {
+ for (const tc of delta.tool_calls) {
+ const args = tc.args ?? '';
+ if (!args) {
+ continue;
+ }
+
+ const toolIndex = tc.index ?? 0;
+
+ // Update tool call arguments
+ const targetMap = isStreaming ? tracker.toolCalls : aggregator.toolCalls;
+ const tracked = targetMap.get(toolIndex);
+ if (tracked) {
+ tracked.function.arguments += args;
+ }
+
+ // Stream argument delta (only for streaming)
+ if (isStreaming) {
+ writeSSE(
+ res,
+ createChunk(context, {
+ tool_calls: [
+ {
+ index: toolIndex,
+ function: { arguments: args },
+ },
+ ],
+ }),
+ );
+ }
+ }
+ }
+ }),
+
+ // Usage tracking
+ 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(),
+ on_tool_execute: createToolExecuteHandler(toolExecuteOptions),
+ ...(summarizationConfig?.enabled !== false
+ ? buildSummarizationHandlers({ isStreaming, res })
+ : {}),
+ };
+
+ // Create and run the agent
+ const userId = req.user?.id ?? 'api-user';
+
+ // Extract merged userMCPAuthMap (needed for MCP tool connections across
+ // the primary and any discovered handoff sub-agents)
+ const userMCPAuthMap = discoveredMCPAuthMap ?? primaryConfig.userMCPAuthMap;
+
+ const runAgents = [primaryConfig, ...handoffAgentConfigs.values()];
+
+ const run = await createRun({
+ agents: runAgents,
+ messages: formattedMessages,
+ indexTokenCountMap,
+ initialSummary,
+ runId: responseId,
+ summarizationConfig,
+ appConfig,
+ signal: abortController.signal,
+ customHandlers: handlers,
+ requestBody: {
+ messageId: responseId,
+ conversationId,
+ },
+ user: { id: userId },
+ tenantId: req.user?.tenantId,
+ /** Bills subagent child-run model calls (reported outside the
+ * streamEvents loop) into the same collectedUsage array. */
+ subagentUsageSink: createSubagentUsageSink(collectedUsage),
+ });
+
+ if (!run) {
+ throw new Error('Failed to create agent run');
+ }
+
+ const config = {
+ runName: 'AgentRun',
+ configurable: {
+ thread_id: conversationId,
+ user_id: userId,
+ user: createSafeUser(req.user),
+ requestBody: {
+ messageId: responseId,
+ conversationId,
+ },
+ ...(userMCPAuthMap != null && { userMCPAuthMap }),
+ },
+ recursionLimit: resolveRecursionLimit(agentsEConfig, agent),
+ signal: abortController.signal,
+ streamMode: 'values',
+ version: 'v2',
+ };
+
+ await run.processStream({ messages: formattedMessages }, config, {
+ callbacks: {
+ [Callback.TOOL_ERROR]: (graph, error, toolId) => {
+ logger.error(`[OpenAI API] Tool Error "${toolId}"`, error);
+ },
+ },
+ });
+
+ // Record token usage against balance
+ const balanceConfig = getBalanceConfig(appConfig);
+ const transactionsConfig = getTransactionsConfig(appConfig);
+ recordCollectedUsage(
+ {
+ spendTokens: db.spendTokens,
+ spendStructuredTokens: db.spendStructuredTokens,
+ pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
+ bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
+ },
+ {
+ user: userId,
+ conversationId,
+ collectedUsage,
+ context: 'message',
+ messageId: responseId,
+ balance: balanceConfig,
+ transactions: transactionsConfig,
+ model: primaryConfig.model || agent.model_parameters?.model,
+ },
+ ).catch((err) => {
+ logger.error('[OpenAI API] Error recording usage:', err);
+ });
+
+ // Finalize response
+ const duration = Date.now() - requestStartTime;
+ if (isStreaming) {
+ sendFinalChunk(handlerConfig);
+ res.end();
+ logger.debug(`[OpenAI API] Response ${responseId} completed in ${duration}ms (streaming)`);
+
+ // Wait for artifact processing after response ends (non-blocking)
+ if (artifactPromises.length > 0) {
+ Promise.all(artifactPromises).catch((artifactError) => {
+ logger.warn('[OpenAI API] Error processing artifacts:', artifactError);
+ });
+ }
+ } else {
+ // For non-streaming, wait for artifacts before sending response
+ if (artifactPromises.length > 0) {
+ try {
+ await Promise.all(artifactPromises);
+ } catch (artifactError) {
+ logger.warn('[OpenAI API] Error processing artifacts:', artifactError);
+ }
+ }
+
+ // Build usage from aggregated data
+ const usage = {
+ prompt_tokens: aggregator.usage.promptTokens,
+ completion_tokens: aggregator.usage.completionTokens,
+ total_tokens: aggregator.usage.promptTokens + aggregator.usage.completionTokens,
+ };
+
+ if (aggregator.usage.reasoningTokens > 0) {
+ usage.completion_tokens_details = {
+ reasoning_tokens: aggregator.usage.reasoningTokens,
+ };
+ }
+
+ const response = buildNonStreamingResponse(
+ context,
+ aggregator.getText(),
+ aggregator.getReasoning(),
+ aggregator.toolCalls,
+ usage,
+ );
+ res.json(response);
+ logger.debug(
+ `[OpenAI API] Response ${responseId} completed in ${duration}ms (non-streaming)`,
+ );
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'An error occurred';
+ logger.error('[OpenAI API] Error:', error);
+
+ // Check if we already started streaming (headers sent)
+ if (res.headersSent) {
+ // Headers already sent, send error in stream
+ const errorChunk = createChunk(context, { content: `\n\nError: ${errorMessage}` }, 'stop');
+ writeSSE(res, errorChunk);
+ writeSSE(res, '[DONE]');
+ res.end();
+ } else {
+ // Forward upstream provider status codes (e.g., Anthropic 400s) instead of masking as 500
+ const statusCode =
+ typeof error?.status === 'number' && error.status >= 400 && error.status < 600
+ ? error.status
+ : 500;
+ const errorType =
+ statusCode >= 400 && statusCode < 500 ? 'invalid_request_error' : 'server_error';
+ sendErrorResponse(res, statusCode, errorMessage, errorType);
+ }
+ }
+};
+
+/**
+ * List available agents as models (filtered by remote access permissions)
+ *
+ * GET /v1/models
+ */
+const ListModelsController = async (req, res) => {
+ try {
+ const userId = req.user?.id;
+ const userRole = req.user?.role;
+
+ if (!userId) {
+ return sendErrorResponse(res, 401, 'Authentication required', 'auth_error');
+ }
+
+ // Find agents the user has remote access to (VIEW permission on REMOTE_AGENT)
+ const accessibleAgentIds = await findAccessibleResources({
+ userId,
+ role: userRole,
+ resourceType: ResourceType.REMOTE_AGENT,
+ requiredPermissions: PermissionBits.VIEW,
+ });
+
+ // Get the accessible agents
+ let agents = [];
+ if (accessibleAgentIds.length > 0) {
+ agents = await db.getAgents({ _id: { $in: accessibleAgentIds } });
+ }
+
+ const models = agents.map((agent) => ({
+ id: agent.id,
+ object: 'model',
+ created: Math.floor(new Date(agent.createdAt || Date.now()).getTime() / 1000),
+ owned_by: 'librechat',
+ permission: [],
+ root: agent.id,
+ parent: null,
+ // LibreChat extensions
+ name: agent.name,
+ description: agent.description,
+ provider: agent.provider,
+ }));
+
+ res.json({
+ object: 'list',
+ data: models,
+ });
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Failed to list models';
+ logger.error('[OpenAI API] Error listing models:', error);
+ sendErrorResponse(res, 500, errorMessage, 'server_error');
+ }
+};
+
+/**
+ * Get a specific model/agent (with remote access permission check)
+ *
+ * GET /v1/models/:model
+ */
+const GetModelController = async (req, res) => {
+ try {
+ const { model } = req.params;
+ const userId = req.user?.id;
+ const userRole = req.user?.role;
+
+ if (!userId) {
+ return sendErrorResponse(res, 401, 'Authentication required', 'auth_error');
+ }
+
+ const agent = await db.getAgent({ id: model });
+
+ if (!agent) {
+ return sendErrorResponse(
+ res,
+ 404,
+ `Model not found: ${model}`,
+ 'invalid_request_error',
+ 'model_not_found',
+ );
+ }
+
+ // Check if user has remote access to this agent
+ const accessibleAgentIds = await findAccessibleResources({
+ userId,
+ role: userRole,
+ resourceType: ResourceType.REMOTE_AGENT,
+ requiredPermissions: PermissionBits.VIEW,
+ });
+
+ const hasAccess = accessibleAgentIds.some((id) => id.toString() === agent._id.toString());
+
+ if (!hasAccess) {
+ return sendErrorResponse(
+ res,
+ 403,
+ `No remote access to model: ${model}`,
+ 'permission_error',
+ 'access_denied',
+ );
+ }
+
+ res.json({
+ id: agent.id,
+ object: 'model',
+ created: Math.floor(new Date(agent.createdAt || Date.now()).getTime() / 1000),
+ owned_by: 'librechat',
+ permission: [],
+ root: agent.id,
+ parent: null,
+ // LibreChat extensions
+ name: agent.name,
+ description: agent.description,
+ provider: agent.provider,
+ });
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Failed to get model';
+ logger.error('[OpenAI API] Error getting model:', error);
+ sendErrorResponse(res, 500, errorMessage, 'server_error');
+ }
+};
+
+module.exports = {
+ OpenAIChatCompletionController,
+ ListModelsController,
+ GetModelController,
+};
diff --git a/api/server/controllers/agents/recordCollectedUsage.spec.js b/api/server/controllers/agents/recordCollectedUsage.spec.js
new file mode 100644
index 0000000..009c5b2
--- /dev/null
+++ b/api/server/controllers/agents/recordCollectedUsage.spec.js
@@ -0,0 +1,358 @@
+/**
+ * Tests for AgentClient.recordCollectedUsage
+ *
+ * This is a critical function that handles token spending for agent LLM calls.
+ * The client now delegates to the TS recordCollectedUsage from @librechat/api,
+ * passing pricing and bulkWriteOps deps.
+ */
+
+const { EModelEndpoint } = require('librechat-data-provider');
+
+const mockSpendTokens = jest.fn().mockResolvedValue();
+const mockSpendStructuredTokens = jest.fn().mockResolvedValue();
+const mockGetMultiplier = jest.fn().mockReturnValue(1);
+const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
+const mockUpdateBalance = jest.fn().mockResolvedValue({});
+const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
+const mockRecordCollectedUsage = jest
+ .fn()
+ .mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
+
+jest.mock('~/models', () => ({
+ spendTokens: (...args) => mockSpendTokens(...args),
+ spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args),
+ getMultiplier: mockGetMultiplier,
+ getCacheMultiplier: mockGetCacheMultiplier,
+ updateBalance: mockUpdateBalance,
+ bulkInsertTransactions: mockBulkInsertTransactions,
+}));
+
+jest.mock('~/config', () => ({
+ logger: {
+ debug: jest.fn(),
+ error: jest.fn(),
+ warn: jest.fn(),
+ info: jest.fn(),
+ },
+ getMCPManager: jest.fn(() => ({
+ formatInstructionsForContext: jest.fn(),
+ })),
+}));
+
+jest.mock('@librechat/agents', () => ({
+ ...jest.requireActual('@librechat/agents'),
+ createMetadataAggregator: () => ({
+ handleLLMEnd: jest.fn(),
+ collected: [],
+ }),
+}));
+
+jest.mock('@librechat/api', () => {
+ const actual = jest.requireActual('@librechat/api');
+ return {
+ ...actual,
+ recordCollectedUsage: (...args) => mockRecordCollectedUsage(...args),
+ };
+});
+
+const AgentClient = require('./client');
+
+describe('AgentClient - recordCollectedUsage', () => {
+ let client;
+ let mockAgent;
+ let mockOptions;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockAgent = {
+ id: 'agent-123',
+ endpoint: EModelEndpoint.openAI,
+ provider: EModelEndpoint.openAI,
+ model_parameters: {
+ model: 'gpt-4',
+ },
+ };
+
+ mockOptions = {
+ req: {
+ user: { id: 'user-123' },
+ body: { model: 'gpt-4', endpoint: EModelEndpoint.openAI },
+ },
+ res: {},
+ agent: mockAgent,
+ endpointTokenConfig: {},
+ };
+
+ client = new AgentClient(mockOptions);
+ client.conversationId = 'convo-123';
+ client.user = 'user-123';
+ });
+
+ describe('basic functionality', () => {
+ it('should delegate to recordCollectedUsage with full deps', async () => {
+ const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }];
+
+ await client.recordCollectedUsage({
+ collectedUsage,
+ balance: { enabled: true },
+ transactions: { enabled: true },
+ });
+
+ expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
+ const [deps, params] = mockRecordCollectedUsage.mock.calls[0];
+
+ expect(deps).toHaveProperty('spendTokens');
+ expect(deps).toHaveProperty('spendStructuredTokens');
+ expect(deps).toHaveProperty('pricing');
+ expect(deps.pricing).toHaveProperty('getMultiplier');
+ expect(deps.pricing).toHaveProperty('getCacheMultiplier');
+ expect(deps).toHaveProperty('bulkWriteOps');
+ expect(deps.bulkWriteOps).toHaveProperty('insertMany');
+ expect(deps.bulkWriteOps).toHaveProperty('updateBalance');
+
+ expect(params).toEqual(
+ expect.objectContaining({
+ user: 'user-123',
+ conversationId: 'convo-123',
+ collectedUsage,
+ context: 'message',
+ balance: { enabled: true },
+ transactions: { enabled: true },
+ }),
+ );
+ });
+
+ it('should not set this.usage if collectedUsage is empty (returns undefined)', async () => {
+ mockRecordCollectedUsage.mockResolvedValue(undefined);
+
+ await client.recordCollectedUsage({
+ collectedUsage: [],
+ balance: { enabled: true },
+ transactions: { enabled: true },
+ });
+
+ expect(client.usage).toBeUndefined();
+ });
+
+ it('should not set this.usage if collectedUsage is null (returns undefined)', async () => {
+ mockRecordCollectedUsage.mockResolvedValue(undefined);
+
+ await client.recordCollectedUsage({
+ collectedUsage: null,
+ balance: { enabled: true },
+ transactions: { enabled: true },
+ });
+
+ expect(client.usage).toBeUndefined();
+ });
+
+ it('should set this.usage from recordCollectedUsage result', async () => {
+ mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 200, output_tokens: 75 });
+ const collectedUsage = [{ input_tokens: 200, output_tokens: 75, model: 'gpt-4' }];
+
+ await client.recordCollectedUsage({
+ collectedUsage,
+ balance: { enabled: true },
+ transactions: { enabled: true },
+ });
+
+ expect(client.usage).toEqual({ input_tokens: 200, output_tokens: 75 });
+ });
+ });
+
+ describe('sequential execution (single agent with tool calls)', () => {
+ it('should pass all usage entries to recordCollectedUsage', async () => {
+ const collectedUsage = [
+ { input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
+ { input_tokens: 150, output_tokens: 30, model: 'gpt-4' },
+ { input_tokens: 180, output_tokens: 20, model: 'gpt-4' },
+ ];
+
+ mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 100 });
+
+ await client.recordCollectedUsage({
+ collectedUsage,
+ balance: { enabled: true },
+ transactions: { enabled: true },
+ });
+
+ expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
+ const [, params] = mockRecordCollectedUsage.mock.calls[0];
+ expect(params.collectedUsage).toHaveLength(3);
+ expect(client.usage.output_tokens).toBe(100);
+ expect(client.usage.input_tokens).toBe(100);
+ });
+ });
+
+ describe('parallel execution (multiple agents)', () => {
+ it('should pass parallel agent usage to recordCollectedUsage', async () => {
+ const collectedUsage = [
+ { input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
+ { input_tokens: 80, output_tokens: 40, model: 'gpt-4' },
+ ];
+
+ mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 90 });
+
+ await client.recordCollectedUsage({
+ collectedUsage,
+ balance: { enabled: true },
+ transactions: { enabled: true },
+ });
+
+ expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
+ expect(client.usage.output_tokens).toBe(90);
+ expect(client.usage.output_tokens).toBeGreaterThan(0);
+ });
+
+ /** Bug regression: parallel agents where second agent has LOWER input tokens produced negative output via incremental calculation. */
+ it('should NOT produce negative output_tokens', async () => {
+ const collectedUsage = [
+ { input_tokens: 200, output_tokens: 100, model: 'gpt-4' },
+ { input_tokens: 50, output_tokens: 30, model: 'gpt-4' },
+ ];
+
+ mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 200, output_tokens: 130 });
+
+ await client.recordCollectedUsage({
+ collectedUsage,
+ balance: { enabled: true },
+ transactions: { enabled: true },
+ });
+
+ expect(client.usage.output_tokens).toBeGreaterThan(0);
+ expect(client.usage.output_tokens).toBe(130);
+ });
+ });
+
+ describe('real-world scenarios', () => {
+ it('should correctly handle sequential tool calls with growing context', async () => {
+ const collectedUsage = [
+ { input_tokens: 31596, output_tokens: 151, model: 'claude-opus-4-5-20251101' },
+ { input_tokens: 35368, output_tokens: 150, model: 'claude-opus-4-5-20251101' },
+ { input_tokens: 58362, output_tokens: 295, model: 'claude-opus-4-5-20251101' },
+ { input_tokens: 112604, output_tokens: 193, model: 'claude-opus-4-5-20251101' },
+ { input_tokens: 257440, output_tokens: 2217, model: 'claude-opus-4-5-20251101' },
+ ];
+
+ mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 31596, output_tokens: 3006 });
+
+ await client.recordCollectedUsage({
+ collectedUsage,
+ balance: { enabled: true },
+ transactions: { enabled: true },
+ });
+
+ expect(client.usage.input_tokens).toBe(31596);
+ expect(client.usage.output_tokens).toBe(3006);
+ });
+
+ it('should correctly handle cache tokens', async () => {
+ const collectedUsage = [
+ {
+ input_tokens: 788,
+ output_tokens: 163,
+ input_token_details: { cache_read: 0, cache_creation: 30808 },
+ model: 'claude-opus-4-5-20251101',
+ },
+ ];
+
+ mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 31596, output_tokens: 163 });
+
+ await client.recordCollectedUsage({
+ collectedUsage,
+ balance: { enabled: true },
+ transactions: { enabled: true },
+ });
+
+ expect(client.usage.input_tokens).toBe(31596);
+ expect(client.usage.output_tokens).toBe(163);
+ });
+ });
+
+ describe('model fallback', () => {
+ it('should use param model when available', async () => {
+ mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
+ const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }];
+
+ await client.recordCollectedUsage({
+ model: 'param-model',
+ collectedUsage,
+ balance: { enabled: true },
+ transactions: { enabled: true },
+ });
+
+ const [, params] = mockRecordCollectedUsage.mock.calls[0];
+ expect(params.model).toBe('param-model');
+ });
+
+ it('should fallback to client.model when param model is missing', async () => {
+ client.model = 'client-model';
+ mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
+ const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }];
+
+ await client.recordCollectedUsage({
+ collectedUsage,
+ balance: { enabled: true },
+ transactions: { enabled: true },
+ });
+
+ const [, params] = mockRecordCollectedUsage.mock.calls[0];
+ expect(params.model).toBe('client-model');
+ });
+
+ it('should fallback to agent model_parameters.model as last resort', async () => {
+ mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
+ const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }];
+
+ await client.recordCollectedUsage({
+ collectedUsage,
+ balance: { enabled: true },
+ transactions: { enabled: true },
+ });
+
+ const [, params] = mockRecordCollectedUsage.mock.calls[0];
+ expect(params.model).toBe('gpt-4');
+ });
+ });
+
+ describe('getStreamUsage integration', () => {
+ it('should return the usage object set by recordCollectedUsage', async () => {
+ mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
+ const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }];
+
+ await client.recordCollectedUsage({
+ collectedUsage,
+ balance: { enabled: true },
+ transactions: { enabled: true },
+ });
+
+ const usage = client.getStreamUsage();
+ expect(usage).toEqual({ input_tokens: 100, output_tokens: 50 });
+ });
+
+ it('should return undefined before recordCollectedUsage is called', () => {
+ const usage = client.getStreamUsage();
+ expect(usage).toBeUndefined();
+ });
+
+ /** Verifies usage passes the check in BaseClient.sendMessage: if (usage != null && Number(usage[this.outputTokensKey]) > 0) */
+ it('should have output_tokens > 0 for BaseClient.sendMessage check', async () => {
+ mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 200, output_tokens: 130 });
+ const collectedUsage = [
+ { input_tokens: 200, output_tokens: 100, model: 'gpt-4' },
+ { input_tokens: 50, output_tokens: 30, model: 'gpt-4' },
+ ];
+
+ await client.recordCollectedUsage({
+ collectedUsage,
+ balance: { enabled: true },
+ transactions: { enabled: true },
+ });
+
+ const usage = client.getStreamUsage();
+ expect(usage).not.toBeNull();
+ expect(Number(usage.output_tokens)).toBeGreaterThan(0);
+ });
+ });
+});
diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js
new file mode 100644
index 0000000..81213a2
--- /dev/null
+++ b/api/server/controllers/agents/request.js
@@ -0,0 +1,1241 @@
+const { logger } = require('@librechat/data-schemas');
+const { Constants, ViolationTypes, isEphemeralAgentId } = require('librechat-data-provider');
+const {
+ sendEvent,
+ getViolationInfo,
+ buildMessageFiles,
+ getReferencedQuotes,
+ resolveTitleTiming,
+ GenerationJobManager,
+ filterPersistableAbortContent,
+ decrementPendingRequest,
+ sanitizeMessageForTransmit,
+ checkAndIncrementPendingRequest,
+ isUnpersistedPreliminaryParent,
+} = require('@librechat/api');
+const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup');
+const {
+ getMCPRequestContext,
+ cleanupMCPRequestContextForReq,
+} = require('~/server/services/MCPRequestContext');
+const { handleAbortError } = require('~/server/middleware');
+const { logViolation } = require('~/cache');
+const { saveMessage, getMessages, getConvo } = require('~/models');
+
+function createCloseHandler(abortController) {
+ return function (manual) {
+ if (!manual) {
+ logger.debug('[AgentController] Request closed');
+ }
+ if (!abortController) {
+ return;
+ } else if (abortController.signal.aborted) {
+ return;
+ } else if (abortController.requestCompleted) {
+ return;
+ }
+
+ abortController.abort();
+ logger.debug('[AgentController] Request aborted on close');
+ };
+}
+
+function toValidISOString(value) {
+ if (value == null) {
+ return null;
+ }
+
+ const date = value instanceof Date ? value : new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
+}
+
+async function resolveConversationCreatedAt({ userId, conversationId, isNewConvo }) {
+ if (isNewConvo) {
+ return { createdAt: new Date().toISOString(), conversation: undefined };
+ }
+
+ try {
+ const conversation = await getConvo(userId, conversationId);
+ return {
+ conversation,
+ createdAt: toValidISOString(conversation?.createdAt) ?? new Date().toISOString(),
+ };
+ } catch (error) {
+ logger.warn('[AgentController] Failed to resolve conversation timestamp anchor', {
+ conversationId,
+ error: error?.message ?? error,
+ });
+ return { createdAt: new Date().toISOString(), conversation: undefined };
+ }
+}
+
+async function attachConversationCreatedAt(req, { userId, conversationId, isNewConvo }) {
+ req.body.conversationId = conversationId;
+ const resolved = await resolveConversationCreatedAt({
+ userId,
+ conversationId,
+ isNewConvo,
+ });
+ req.conversationCreatedAt = resolved.createdAt;
+ if (!isNewConvo && resolved.conversation !== undefined) {
+ req.resolvedConversation = resolved.conversation ?? null;
+ }
+}
+
+function getPreliminaryResponseMessageId({ messageId, responseMessageId }) {
+ if (typeof responseMessageId === 'string' && responseMessageId.length > 0) {
+ return responseMessageId;
+ }
+
+ if (typeof messageId !== 'string' || messageId.length === 0) {
+ return null;
+ }
+
+ return `${messageId.replace(/_+$/, '')}_`;
+}
+
+function getPreliminaryUserMessage(
+ { messageId, parentMessageId, text, quotes, files, manualSkills, alwaysAppliedSkills },
+ conversationId,
+) {
+ if (typeof messageId !== 'string' || messageId.length === 0) {
+ return null;
+ }
+
+ /**
+ * Seed normalized quotes here too: if the user aborts before `sendMessage`
+ * reaches `onStart` (during init/tool loading), `abortMiddleware` falls back
+ * to this preliminary metadata, which must carry the excerpts so the stopped
+ * turn keeps its `MessageQuotes`.
+ */
+ const referencedQuotes = getReferencedQuotes(quotes);
+
+ return {
+ messageId,
+ parentMessageId,
+ conversationId,
+ text,
+ ...(referencedQuotes != null && { quotes: referencedQuotes }),
+ // Persist the turn's uploaded files on this AWAITED preliminary write so they land on
+ // job.metadata.userMessage BEFORE the run can reach its first interrupt. onStart's
+ // later writes are fire-and-forget, so a fast approval could otherwise read the job
+ // and resume an approved code/read-file tool without the paused turn's uploads.
+ ...(Array.isArray(files) && files.length > 0 && { files }),
+ // Carry skill selections so a HITL-resumed turn's reconstructed `requestMessage`
+ // keeps its skill pills — the client's final handler replaces the user bubble from
+ // this object, and they'd otherwise vanish until a full reload refetches the row.
+ ...(Array.isArray(manualSkills) && manualSkills.length > 0 && { manualSkills }),
+ ...(Array.isArray(alwaysAppliedSkills) &&
+ alwaysAppliedSkills.length > 0 && { alwaysAppliedSkills }),
+ };
+}
+
+function getRequestModelSpec(req, endpointOption) {
+ const spec = endpointOption?.spec ?? req.body?.spec;
+ if (typeof spec !== 'string' || spec.length === 0) {
+ return;
+ }
+
+ const list = req.config?.modelSpecs?.list;
+ if (!Array.isArray(list)) {
+ return;
+ }
+
+ return list.find((modelSpec) => modelSpec?.name === spec);
+}
+
+function getModelSpecIconURL(modelSpec) {
+ return modelSpec?.iconURL ?? modelSpec?.preset?.iconURL ?? modelSpec?.preset?.endpoint ?? '';
+}
+
+function getEndpointIconURL(req, endpointOption) {
+ const iconURL =
+ endpointOption?.iconURL ?? getModelSpecIconURL(getRequestModelSpec(req, endpointOption));
+ return iconURL || undefined;
+}
+
+function getEndpointResponseModel(endpointOption) {
+ return endpointOption?.modelOptions?.model || endpointOption?.model_parameters?.model;
+}
+
+function getAgentResponseModel(req, endpointOption) {
+ const agentId = endpointOption?.agent_id || req.body?.agent_id;
+ if (typeof agentId === 'string' && agentId.length > 0 && !isEphemeralAgentId(agentId)) {
+ return agentId;
+ }
+
+ return getEndpointResponseModel(endpointOption);
+}
+
+async function finishResumableRequest(req, userId) {
+ try {
+ await cleanupMCPRequestContextForReq(req);
+ } finally {
+ await decrementPendingRequest(userId);
+ }
+}
+
+function rejectPreliminaryParentMessageId(res) {
+ return res.status(409).json({
+ error:
+ 'Cannot submit a follow-up while the selected parent response is still being saved. Please wait and try again.',
+ });
+}
+
+/**
+ * Resumable Agent Controller - Generation runs independently of HTTP connection.
+ * Returns streamId immediately, client subscribes separately via SSE.
+ */
+const ResumableAgentController = async (req, res, next, initializeClient, addTitle) => {
+ const {
+ text,
+ isRegenerate,
+ endpointOption,
+ conversationId: reqConversationId,
+ isContinued = false,
+ editedContent = null,
+ parentMessageId = null,
+ overrideParentMessageId = null,
+ responseMessageId: editedResponseMessageId = null,
+ } = req.body;
+
+ const userId = req.user.id;
+
+ if (
+ await isUnpersistedPreliminaryParent({
+ userId,
+ conversationId: reqConversationId,
+ parentMessageId,
+ getMessages,
+ })
+ ) {
+ return rejectPreliminaryParentMessageId(res);
+ }
+
+ /** When to generate the conversation title. `immediate` (default) fires title
+ * generation in parallel with the response, from the user's first message;
+ * `final` defers it until the full response completes (legacy behavior).
+ * Resolved from the agent's actual endpoint once the client is initialized. */
+ let titleTiming = 'immediate';
+
+ const { allowed, pendingRequests, limit } = await checkAndIncrementPendingRequest(userId);
+ if (!allowed) {
+ const violationInfo = getViolationInfo(pendingRequests, limit);
+ await logViolation(req, res, ViolationTypes.CONCURRENT, violationInfo, violationInfo.score);
+ return res.status(429).json(violationInfo);
+ }
+
+ // Generate conversationId upfront if not provided - streamId === conversationId always
+ // Treat "new" as a placeholder that needs a real UUID (frontend may send "new" for new convos)
+ const isNewConvo = !reqConversationId || reqConversationId === 'new';
+ const conversationId = isNewConvo ? crypto.randomUUID() : reqConversationId;
+ const streamId = conversationId;
+ req.body.conversationId = conversationId;
+
+ let client = null;
+
+ try {
+ logger.debug(`[ResumableAgentController] Creating job`, {
+ streamId,
+ conversationId,
+ reqConversationId,
+ userId,
+ });
+
+ const job = await GenerationJobManager.createJob(streamId, userId, conversationId);
+ const jobCreatedAt = job.createdAt; // Capture creation time to detect job replacement
+ req._resumableStreamId = streamId;
+ getMCPRequestContext(req, undefined, { cleanupOnResponse: false });
+
+ // Send JSON response IMMEDIATELY so client can connect to SSE stream
+ // This is critical: tool loading (MCP OAuth) may emit events that the client needs to receive
+ res.json({ streamId, conversationId, status: 'started' });
+
+ await attachConversationCreatedAt(req, { userId, conversationId, isNewConvo });
+
+ const endpointIconURL = getEndpointIconURL(req, endpointOption);
+ const responseModel = getAgentResponseModel(req, endpointOption);
+ const preliminaryUserMessage = getPreliminaryUserMessage(req.body, conversationId);
+ const preliminaryResponseMessageId = getPreliminaryResponseMessageId(req.body);
+ await GenerationJobManager.updateMetadata(streamId, {
+ conversationId,
+ endpoint: endpointOption.endpoint,
+ iconURL: endpointIconURL,
+ model: responseModel,
+ // Persist the originating agent so a HITL resume can refuse to rebuild this
+ // paused run on a different agent (see resume.js).
+ agent_id: endpointOption.agent_id ?? req.body?.agent_id,
+ // Persist temporary-chat state so a HITL resume keeps the resumed response
+ // non-persisted instead of trusting the resume request to re-send the flag.
+ isTemporary: req.body?.isTemporary,
+ responseMessageId: preliminaryResponseMessageId,
+ userMessage: preliminaryUserMessage,
+ });
+
+ // Note: We no longer use res.on('close') to abort since we send JSON immediately.
+ // The response closes normally after res.json(), which is not an abort condition.
+ // Abort handling is done through GenerationJobManager via the SSE stream connection.
+
+ // Track if partial response was already saved to avoid duplicates
+ let partialResponseSaved = false;
+
+ /**
+ * Listen for all subscribers leaving to save partial response.
+ * This ensures the response is saved to DB even if all clients disconnect
+ * while generation continues.
+ *
+ * Note: The messageId used here falls back to `${userMessage.messageId}_` if the
+ * actual response messageId isn't available yet. The final response save will
+ * overwrite this with the complete response using the same messageId pattern.
+ */
+ job.emitter.on('allSubscribersLeft', async (aggregatedContent) => {
+ if (partialResponseSaved || !aggregatedContent || aggregatedContent.length === 0) {
+ return;
+ }
+
+ const persistableContent = filterPersistableAbortContent(aggregatedContent);
+ if (persistableContent.length === 0) {
+ logger.debug('[ResumableAgentController] No persistable content to save partial response');
+ return;
+ }
+
+ const resumeState = await GenerationJobManager.getResumeState(streamId);
+ if (!resumeState?.userMessage) {
+ logger.debug('[ResumableAgentController] No user message to save partial response for');
+ return;
+ }
+
+ partialResponseSaved = true;
+ const responseConversationId = resumeState.conversationId || conversationId;
+
+ try {
+ const partialMessage = {
+ messageId: resumeState.responseMessageId || `${resumeState.userMessage.messageId}_`,
+ conversationId: responseConversationId,
+ parentMessageId: resumeState.userMessage.messageId,
+ sender: client?.sender ?? 'AI',
+ content: persistableContent,
+ unfinished: true,
+ error: false,
+ isCreatedByUser: false,
+ user: userId,
+ endpoint: endpointOption.endpoint,
+ iconURL: resumeState.iconURL || endpointIconURL,
+ model: resumeState.model || responseModel,
+ };
+
+ if (req.body?.agent_id) {
+ partialMessage.agent_id = req.body.agent_id;
+ }
+
+ 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: ${persistableContent.length}`,
+ );
+ } catch (error) {
+ logger.error('[ResumableAgentController] Error saving partial response:', error);
+ // Reset flag so we can try again if subscribers reconnect and leave again
+ partialResponseSaved = false;
+ }
+ });
+
+ /** @type {{ client: TAgentClient; userMCPAuthMap?: Record> }} */
+ const result = await initializeClient({
+ req,
+ res,
+ endpointOption,
+ // Use the job's abort controller signal - allows abort via GenerationJobManager.abortJob()
+ signal: job.abortController.signal,
+ });
+
+ if (job.abortController.signal.aborted) {
+ GenerationJobManager.completeJob(streamId, 'Request aborted during initialization');
+ await finishResumableRequest(req, userId);
+ return;
+ }
+
+ client = result.client;
+ // Tag the client with THIS generation's identity so HITL terminal side-effects
+ // (pause CAS, checkpoint prune) can tell whether a newer request has since replaced
+ // this job on the same conversationId before acting on it.
+ client.jobCreatedAt = jobCreatedAt;
+
+ // Resolve title timing from the public agents endpoint first, then fall
+ // back to the agent's actual backing provider/custom endpoint.
+ titleTiming = resolveTitleTiming({
+ appConfig: req.config,
+ endpoint: [endpointOption?.endpoint, client?.options?.agent?.endpoint],
+ });
+
+ if (client?.sender) {
+ GenerationJobManager.updateMetadata(streamId, { sender: client.sender });
+ }
+
+ // Store reference to client's contentParts - graph will be set when run is created
+ if (client?.contentParts) {
+ GenerationJobManager.setContentParts(streamId, client.contentParts);
+ }
+
+ let userMessage;
+
+ const getReqData = (data = {}) => {
+ if (data.userMessage) {
+ userMessage = data.userMessage;
+ }
+ // conversationId is pre-generated, no need to update from callback
+ };
+
+ // Start background generation - readyPromise resolves immediately now
+ // (sync mechanism handles late subscribers)
+ const startGeneration = async () => {
+ try {
+ // Short timeout as safety net - promise should already be resolved
+ await Promise.race([job.readyPromise, new Promise((resolve) => setTimeout(resolve, 100))]);
+ } catch (waitError) {
+ logger.warn(
+ `[ResumableAgentController] Error waiting for subscriber: ${waitError.message}`,
+ );
+ }
+
+ /** Immediate-mode title generation runs in parallel with the response, so
+ * the conversation row may not exist when the title resolves. `convoReady`
+ * resolves once the response (and thus the conversation) has been saved,
+ * gating the title's `saveConvo`. Declared here so both the success tail
+ * and the catch block can settle it and gate `disposeClient` on the title. */
+ let immediateTitlePromise = null;
+ let titleEventPromise = null;
+ let acceptsTitleEvents = true;
+ let resolveConvoReady;
+ const convoReady = new Promise((resolve) => {
+ resolveConvoReady = resolve;
+ });
+ /** Dedicated controller so a user Stop (or a replaced stream) cancels the
+ * in-flight title — kept separate from `job.abortController`, which
+ * `completeJob` also aborts on *successful* completion and would otherwise
+ * cancel a title that is merely slower than a short response. */
+ const titleAbortController = new AbortController();
+ /** Separate from `titleAbortController`: a user Stop cancels the in-flight
+ * title model call but keeps a title that already finished generating.
+ * Only a superseded/failed stream aborts this to discard such a title so it
+ * cannot clobber the conversation now owned by the newer run. */
+ const titleDiscardController = new AbortController();
+ const abortTitleOnJobAbort = () => titleAbortController.abort();
+ if (job.abortController.signal.aborted) {
+ titleAbortController.abort();
+ } else {
+ job.abortController.signal.addEventListener('abort', abortTitleOnJobAbort, { once: true });
+ }
+ const titleEligible =
+ addTitle && parentMessageId === Constants.NO_PARENT && isNewConvo && !req.body?.isTemporary;
+ const emitTitleEvent = ({ conversationId: titleConversationId, title }) => {
+ titleEventPromise = (async () => {
+ if (!acceptsTitleEvents || titleAbortController.signal.aborted) {
+ return;
+ }
+ const currentJob = await GenerationJobManager.getJob(streamId);
+ if (!currentJob || currentJob.createdAt !== jobCreatedAt) {
+ return;
+ }
+ if (titleAbortController.signal.aborted) {
+ return;
+ }
+ await GenerationJobManager.emitChunk(streamId, {
+ event: 'title',
+ data: {
+ conversationId: titleConversationId,
+ title,
+ },
+ });
+ })().catch((err) => {
+ logger.error('[ResumableAgentController] Error emitting title event', err);
+ });
+ return titleEventPromise;
+ };
+
+ try {
+ const onStart = (userMsg, respMsgId, _isNewConvo) => {
+ userMessage = userMsg;
+
+ // Store userMessage and responseMessageId upfront for resume capability
+ GenerationJobManager.updateMetadata(streamId, {
+ responseMessageId: respMsgId,
+ userMessage: {
+ messageId: userMsg.messageId,
+ parentMessageId: userMsg.parentMessageId,
+ conversationId: userMsg.conversationId,
+ text: userMsg.text,
+ quotes: userMsg.quotes,
+ // Persist the turn's uploaded files here (authoritative job metadata) so a
+ // HITL resume sources them from the job, not the user DB row — which the
+ // approval prompt can race (the row save may still be in flight when a fast
+ // /resume reads it). Without this an approved tool run can rebuild without the
+ // paused turn's files.
+ ...(Array.isArray(req.body?.files) &&
+ req.body.files.length > 0 && { files: req.body.files }),
+ // Skill selections aren't on `userMsg` yet at onStart (BaseClient adds them
+ // later), so source them from the request — otherwise this update overwrites
+ // the preliminary metadata and a HITL-resumed turn loses its skill pills.
+ ...(Array.isArray(req.body?.manualSkills) &&
+ req.body.manualSkills.length > 0 && { manualSkills: req.body.manualSkills }),
+ ...(Array.isArray(req.body?.alwaysAppliedSkills) &&
+ req.body.alwaysAppliedSkills.length > 0 && {
+ alwaysAppliedSkills: req.body.alwaysAppliedSkills,
+ }),
+ },
+ });
+
+ GenerationJobManager.emitChunk(streamId, {
+ created: true,
+ // Skill selections aren't on `userMessage` yet at onStart (BaseClient adds
+ // them later), so attach them from the request — this is the message
+ // `trackUserMessage` persists as the authoritative job.metadata.userMessage,
+ // and it's what the live client renders the user bubble from.
+ message: {
+ ...userMessage,
+ // Carry files so trackUserMessage (the authoritative writer) persists them on
+ // job.metadata.userMessage for a HITL resume (see the updateMetadata above).
+ ...(Array.isArray(req.body?.files) &&
+ req.body.files.length > 0 && { files: req.body.files }),
+ ...(Array.isArray(req.body?.manualSkills) &&
+ req.body.manualSkills.length > 0 && { manualSkills: req.body.manualSkills }),
+ ...(Array.isArray(req.body?.alwaysAppliedSkills) &&
+ req.body.alwaysAppliedSkills.length > 0 && {
+ alwaysAppliedSkills: req.body.alwaysAppliedSkills,
+ }),
+ },
+ streamId,
+ });
+ };
+
+ const messageOptions = {
+ user: userId,
+ onStart,
+ getReqData,
+ isContinued,
+ isRegenerate,
+ editedContent,
+ conversationId,
+ parentMessageId,
+ abortController: job.abortController,
+ overrideParentMessageId,
+ isEdited: !!editedContent,
+ userMCPAuthMap: result.userMCPAuthMap,
+ responseMessageId: editedResponseMessageId,
+ progressOptions: {
+ res: {
+ write: () => true,
+ end: () => {},
+ headersSent: false,
+ writableEnded: false,
+ },
+ },
+ };
+
+ const sendPromise = client.sendMessage(text, messageOptions);
+
+ if (titleEligible && titleTiming === 'immediate') {
+ immediateTitlePromise = addTitle(req, {
+ text,
+ conversationId,
+ client,
+ immediate: true,
+ convoReady,
+ signal: titleAbortController.signal,
+ discardSignal: titleDiscardController.signal,
+ onTitleGenerated: emitTitleEvent,
+ }).catch((err) => {
+ logger.error('[ResumableAgentController] Error in immediate title generation', err);
+ });
+ }
+
+ const response = await sendPromise;
+
+ // HITL: the turn paused for human review (see AgentClient.handleRunInterrupt).
+ // The job is already `requires_action` with the pending action persisted and
+ // emitted to the client; the resume route owns finishing this turn. Settle the
+ // in-flight user-message / conversation save, then tear down WITHOUT saving a
+ // partial response, emitting a terminal event, or completing the job.
+ if (client?.pendingApproval) {
+ if (response?.databasePromise) {
+ try {
+ await response.databasePromise;
+ } catch (dbErr) {
+ logger.error(
+ '[ResumableAgentController] Error settling databasePromise on HITL pause',
+ dbErr,
+ );
+ }
+ delete response.databasePromise;
+ }
+ // BaseClient saved the response as completed (unfinished:false), but the turn
+ // is paused awaiting a decision. Re-mark it unfinished so an expired / never-
+ // resumed approval doesn't leave a "finished" response in history; the resume
+ // path overwrites it with the full completed message on success.
+ if (response?.messageId) {
+ // Guard against a fast /resume: the user can approve the instant the
+ // pending-action SSE lands, and resume.js can then claim + finalize — saving
+ // the COMPLETED response — while we're still awaiting `response.databasePromise`
+ // above. Marking the row unfinished now would clobber that completed content
+ // with this stale pre-pause response. Only mark unfinished while the job is
+ // STILL paused on THIS generation's action: a claim transitions it out of
+ // `requires_action`, and a replacement bumps `createdAt`. Fail open on a read
+ // error so a genuinely never-resumed approval isn't left looking "finished".
+ let stillPaused = true;
+ try {
+ const liveJob = await GenerationJobManager.getJob(streamId);
+ stillPaused =
+ !!liveJob &&
+ liveJob.status === 'requires_action' &&
+ (client?.jobCreatedAt == null || liveJob.createdAt === client.jobCreatedAt);
+ } catch (readErr) {
+ logger.warn(
+ '[ResumableAgentController] Pause unfinished-save liveness check failed; proceeding',
+ readErr?.message ?? readErr,
+ );
+ }
+ if (!stillPaused) {
+ logger.debug(
+ `[ResumableAgentController] Skipping pause unfinished-save — ${streamId} already resumed/replaced`,
+ );
+ } else {
+ try {
+ await saveMessage(
+ {
+ userId,
+ isTemporary: req?.body?.isTemporary,
+ interfaceConfig: req?.config?.interfaceConfig,
+ },
+ {
+ ...response,
+ endpoint: endpointOption.endpoint,
+ unfinished: true,
+ user: userId,
+ },
+ {
+ context:
+ 'api/server/controllers/agents/request.js - HITL pause (mark unfinished)',
+ },
+ );
+ } catch (saveErr) {
+ logger.error(
+ '[ResumableAgentController] Failed to mark paused response unfinished',
+ saveErr,
+ );
+ }
+ }
+ }
+ titleAbortController.abort();
+ acceptsTitleEvents = false;
+ resolveConvoReady();
+ // handleRunInterrupt already released the concurrency slot the moment it paused
+ // (so a fast /resume isn't 429'd); only release here if that didn't happen.
+ // Always run the MCP request-context cleanup.
+ await cleanupMCPRequestContextForReq(req);
+ if (!client?.pendingRequestReleased) {
+ await decrementPendingRequest(userId);
+ }
+ if (client) {
+ disposeClient(client);
+ }
+ logger.debug(
+ `[ResumableAgentController] Turn paused for approval; awaiting resume: ${streamId}`,
+ );
+ return;
+ }
+
+ const messageId = response.messageId;
+ const endpoint = endpointOption.endpoint;
+ response.endpoint = endpoint;
+
+ const databasePromise = response.databasePromise;
+ delete response.databasePromise;
+
+ const { conversation: convoData = {} } = await databasePromise;
+ const conversation = { ...convoData };
+ conversation.title =
+ conversation && !conversation.title ? null : conversation?.title || 'New Chat';
+
+ if (req.body.files && Array.isArray(client.options.attachments)) {
+ const files = buildMessageFiles(req.body.files, client.options.attachments);
+ if (files.length > 0) {
+ userMessage.files = files;
+ }
+ delete userMessage.image_urls;
+ }
+
+ // Check abort state BEFORE calling completeJob (which triggers abort signal for cleanup)
+ const wasAbortedBeforeComplete = job.abortController.signal.aborted;
+ const shouldGenerateTitle =
+ addTitle &&
+ parentMessageId === Constants.NO_PARENT &&
+ isNewConvo &&
+ !wasAbortedBeforeComplete;
+
+ // 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(reqCtx, userMessage, {
+ context: 'api/server/controllers/agents/request.js - resumable user message',
+ });
+ }
+
+ // CRITICAL: Save response message BEFORE emitting final event.
+ // This prevents race conditions where the client sends a follow-up message
+ // before the response is saved to the database, causing orphaned parentMessageIds.
+ if (client.savedMessageIds && !client.savedMessageIds.has(messageId)) {
+ await saveMessage(
+ reqCtx,
+ { ...response, user: userId, unfinished: wasAbortedBeforeComplete },
+ { context: 'api/server/controllers/agents/request.js - resumable response end' },
+ );
+ }
+
+ // Check if our job was replaced by a new request before emitting
+ // This prevents stale requests from emitting events to newer jobs
+ const currentJob = await GenerationJobManager.getJob(streamId);
+ const jobWasReplaced = !currentJob || currentJob.createdAt !== jobCreatedAt;
+
+ if (jobWasReplaced) {
+ logger.debug(`[ResumableAgentController] Skipping FINAL emit - job was replaced`, {
+ streamId,
+ originalCreatedAt: jobCreatedAt,
+ currentCreatedAt: currentJob?.createdAt,
+ });
+ // Discard the stale title from this replaced stream: cancel it and
+ // unblock its persistence wait without letting it save (the newer job
+ // owns the conversation now).
+ titleAbortController.abort();
+ titleDiscardController.abort();
+ job.abortController.signal.removeEventListener('abort', abortTitleOnJobAbort);
+ acceptsTitleEvents = false;
+ resolveConvoReady();
+ // Still decrement pending request since we incremented at start
+ await finishResumableRequest(req, userId);
+ if (immediateTitlePromise) {
+ immediateTitlePromise.finally(() => {
+ if (client) {
+ disposeClient(client);
+ }
+ });
+ } else if (client) {
+ disposeClient(client);
+ }
+ return;
+ }
+
+ // If the user stopped this turn, cancel the title BEFORE unblocking its
+ // persistence wait — otherwise resolving `convoReady` lets the title task
+ // resume and save before the later abort runs.
+ if (wasAbortedBeforeComplete) {
+ titleAbortController.abort();
+ } else {
+ job.abortController.signal.removeEventListener('abort', abortTitleOnJobAbort);
+ }
+
+ // The conversation row now exists and this stream is authoritative; allow
+ // any in-flight immediate title generation to persist (saveConvo uses noUpsert).
+ resolveConvoReady();
+ acceptsTitleEvents = false;
+
+ if (titleEventPromise) {
+ await titleEventPromise;
+ }
+
+ if (!wasAbortedBeforeComplete) {
+ const finalEvent = {
+ final: true,
+ conversation,
+ title: conversation.title,
+ requestMessage: sanitizeMessageForTransmit(userMessage),
+ responseMessage: { ...response },
+ };
+
+ logger.debug(`[ResumableAgentController] Emitting FINAL event`, {
+ streamId,
+ wasAbortedBeforeComplete,
+ userMessageId: userMessage?.messageId,
+ responseMessageId: response?.messageId,
+ conversationId: conversation?.conversationId,
+ });
+
+ await GenerationJobManager.emitDone(streamId, finalEvent);
+ GenerationJobManager.completeJob(streamId);
+ await finishResumableRequest(req, userId);
+ } else {
+ const finalEvent = {
+ final: true,
+ conversation,
+ title: conversation.title,
+ requestMessage: sanitizeMessageForTransmit(userMessage),
+ responseMessage: { ...response, unfinished: true },
+ };
+
+ logger.debug(`[ResumableAgentController] Emitting ABORTED FINAL event`, {
+ streamId,
+ wasAbortedBeforeComplete,
+ userMessageId: userMessage?.messageId,
+ responseMessageId: response?.messageId,
+ conversationId: conversation?.conversationId,
+ });
+
+ await GenerationJobManager.emitDone(streamId, finalEvent);
+ GenerationJobManager.completeJob(streamId, 'Request aborted');
+ await finishResumableRequest(req, userId);
+ }
+
+ if (titleTiming === 'immediate') {
+ // Title was fired in parallel above (if eligible); a stopped turn already
+ // aborted it before `resolveConvoReady`. Defer disposal until it settles
+ // so the run/req aren't torn down mid-generation.
+ if (immediateTitlePromise) {
+ immediateTitlePromise.finally(() => {
+ if (client) {
+ disposeClient(client);
+ }
+ });
+ } else if (client) {
+ disposeClient(client);
+ }
+ } else if (shouldGenerateTitle) {
+ addTitle(req, {
+ text,
+ response: { ...response },
+ client,
+ })
+ .catch((err) => {
+ logger.error('[ResumableAgentController] Error in title generation', err);
+ })
+ .finally(() => {
+ if (client) {
+ disposeClient(client);
+ }
+ });
+ } else {
+ if (client) {
+ disposeClient(client);
+ }
+ }
+ } catch (error) {
+ // Any failure (user Stop, or a preflight/quota failure before the run is
+ // even created) must cancel the title and unblock its waits: the title's
+ // `_waitForRun` would otherwise never resolve, deferring client disposal
+ // until the 45s title timeout, and no title should persist for a failed turn.
+ titleAbortController.abort();
+ titleDiscardController.abort();
+ job.abortController.signal.removeEventListener('abort', abortTitleOnJobAbort);
+ acceptsTitleEvents = false;
+ resolveConvoReady();
+
+ // Check if this was an abort (not a real error)
+ const wasAborted = job.abortController.signal.aborted || error.message?.includes('abort');
+
+ if (wasAborted) {
+ logger.debug(`[ResumableAgentController] Generation aborted for ${streamId}`);
+ // abortJob already handled emitDone and completeJob
+ } else {
+ logger.error(`[ResumableAgentController] Generation error for ${streamId}:`, error);
+ await GenerationJobManager.emitError(streamId, error.message || 'Generation failed');
+ GenerationJobManager.completeJob(streamId, error.message);
+ }
+
+ await finishResumableRequest(req, userId);
+
+ // Defer disposal until any immediate title settles (it holds the run/req).
+ if (immediateTitlePromise) {
+ immediateTitlePromise.finally(() => {
+ if (client) {
+ disposeClient(client);
+ }
+ });
+ } else if (client) {
+ disposeClient(client);
+ }
+
+ // Don't continue to title generation after error/abort
+ return;
+ }
+ };
+
+ // Start generation and handle any unhandled errors
+ startGeneration().catch(async (err) => {
+ logger.error(
+ `[ResumableAgentController] Unhandled error in background generation: ${err.message}`,
+ );
+ GenerationJobManager.completeJob(streamId, err.message);
+ await finishResumableRequest(req, userId);
+ });
+ } catch (error) {
+ logger.error('[ResumableAgentController] Initialization error:', error);
+ if (!res.headersSent) {
+ res.status(500).json({ error: error.message || 'Failed to start generation' });
+ } else {
+ // JSON already sent, emit error to stream so client can receive it
+ await GenerationJobManager.emitError(streamId, error.message || 'Failed to start generation');
+ }
+ GenerationJobManager.completeJob(streamId, error.message);
+ await finishResumableRequest(req, userId);
+ if (client) {
+ disposeClient(client);
+ }
+ }
+};
+
+/**
+ * Agent Controller - Routes to ResumableAgentController for all requests.
+ * The legacy non-resumable path is kept below but no longer used by default.
+ */
+const AgentController = async (req, res, next, initializeClient, addTitle) => {
+ return ResumableAgentController(req, res, next, initializeClient, addTitle);
+};
+
+/**
+ * Legacy Non-resumable Agent Controller - Uses GenerationJobManager for abort handling.
+ * Response is streamed directly to client via res, but abort state is managed centrally.
+ * @deprecated Use ResumableAgentController instead
+ */
+const _LegacyAgentController = async (req, res, next, initializeClient, addTitle) => {
+ const {
+ text,
+ isRegenerate,
+ endpointOption,
+ conversationId: reqConversationId,
+ isContinued = false,
+ editedContent = null,
+ parentMessageId = null,
+ overrideParentMessageId = null,
+ responseMessageId: editedResponseMessageId = null,
+ } = req.body;
+
+ // Generate conversationId upfront if not provided - streamId === conversationId always
+ // Treat "new" as a placeholder that needs a real UUID (frontend may send "new" for new convos)
+ const isNewConvo = !reqConversationId || reqConversationId === 'new';
+ const conversationId = isNewConvo ? crypto.randomUUID() : reqConversationId;
+ const streamId = conversationId;
+
+ let userMessage;
+ let userMessageId;
+ let responseMessageId;
+ let client = null;
+ let cleanupHandlers = [];
+
+ // Match the same logic used for conversationId generation above
+ const userId = req.user.id;
+
+ if (
+ await isUnpersistedPreliminaryParent({
+ userId,
+ conversationId: reqConversationId,
+ parentMessageId,
+ getMessages,
+ })
+ ) {
+ return rejectPreliminaryParentMessageId(res);
+ }
+
+ await attachConversationCreatedAt(req, { userId, conversationId, isNewConvo });
+
+ // Create handler to avoid capturing the entire parent scope
+ let getReqData = (data = {}) => {
+ for (let key in data) {
+ if (key === 'userMessage') {
+ userMessage = data[key];
+ userMessageId = data[key].messageId;
+ } else if (key === 'responseMessageId') {
+ responseMessageId = data[key];
+ } else if (key === 'promptTokens') {
+ // Update job metadata with prompt tokens for abort handling
+ GenerationJobManager.updateMetadata(streamId, { promptTokens: data[key] });
+ } else if (key === 'sender') {
+ GenerationJobManager.updateMetadata(streamId, { sender: data[key] });
+ }
+ // conversationId is pre-generated, no need to update from callback
+ }
+ };
+
+ // Create a function to handle final cleanup
+ const performCleanup = async () => {
+ logger.debug('[AgentController] Performing cleanup');
+ if (Array.isArray(cleanupHandlers)) {
+ for (const handler of cleanupHandlers) {
+ try {
+ if (typeof handler === 'function') {
+ handler();
+ }
+ } catch (e) {
+ logger.error('[AgentController] Error in cleanup handler', e);
+ }
+ }
+ }
+
+ // Complete the job in GenerationJobManager
+ if (streamId) {
+ logger.debug('[AgentController] Completing job in GenerationJobManager');
+ await GenerationJobManager.completeJob(streamId);
+ }
+
+ // Dispose client properly
+ if (client) {
+ disposeClient(client);
+ }
+
+ // Clear all references
+ client = null;
+ getReqData = null;
+ userMessage = null;
+ cleanupHandlers = null;
+
+ // Clear request data map
+ if (requestDataMap.has(req)) {
+ requestDataMap.delete(req);
+ }
+ logger.debug('[AgentController] Cleanup completed');
+ };
+
+ try {
+ let prelimAbortController = new AbortController();
+ const prelimCloseHandler = createCloseHandler(prelimAbortController);
+ res.on('close', prelimCloseHandler);
+ const removePrelimHandler = (manual) => {
+ try {
+ prelimCloseHandler(manual);
+ res.removeListener('close', prelimCloseHandler);
+ } catch (e) {
+ logger.error('[AgentController] Error removing close listener', e);
+ }
+ };
+ cleanupHandlers.push(removePrelimHandler);
+
+ /** @type {{ client: TAgentClient; userMCPAuthMap?: Record> }} */
+ const result = await initializeClient({
+ req,
+ res,
+ endpointOption,
+ signal: prelimAbortController.signal,
+ });
+
+ if (prelimAbortController.signal?.aborted) {
+ prelimAbortController = null;
+ throw new Error('Request was aborted before initialization could complete');
+ } else {
+ prelimAbortController = null;
+ removePrelimHandler(true);
+ cleanupHandlers.pop();
+ }
+ client = result.client;
+
+ // Register client with finalization registry if available
+ if (clientRegistry) {
+ clientRegistry.register(client, { userId }, client);
+ }
+
+ // Store request data in WeakMap keyed by req object
+ requestDataMap.set(req, { client });
+
+ // Create job in GenerationJobManager for abort handling
+ // streamId === conversationId (pre-generated above)
+ const job = await GenerationJobManager.createJob(streamId, userId, conversationId);
+
+ // Store endpoint metadata for abort handling
+ GenerationJobManager.updateMetadata(streamId, {
+ endpoint: endpointOption.endpoint,
+ iconURL: getEndpointIconURL(req, endpointOption),
+ model: getAgentResponseModel(req, endpointOption),
+ sender: client?.sender,
+ });
+
+ // Store content parts reference for abort
+ if (client?.contentParts) {
+ GenerationJobManager.setContentParts(streamId, client.contentParts);
+ }
+
+ const closeHandler = createCloseHandler(job.abortController);
+ res.on('close', closeHandler);
+ cleanupHandlers.push(() => {
+ try {
+ res.removeListener('close', closeHandler);
+ } catch (e) {
+ logger.error('[AgentController] Error removing close listener', e);
+ }
+ });
+
+ /**
+ * onStart callback - stores user message and response ID for abort handling
+ */
+ const onStart = (userMsg, respMsgId, _isNewConvo) => {
+ sendEvent(res, { message: userMsg, created: true });
+ userMessage = userMsg;
+ userMessageId = userMsg.messageId;
+ responseMessageId = respMsgId;
+
+ // Store metadata for abort handling (conversationId is pre-generated)
+ GenerationJobManager.updateMetadata(streamId, {
+ responseMessageId: respMsgId,
+ userMessage: {
+ messageId: userMsg.messageId,
+ parentMessageId: userMsg.parentMessageId,
+ conversationId,
+ text: userMsg.text,
+ quotes: userMsg.quotes,
+ },
+ });
+ };
+
+ const messageOptions = {
+ user: userId,
+ onStart,
+ getReqData,
+ isContinued,
+ isRegenerate,
+ editedContent,
+ conversationId,
+ parentMessageId,
+ abortController: job.abortController,
+ overrideParentMessageId,
+ isEdited: !!editedContent,
+ userMCPAuthMap: result.userMCPAuthMap,
+ responseMessageId: editedResponseMessageId,
+ progressOptions: {
+ res,
+ },
+ };
+
+ let response = await client.sendMessage(text, messageOptions);
+
+ // Extract what we need and immediately break reference
+ const messageId = response.messageId;
+ const endpoint = endpointOption.endpoint;
+ response.endpoint = endpoint;
+
+ // Store database promise locally
+ const databasePromise = response.databasePromise;
+ delete response.databasePromise;
+
+ // Resolve database-related data
+ const { conversation: convoData = {} } = await databasePromise;
+ const conversation = { ...convoData };
+ conversation.title =
+ conversation && !conversation.title ? null : conversation?.title || 'New Chat';
+
+ if (req.body.files && Array.isArray(client.options.attachments)) {
+ const files = buildMessageFiles(req.body.files, client.options.attachments);
+ if (files.length > 0) {
+ userMessage.files = files;
+ }
+ delete userMessage.image_urls;
+ }
+
+ // Only send if not aborted
+ if (!job.abortController.signal.aborted) {
+ // Create a new response object with minimal copies
+ const finalResponse = { ...response };
+
+ sendEvent(res, {
+ final: true,
+ conversation,
+ title: conversation.title,
+ requestMessage: sanitizeMessageForTransmit(userMessage),
+ responseMessage: finalResponse,
+ });
+ res.end();
+
+ // Save the message if needed
+ if (client.savedMessageIds && !client.savedMessageIds.has(messageId)) {
+ await saveMessage(
+ {
+ userId: req?.user?.id,
+ isTemporary: req?.body?.isTemporary,
+ interfaceConfig: req?.config?.interfaceConfig,
+ },
+ { ...finalResponse, user: userId },
+ { context: 'api/server/controllers/agents/request.js - response end' },
+ );
+ }
+ }
+ // Edge case: sendMessage completed but abort happened during sendCompletion
+ // We need to ensure a final event is sent
+ else if (!res.headersSent && !res.finished) {
+ logger.debug(
+ '[AgentController] Handling edge case: `sendMessage` completed but aborted during `sendCompletion`',
+ );
+
+ const finalResponse = { ...response };
+ finalResponse.error = true;
+
+ sendEvent(res, {
+ final: true,
+ conversation,
+ title: conversation.title,
+ requestMessage: sanitizeMessageForTransmit(userMessage),
+ responseMessage: finalResponse,
+ error: { message: 'Request was aborted during completion' },
+ });
+ res.end();
+ }
+
+ // Save user message if needed
+ if (!client.skipSaveUserMessage) {
+ 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
+ if (addTitle && parentMessageId === Constants.NO_PARENT && isNewConvo) {
+ addTitle(req, {
+ text,
+ response: { ...response },
+ client,
+ })
+ .then(() => {
+ logger.debug('[AgentController] Title generation started');
+ })
+ .catch((err) => {
+ logger.error('[AgentController] Error in title generation', err);
+ })
+ .finally(() => {
+ logger.debug('[AgentController] Title generation completed');
+ performCleanup();
+ });
+ } else {
+ performCleanup();
+ }
+ } catch (error) {
+ // Handle error without capturing much scope
+ handleAbortError(res, req, error, {
+ conversationId,
+ sender: client?.sender,
+ messageId: responseMessageId,
+ parentMessageId: overrideParentMessageId ?? userMessageId ?? parentMessageId,
+ userMessageId,
+ })
+ .catch((err) => {
+ logger.error('[api/server/controllers/agents/request] Error in `handleAbortError`', err);
+ })
+ .finally(() => {
+ performCleanup();
+ });
+ }
+};
+
+module.exports = AgentController;
diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js
new file mode 100644
index 0000000..c88545c
--- /dev/null
+++ b/api/server/controllers/agents/responses.js
@@ -0,0 +1,1240 @@
+const { nanoid } = require('nanoid');
+const { v4: uuidv4 } = require('uuid');
+const { logger } = require('@librechat/data-schemas');
+const { Callback, ToolEndHandler, formatAgentMessages } = require('@librechat/agents');
+const {
+ EModelEndpoint,
+ ResourceType,
+ PermissionBits,
+ hasPermissions,
+ AgentCapabilities,
+} = require('librechat-data-provider');
+const {
+ createRun,
+ applyContextToAgent,
+ buildToolSet,
+ buildAgentScopedContext,
+ buildAgentContextAttachmentsByAgentId,
+ createSafeUser,
+ initializeAgent,
+ loadSkillStates,
+ getBalanceConfig,
+ injectSkillPrimes,
+ extractManualSkills,
+ recordCollectedUsage,
+ createSubagentUsageSink,
+ getTransactionsConfig,
+ findPiiMatchInMessages,
+ discoverConnectedAgents,
+ createToolExecuteHandler,
+ getRemoteAgentPermissions,
+ resolveAgentScopedSkillIds,
+ // Responses API
+ writeDone,
+ buildResponse,
+ generateResponseId,
+ isValidationFailure,
+ emitResponseCreated,
+ createResponseContext,
+ createResponseTracker,
+ setupStreamingResponse,
+ emitResponseInProgress,
+ convertInputToMessages,
+ validateResponseRequest,
+ buildAggregatedResponse,
+ createResponseAggregator,
+ sendResponsesErrorResponse,
+ createResponsesEventHandlers,
+ createAggregatorEventHandlers,
+} = require('@librechat/api');
+const {
+ createResponsesToolEndCallback,
+ buildSummarizationHandlers,
+ markSummarizationUsage,
+ createToolEndCallback,
+ agentLogHandlerObj,
+} = require('~/server/controllers/agents/callbacks');
+const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
+const {
+ findAccessibleResources,
+ getEffectivePermissions,
+} = require('~/server/services/PermissionService');
+const {
+ getSkillToolDeps,
+ getSkillDbMethods,
+ canAuthorSkillFiles,
+ withDeploymentSkillIds,
+ buildAgentToolContext,
+ enrichLoadedToolsWithAgentContext,
+} = require('~/server/services/Endpoints/agents/skillDeps');
+const { getModelsConfig } = require('~/server/controllers/ModelController');
+const { resolveConfigServers } = require('~/server/services/MCP');
+const { getMCPManager } = require('~/config');
+const { logViolation } = require('~/cache');
+const db = require('~/models');
+
+/**
+ * Creates a tool loader function for the agent.
+ * @param {AbortSignal} signal - The abort signal
+ * @param {boolean} [definitionsOnly=true] - When true, returns only serializable
+ * tool definitions without creating full tool instances (for event-driven mode)
+ */
+function createToolLoader(signal, definitionsOnly = true) {
+ return async function loadTools({
+ req,
+ res,
+ tools,
+ model,
+ agentId,
+ provider,
+ tool_options,
+ tool_resources,
+ }) {
+ const agent = { id: agentId, tools, provider, model, tool_options };
+ try {
+ return await loadAgentTools({
+ req,
+ res,
+ agent,
+ signal,
+ tool_resources,
+ definitionsOnly,
+ streamId: null,
+ });
+ } catch (error) {
+ logger.error('Error loading tools for agent ' + agentId, error);
+ }
+ };
+}
+
+/**
+ * Convert Open Responses input items to internal messages
+ * @param {import('@librechat/api').InputItem[]} input
+ * @returns {Array} Internal messages
+ */
+function convertToInternalMessages(input) {
+ return convertInputToMessages(input);
+}
+
+/**
+ * Load messages from a previous response/conversation
+ * @param {string} conversationId - The conversation/response ID
+ * @param {string} userId - The user ID
+ * @returns {Promise} Messages from the conversation
+ */
+async function loadPreviousMessages(conversationId, userId) {
+ try {
+ const messages = await db.getMessages({ conversationId, user: userId });
+ if (!messages || messages.length === 0) {
+ return [];
+ }
+
+ // Convert stored messages to internal format
+ return messages.map((msg) => {
+ const internalMsg = {
+ role: msg.isCreatedByUser ? 'user' : 'assistant',
+ content: '',
+ messageId: msg.messageId,
+ };
+
+ // Handle content - could be string or array
+ if (typeof msg.text === 'string') {
+ internalMsg.content = msg.text;
+ } else if (Array.isArray(msg.content)) {
+ // Handle content parts
+ internalMsg.content = msg.content;
+ } else if (msg.text) {
+ internalMsg.content = String(msg.text);
+ }
+
+ return internalMsg;
+ });
+ } catch (error) {
+ logger.error('[Responses API] Error loading previous messages:', error);
+ return [];
+ }
+}
+
+/**
+ * Save input messages to database
+ * @param {import('express').Request} req
+ * @param {string} conversationId
+ * @param {Array} inputMessages - Internal format messages
+ * @param {string} agentId
+ * @returns {Promise}
+ */
+async function saveInputMessages(req, conversationId, inputMessages, agentId) {
+ for (const msg of inputMessages) {
+ if (msg.role === 'user') {
+ await db.saveMessage(
+ req,
+ {
+ messageId: msg.messageId || nanoid(),
+ conversationId,
+ parentMessageId: null,
+ isCreatedByUser: true,
+ text: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
+ sender: 'User',
+ endpoint: EModelEndpoint.agents,
+ model: agentId,
+ },
+ { context: 'Responses API - save user input' },
+ );
+ }
+ }
+}
+
+/**
+ * Save response output to database
+ * @param {import('express').Request} req
+ * @param {string} conversationId
+ * @param {string} responseId
+ * @param {import('@librechat/api').Response} response
+ * @param {string} agentId
+ * @returns {Promise}
+ */
+async function saveResponseOutput(req, conversationId, responseId, response, agentId) {
+ // Extract text content from output items
+ let responseText = '';
+ for (const item of response.output) {
+ if (item.type === 'message' && item.content) {
+ for (const part of item.content) {
+ if (part.type === 'output_text' && part.text) {
+ responseText += part.text;
+ }
+ }
+ }
+ }
+
+ // Save the assistant message
+ await db.saveMessage(
+ req,
+ {
+ messageId: responseId,
+ conversationId,
+ parentMessageId: null,
+ isCreatedByUser: false,
+ text: responseText,
+ sender: 'Agent',
+ endpoint: EModelEndpoint.agents,
+ model: agentId,
+ finish_reason: response.status === 'completed' ? 'stop' : response.status,
+ tokenCount: response.usage?.output_tokens,
+ },
+ { context: 'Responses API - save assistant response' },
+ );
+}
+
+/**
+ * Save or update conversation
+ * @param {import('express').Request} req
+ * @param {string} conversationId
+ * @param {string} agentId
+ * @param {object} agent
+ * @returns {Promise}
+ */
+async function saveConversation(req, conversationId, agentId, agent) {
+ await db.saveConvo(
+ {
+ userId: req?.user?.id,
+ isTemporary: req?.body?.isTemporary,
+ interfaceConfig: req?.config?.interfaceConfig,
+ },
+ {
+ conversationId,
+ endpoint: EModelEndpoint.agents,
+ agentId,
+ title: agent?.name || 'Open Responses Conversation',
+ model: agent?.model,
+ },
+ { context: 'Responses API - save conversation' },
+ );
+}
+
+/**
+ * Convert stored messages to Open Responses output format
+ * @param {Array} messages - Stored messages
+ * @returns {Array} Output items
+ */
+function convertMessagesToOutputItems(messages) {
+ const output = [];
+
+ for (const msg of messages) {
+ if (!msg.isCreatedByUser) {
+ output.push({
+ type: 'message',
+ id: msg.messageId,
+ role: 'assistant',
+ status: 'completed',
+ content: [
+ {
+ type: 'output_text',
+ text: msg.text || '',
+ annotations: [],
+ },
+ ],
+ });
+ }
+ }
+
+ return output;
+}
+
+/**
+ * Create Response - POST /v1/responses
+ *
+ * Creates a model response following the Open Responses API specification.
+ * Supports both streaming and non-streaming responses.
+ *
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
+ */
+const createResponse = async (req, res) => {
+ const appConfig = req.config;
+ const requestStartTime = Date.now();
+
+ // Validate request
+ const validation = validateResponseRequest(req.body);
+ if (isValidationFailure(validation)) {
+ return sendResponsesErrorResponse(res, 400, validation.error);
+ }
+
+ const request = validation.request;
+ const agentId = request.model;
+ const isStreaming = request.stream === true;
+ const summarizationConfig = appConfig?.summarization;
+
+ // Look up the agent
+ const agent = await db.getAgent({ id: agentId });
+ if (!agent) {
+ return sendResponsesErrorResponse(
+ res,
+ 404,
+ `Agent not found: ${agentId}`,
+ 'not_found',
+ 'model_not_found',
+ );
+ }
+
+ // Generate IDs
+ const responseId = generateResponseId();
+ const context = createResponseContext(request, responseId);
+
+ logger.debug(
+ `[Responses API] Request ${responseId} started for agent ${agentId}, stream: ${isStreaming}`,
+ );
+
+ // Set up abort controller
+ const abortController = new AbortController();
+
+ // Handle client disconnect
+ req.on('close', () => {
+ if (!abortController.signal.aborted) {
+ abortController.abort();
+ logger.debug('[Responses API] Client disconnected, aborting');
+ }
+ });
+
+ try {
+ if (request.previous_response_id != null) {
+ if (typeof request.previous_response_id !== 'string') {
+ return sendResponsesErrorResponse(
+ res,
+ 400,
+ 'previous_response_id must be a string',
+ 'invalid_request',
+ );
+ }
+ if (!(await db.getConvo(req.user?.id, request.previous_response_id))) {
+ return sendResponsesErrorResponse(res, 404, 'Conversation not found', 'not_found');
+ }
+ }
+
+ const conversationId = request.previous_response_id ?? uuidv4();
+ const parentMessageId = null;
+
+ // Build allowed providers set
+ const allowedProviders = new Set(
+ appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders,
+ );
+
+ // Create tool loader
+ const loadTools = createToolLoader(abortController.signal);
+ const skillDbMethods = getSkillDbMethods();
+
+ // Initialize the agent first to check for disableStreaming
+ const endpointOption = {
+ endpoint: agent.provider,
+ model_parameters: agent.model_parameters ?? {},
+ };
+
+ // `filterFilesByAgentAccess` is intentionally omitted: it calls
+ // `checkPermission` with `resourceType: AGENT`, but this route
+ // authorizes callers through `REMOTE_AGENT` (via
+ // `getRemoteAgentPermissions`), so including it would silently drop
+ // owner-attached context files for any remote user who has
+ // `REMOTE_AGENT_VIEWER` but not direct `AGENT_VIEW`.
+ const dbMethods = {
+ getConvoFiles: db.getConvoFiles,
+ getFiles: db.getFiles,
+ getUserKey: db.getUserKey,
+ getMessages: db.getMessages,
+ updateFilesUsage: db.updateFilesUsage,
+ getUserKeyValues: db.getUserKeyValues,
+ getUserCodeFiles: db.getUserCodeFiles,
+ getToolFilesByIds: db.getToolFilesByIds,
+ getCodeGeneratedFiles: db.getCodeGeneratedFiles,
+ listSkillsByAccess: skillDbMethods.listSkillsByAccess,
+ listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
+ getSkillByName: skillDbMethods.getSkillByName,
+ };
+
+ const enabledCapabilities = new Set(
+ appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities,
+ );
+ const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
+ const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true;
+ const accessibleSkillIds = skillsCapabilityEnabled
+ ? withDeploymentSkillIds(
+ await findAccessibleResources({
+ userId: req.user.id,
+ role: req.user.role,
+ resourceType: ResourceType.SKILL,
+ requiredPermissions: PermissionBits.VIEW,
+ }),
+ )
+ : [];
+ const editableSkillIds = skillsCapabilityEnabled
+ ? await findAccessibleResources({
+ userId: req.user.id,
+ role: req.user.role,
+ resourceType: ResourceType.SKILL,
+ requiredPermissions: PermissionBits.EDIT,
+ })
+ : [];
+ const skillCreateAllowed = skillsCapabilityEnabled
+ ? await getSkillToolDeps().canCreateSkill({ req })
+ : false;
+
+ const { skillStates, defaultActiveOnShare } = await loadSkillStates({
+ userId: req.user.id,
+ appConfig,
+ getUserById: db.getUserById,
+ accessibleSkillIds,
+ });
+
+ const manualSkills = extractManualSkills(req.body);
+
+ const primaryScopedSkillIds = resolveAgentScopedSkillIds({
+ agent,
+ accessibleSkillIds,
+ skillsCapabilityEnabled,
+ ephemeralSkillsToggle,
+ });
+ const primaryScopedEditableSkillIds = resolveAgentScopedSkillIds({
+ agent,
+ accessibleSkillIds: editableSkillIds,
+ skillsCapabilityEnabled,
+ ephemeralSkillsToggle,
+ });
+
+ const primaryConfig = await initializeAgent(
+ {
+ req,
+ res,
+ loadTools,
+ requestFiles: [],
+ conversationId,
+ parentMessageId,
+ agent,
+ endpointOption,
+ allowedProviders,
+ isInitialAgent: true,
+ accessibleSkillIds: primaryScopedSkillIds,
+ skillAuthoringAvailable: canAuthorSkillFiles({
+ agent,
+ scopedEditableSkillIds: primaryScopedEditableSkillIds,
+ skillCreateAllowed,
+ skillsCapabilityEnabled,
+ ephemeralSkillsToggle,
+ }),
+ codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
+ skillStates,
+ defaultActiveOnShare,
+ manualSkills,
+ },
+ dbMethods,
+ );
+
+ /**
+ * Per-agent tool-execution context map, keyed by agentId. Ensures the
+ * ON_TOOL_EXECUTE callback routes each sub-agent's tool calls to the
+ * correct toolRegistry / userMCPAuthMap / tool_resources.
+ * @type {Map>,
+ * tool_resources?: object,
+ * actionsEnabled?: boolean,
+ * }>}
+ */
+ const agentToolContexts = new Map();
+ agentToolContexts.set(
+ primaryConfig.id,
+ buildAgentToolContext({ agent, config: primaryConfig }),
+ );
+
+ // Only run BFS discovery (and pay `getModelsConfig` upfront) when the
+ // primary has edges to follow — the common API case is single-agent.
+ let handoffAgentConfigs = new Map();
+ let discoveredEdges = [];
+ let discoveredMCPAuthMap;
+ if (primaryConfig.edges?.length) {
+ const modelsConfig = await getModelsConfig(req);
+ ({
+ agentConfigs: handoffAgentConfigs,
+ edges: discoveredEdges,
+ userMCPAuthMap: discoveredMCPAuthMap,
+ } = await discoverConnectedAgents(
+ {
+ req,
+ res,
+ primaryConfig,
+ endpointOption,
+ allowedProviders,
+ modelsConfig,
+ loadTools,
+ requestFiles: [],
+ conversationId,
+ parentMessageId,
+ // The route enforces REMOTE_AGENT on the primary; every discovered
+ // sub-agent must clear the same sharing boundary, not the looser
+ // in-app AGENT one.
+ resourceType: ResourceType.REMOTE_AGENT,
+ computeAccessibleSkillIds: (handoffAgent) =>
+ resolveAgentScopedSkillIds({
+ agent: handoffAgent,
+ accessibleSkillIds,
+ skillsCapabilityEnabled,
+ ephemeralSkillsToggle,
+ }),
+ computeSkillAuthoringAvailable: (handoffAgent) =>
+ canAuthorSkillFiles({
+ agent: handoffAgent,
+ scopedEditableSkillIds: resolveAgentScopedSkillIds({
+ agent: handoffAgent,
+ accessibleSkillIds: editableSkillIds,
+ skillsCapabilityEnabled,
+ ephemeralSkillsToggle,
+ }),
+ skillCreateAllowed,
+ skillsCapabilityEnabled,
+ ephemeralSkillsToggle,
+ }),
+ skillStates,
+ defaultActiveOnShare,
+ /** @see DiscoverConnectedAgentsParams.codeEnvAvailable */
+ codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
+ },
+ {
+ getAgent: db.getAgent,
+ // Use `getRemoteAgentPermissions` so sub-agent authorization
+ // matches what the route's `createCheckRemoteAgentAccess`
+ // middleware does for the primary: AGENT owners with the SHARE
+ // bit are treated as remotely authorized even without an
+ // explicit REMOTE_AGENT grant.
+ checkPermission: async ({ userId, role, resourceId, requiredPermission }) => {
+ const permissions = await getRemoteAgentPermissions(
+ { getEffectivePermissions },
+ userId,
+ role,
+ resourceId,
+ );
+ return hasPermissions(permissions, requiredPermission);
+ },
+ logViolation,
+ db: dbMethods,
+ onAgentInitialized: (agentId, handoffAgent, config) => {
+ agentToolContexts.set(agentId, buildAgentToolContext({ agent: handoffAgent, config }));
+ },
+ initializeAgent,
+ },
+ ));
+ }
+
+ primaryConfig.edges = discoveredEdges;
+ const runAgents = [primaryConfig, ...handoffAgentConfigs.values()];
+ const mergedMCPAuthMap = discoveredMCPAuthMap ?? primaryConfig.userMCPAuthMap;
+
+ const agentContextAttachmentsByAgentId = buildAgentContextAttachmentsByAgentId(runAgents);
+ const agentScopedContext = await buildAgentScopedContext({
+ agentIds: runAgents.map(({ id }) => id),
+ attachmentsByAgentId: agentContextAttachmentsByAgentId,
+ req,
+ });
+
+ const mcpManager = getMCPManager();
+ const configServers = await resolveConfigServers(req);
+
+ await Promise.all(
+ runAgents.map((runAgent) =>
+ applyContextToAgent({
+ agent: runAgent,
+ agentId: runAgent.id,
+ logger,
+ mcpManager,
+ configServers,
+ sharedRunContext: agentScopedContext.get(runAgent.id) ?? '',
+ }),
+ ),
+ );
+
+ // Determine if streaming is enabled (check both request and agent config)
+ const streamingDisabled = !!primaryConfig.model_parameters?.disableStreaming;
+ const actuallyStreaming = isStreaming && !streamingDisabled;
+
+ // Load previous messages if previous_response_id is provided
+ let previousMessages = [];
+ if (request.previous_response_id) {
+ const userId = req.user?.id ?? 'api-user';
+ previousMessages = await loadPreviousMessages(request.previous_response_id, userId);
+ }
+
+ // Convert input to internal messages
+ const inputMessages = convertToInternalMessages(
+ typeof request.input === 'string' ? request.input : request.input,
+ );
+
+ const piiHit = findPiiMatchInMessages(inputMessages, appConfig?.messageFilter?.pii);
+ if (piiHit != null) {
+ return sendResponsesErrorResponse(
+ res,
+ 400,
+ `Message contains a ${piiHit.label}. Remove it and try again.`,
+ 'invalid_request',
+ 'message_filter_pii_block',
+ );
+ }
+
+ // Merge previous messages with new input
+ const allMessages = [...previousMessages, ...inputMessages];
+
+ const toolSet = buildToolSet(primaryConfig);
+ const formatted = formatAgentMessages(allMessages, {}, toolSet);
+ const formattedMessages = formatted.messages;
+ const initialSummary = formatted.summary;
+ let indexTokenCountMap = formatted.indexTokenCountMap;
+
+ /**
+ * Inject manual + always-apply skill primes so the model sees SKILL.md
+ * bodies for this turn — parity with AgentClient's chat path. The
+ * Responses API uses its own response-builder shape, so LibreChat-
+ * style card SSE events don't apply; only the message-context part
+ * carries over.
+ */
+ const manualSkillPrimes = primaryConfig.manualSkillPrimes;
+ const alwaysApplySkillPrimes = primaryConfig.alwaysApplySkillPrimes;
+ if (
+ (manualSkillPrimes && manualSkillPrimes.length > 0) ||
+ (alwaysApplySkillPrimes && alwaysApplySkillPrimes.length > 0)
+ ) {
+ const primeResult = injectSkillPrimes({
+ initialMessages: formattedMessages,
+ indexTokenCountMap,
+ manualSkillPrimes,
+ alwaysApplySkillPrimes,
+ });
+ indexTokenCountMap = primeResult.indexTokenCountMap;
+ /* Surface the cap-driven always-apply truncation at the controller
+ layer too — `injectSkillPrimes` already logs internally, but the
+ controller-level warn includes endpoint context so operators can
+ tell at a glance which path hit the cap. Mirrors AgentClient's
+ warn in `client.js`. */
+ if (primeResult.alwaysApplyDropped > 0) {
+ logger.warn(
+ `[Responses API] Dropped ${primeResult.alwaysApplyDropped} always-apply prime(s) to stay within MAX_PRIMED_SKILLS_PER_TURN.`,
+ );
+ }
+ }
+
+ /* Stable for the turn: the primary prime list is fixed once
+ `initializeAgent` resolves and is used as the fallback when a
+ specific agent context is unavailable. `codeEnvAvailable` is read
+ per-agent from the stored tool context (admin cap AND that
+ agent's `tools` list includes `execute_code`) — a skills-only
+ agent never gains sandbox access even if the admin enabled the
+ capability globally. */
+ // Create tracker for streaming or aggregator for non-streaming
+ const tracker = actuallyStreaming ? createResponseTracker() : null;
+ const aggregator = actuallyStreaming ? null : createResponseAggregator();
+
+ // Set up response for streaming
+ if (actuallyStreaming) {
+ setupStreamingResponse(res);
+
+ // Create handler config
+ const handlerConfig = {
+ res,
+ context,
+ tracker,
+ };
+
+ // Emit response.created then response.in_progress per Open Responses spec
+ emitResponseCreated(handlerConfig);
+ emitResponseInProgress(handlerConfig);
+
+ // Create event handlers
+ const { handlers: responsesHandlers, finalizeStream } =
+ createResponsesEventHandlers(handlerConfig);
+
+ // Collect usage for balance tracking
+ const collectedUsage = [];
+
+ // Artifact promises for processing tool outputs
+ /** @type {Promise[]} */
+ const artifactPromises = [];
+ // Use Responses API-specific callback that emits librechat:attachment events
+ const toolEndCallback = createResponsesToolEndCallback({
+ req,
+ res,
+ tracker,
+ artifactPromises,
+ });
+
+ // Create tool execute options for event-driven tool execution
+ const toolExecuteOptions = {
+ loadTools: async (toolNames, agentId) => {
+ const ctx =
+ agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {};
+ const result = await loadToolsForExecution({
+ req,
+ res,
+ toolNames,
+ agent: ctx.agent ?? agent,
+ signal: abortController.signal,
+ toolRegistry: ctx.toolRegistry,
+ mcpAvailableTools: ctx.mcpAvailableTools,
+ requestScopedConnections: ctx.requestScopedConnections,
+ userMCPAuthMap: ctx.userMCPAuthMap,
+ tool_resources: ctx.tool_resources,
+ actionsEnabled: ctx.actionsEnabled,
+ });
+ return enrichLoadedToolsWithAgentContext({
+ result,
+ req,
+ ctx,
+ });
+ },
+ toolEndCallback,
+ ...getSkillToolDeps(),
+ };
+
+ // Combine handlers
+ const handlers = {
+ on_message_delta: responsesHandlers.on_message_delta,
+ on_reasoning_delta: responsesHandlers.on_reasoning_delta,
+ on_run_step: responsesHandlers.on_run_step,
+ on_run_step_delta: responsesHandlers.on_run_step_delta,
+ on_chat_model_end: {
+ handle: (event, data, metadata) => {
+ responsesHandlers.on_chat_model_end.handle(event, data);
+ const usage = data?.output?.usage_metadata;
+ if (usage) {
+ const taggedUsage = markSummarizationUsage(usage, metadata);
+ collectedUsage.push(taggedUsage);
+ }
+ },
+ },
+ on_tool_end: new ToolEndHandler(toolEndCallback, logger),
+ on_run_step_completed: { handle: () => {} },
+ on_chain_stream: { handle: () => {} },
+ on_chain_end: { handle: () => {} },
+ 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
+ const userId = req.user?.id ?? 'api-user';
+ const userMCPAuthMap = mergedMCPAuthMap;
+
+ const run = await createRun({
+ agents: runAgents,
+ messages: formattedMessages,
+ indexTokenCountMap,
+ initialSummary,
+ runId: responseId,
+ summarizationConfig,
+ appConfig,
+ signal: abortController.signal,
+ customHandlers: handlers,
+ requestBody: {
+ messageId: responseId,
+ conversationId,
+ },
+ user: { id: userId },
+ tenantId: req.user?.tenantId,
+ /** Bills subagent child-run model calls (reported outside the
+ * streamEvents loop) into the same collectedUsage array. */
+ subagentUsageSink: createSubagentUsageSink(collectedUsage),
+ });
+
+ if (!run) {
+ throw new Error('Failed to create agent run');
+ }
+
+ // Process the stream
+ const config = {
+ runName: 'AgentRun',
+ configurable: {
+ thread_id: conversationId,
+ user_id: userId,
+ user: createSafeUser(req.user),
+ requestBody: {
+ messageId: responseId,
+ conversationId,
+ },
+ ...(userMCPAuthMap != null && { userMCPAuthMap }),
+ },
+ signal: abortController.signal,
+ streamMode: 'values',
+ version: 'v2',
+ };
+
+ await run.processStream({ messages: formattedMessages }, config, {
+ callbacks: {
+ [Callback.TOOL_ERROR]: (graph, error, toolId) => {
+ logger.error(`[Responses API] Tool Error "${toolId}"`, error);
+ },
+ },
+ });
+
+ // Record token usage against balance
+ const balanceConfig = getBalanceConfig(appConfig);
+ const transactionsConfig = getTransactionsConfig(appConfig);
+ recordCollectedUsage(
+ {
+ spendTokens: db.spendTokens,
+ spendStructuredTokens: db.spendStructuredTokens,
+ pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
+ bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
+ },
+ {
+ user: userId,
+ conversationId,
+ collectedUsage,
+ context: 'message',
+ messageId: responseId,
+ balance: balanceConfig,
+ transactions: transactionsConfig,
+ model: primaryConfig.model || agent.model_parameters?.model,
+ },
+ ).catch((err) => {
+ logger.error('[Responses API] Error recording usage:', err);
+ });
+
+ // Finalize the stream
+ finalizeStream();
+ res.end();
+
+ const duration = Date.now() - requestStartTime;
+ logger.debug(`[Responses API] Request ${responseId} completed in ${duration}ms (streaming)`);
+
+ // Save to database if store: true
+ if (request.store === true) {
+ try {
+ // Save conversation
+ await saveConversation(req, conversationId, agentId, agent);
+
+ // Save input messages
+ await saveInputMessages(req, conversationId, inputMessages, agentId);
+
+ // Build response for saving (use tracker with buildResponse for streaming)
+ const finalResponse = buildResponse(context, tracker, 'completed');
+ await saveResponseOutput(req, conversationId, responseId, finalResponse, agentId);
+
+ logger.debug(
+ `[Responses API] Stored response ${responseId} in conversation ${conversationId}`,
+ );
+ } catch (saveError) {
+ logger.error('[Responses API] Error saving response:', saveError);
+ // Don't fail the request if saving fails
+ }
+ }
+
+ // Wait for artifact processing after response ends (non-blocking)
+ if (artifactPromises.length > 0) {
+ Promise.all(artifactPromises).catch((artifactError) => {
+ logger.warn('[Responses API] Error processing artifacts:', artifactError);
+ });
+ }
+ } else {
+ const aggregatorHandlers = createAggregatorEventHandlers(aggregator);
+
+ // Collect usage for balance tracking
+ const collectedUsage = [];
+
+ /** @type {Promise[]} */
+ const artifactPromises = [];
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId: null });
+
+ const toolExecuteOptions = {
+ loadTools: async (toolNames, agentId) => {
+ const ctx =
+ agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {};
+ const result = await loadToolsForExecution({
+ req,
+ res,
+ toolNames,
+ agent: ctx.agent ?? agent,
+ signal: abortController.signal,
+ toolRegistry: ctx.toolRegistry,
+ mcpAvailableTools: ctx.mcpAvailableTools,
+ requestScopedConnections: ctx.requestScopedConnections,
+ userMCPAuthMap: ctx.userMCPAuthMap,
+ tool_resources: ctx.tool_resources,
+ actionsEnabled: ctx.actionsEnabled,
+ });
+ return enrichLoadedToolsWithAgentContext({
+ result,
+ req,
+ ctx,
+ });
+ },
+ toolEndCallback,
+ ...getSkillToolDeps(),
+ };
+
+ const handlers = {
+ on_message_delta: aggregatorHandlers.on_message_delta,
+ on_reasoning_delta: aggregatorHandlers.on_reasoning_delta,
+ on_run_step: aggregatorHandlers.on_run_step,
+ on_run_step_delta: aggregatorHandlers.on_run_step_delta,
+ on_chat_model_end: {
+ handle: (event, data, metadata) => {
+ aggregatorHandlers.on_chat_model_end.handle(event, data);
+ const usage = data?.output?.usage_metadata;
+ if (usage) {
+ const taggedUsage = markSummarizationUsage(usage, metadata);
+ collectedUsage.push(taggedUsage);
+ }
+ },
+ },
+ on_tool_end: new ToolEndHandler(toolEndCallback, logger),
+ on_run_step_completed: { handle: () => {} },
+ on_chain_stream: { handle: () => {} },
+ on_chain_end: { handle: () => {} },
+ 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';
+ const userMCPAuthMap = mergedMCPAuthMap;
+
+ const run = await createRun({
+ agents: runAgents,
+ messages: formattedMessages,
+ indexTokenCountMap,
+ initialSummary,
+ runId: responseId,
+ summarizationConfig,
+ appConfig,
+ signal: abortController.signal,
+ customHandlers: handlers,
+ requestBody: {
+ messageId: responseId,
+ conversationId,
+ },
+ user: { id: userId },
+ tenantId: req.user?.tenantId,
+ /** Bills subagent child-run model calls (reported outside the
+ * streamEvents loop) into the same collectedUsage array. */
+ subagentUsageSink: createSubagentUsageSink(collectedUsage),
+ });
+
+ if (!run) {
+ throw new Error('Failed to create agent run');
+ }
+
+ const config = {
+ runName: 'AgentRun',
+ configurable: {
+ thread_id: conversationId,
+ user_id: userId,
+ user: createSafeUser(req.user),
+ requestBody: {
+ messageId: responseId,
+ conversationId,
+ },
+ ...(userMCPAuthMap != null && { userMCPAuthMap }),
+ },
+ signal: abortController.signal,
+ streamMode: 'values',
+ version: 'v2',
+ };
+
+ await run.processStream({ messages: formattedMessages }, config, {
+ callbacks: {
+ [Callback.TOOL_ERROR]: (graph, error, toolId) => {
+ logger.error(`[Responses API] Tool Error "${toolId}"`, error);
+ },
+ },
+ });
+
+ // Record token usage against balance
+ const balanceConfig = getBalanceConfig(appConfig);
+ const transactionsConfig = getTransactionsConfig(appConfig);
+ recordCollectedUsage(
+ {
+ spendTokens: db.spendTokens,
+ spendStructuredTokens: db.spendStructuredTokens,
+ pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
+ bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
+ },
+ {
+ user: userId,
+ conversationId,
+ collectedUsage,
+ context: 'message',
+ messageId: responseId,
+ balance: balanceConfig,
+ transactions: transactionsConfig,
+ model: primaryConfig.model || agent.model_parameters?.model,
+ },
+ ).catch((err) => {
+ logger.error('[Responses API] Error recording usage:', err);
+ });
+
+ if (artifactPromises.length > 0) {
+ try {
+ await Promise.all(artifactPromises);
+ } catch (artifactError) {
+ logger.warn('[Responses API] Error processing artifacts:', artifactError);
+ }
+ }
+
+ const response = buildAggregatedResponse(context, aggregator);
+
+ if (request.store === true) {
+ try {
+ await saveConversation(req, conversationId, agentId, agent);
+
+ await saveInputMessages(req, conversationId, inputMessages, agentId);
+
+ await saveResponseOutput(req, conversationId, responseId, response, agentId);
+
+ logger.debug(
+ `[Responses API] Stored response ${responseId} in conversation ${conversationId}`,
+ );
+ } catch (saveError) {
+ logger.error('[Responses API] Error saving response:', saveError);
+ // Don't fail the request if saving fails
+ }
+ }
+
+ res.json(response);
+
+ const duration = Date.now() - requestStartTime;
+ logger.debug(
+ `[Responses API] Request ${responseId} completed in ${duration}ms (non-streaming)`,
+ );
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'An error occurred';
+ logger.error('[Responses API] Error:', error);
+
+ // Check if we already started streaming (headers sent)
+ if (res.headersSent) {
+ // Headers already sent, write error event and close
+ writeDone(res);
+ res.end();
+ } else {
+ // Forward upstream provider status codes (e.g., Anthropic 400s) instead of masking as 500
+ const statusCode =
+ typeof error?.status === 'number' && error.status >= 400 && error.status < 600
+ ? error.status
+ : 500;
+ const errorType = statusCode >= 400 && statusCode < 500 ? 'invalid_request' : 'server_error';
+ sendResponsesErrorResponse(res, statusCode, errorMessage, errorType);
+ }
+ }
+};
+
+/**
+ * List available agents as models - GET /v1/models (also works with /v1/responses/models)
+ *
+ * Returns a list of available agents the user has remote access to.
+ *
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
+ */
+const listModels = async (req, res) => {
+ try {
+ const userId = req.user?.id;
+ const userRole = req.user?.role;
+
+ if (!userId) {
+ return sendResponsesErrorResponse(res, 401, 'Authentication required', 'auth_error');
+ }
+
+ // Find agents the user has remote access to (VIEW permission on REMOTE_AGENT)
+ const accessibleAgentIds = await findAccessibleResources({
+ userId,
+ role: userRole,
+ resourceType: ResourceType.REMOTE_AGENT,
+ requiredPermissions: PermissionBits.VIEW,
+ });
+
+ // Get the accessible agents
+ let agents = [];
+ if (accessibleAgentIds.length > 0) {
+ agents = await db.getAgents({ _id: { $in: accessibleAgentIds } });
+ }
+
+ // Convert to models format
+ const models = agents.map((agent) => ({
+ id: agent.id,
+ object: 'model',
+ created: Math.floor(new Date(agent.createdAt).getTime() / 1000),
+ owned_by: agent.author ?? 'librechat',
+ // Additional metadata
+ name: agent.name,
+ description: agent.description,
+ provider: agent.provider,
+ }));
+
+ res.json({
+ object: 'list',
+ data: models,
+ });
+ } catch (error) {
+ logger.error('[Responses API] Error listing models:', error);
+ sendResponsesErrorResponse(
+ res,
+ 500,
+ error instanceof Error ? error.message : 'Failed to list models',
+ 'server_error',
+ );
+ }
+};
+
+/**
+ * Get Response - GET /v1/responses/:id
+ *
+ * Retrieves a stored response by its ID.
+ * The response ID maps to a conversationId in LibreChat's storage.
+ *
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
+ */
+const getResponse = async (req, res) => {
+ try {
+ const responseId = req.params.id;
+ const userId = req.user?.id;
+
+ if (!responseId) {
+ return sendResponsesErrorResponse(res, 400, 'Response ID is required');
+ }
+
+ // The responseId could be either the response ID or the conversation ID
+ // Try to find a conversation with this ID
+ const conversation = await db.getConvo(userId, responseId);
+
+ if (!conversation) {
+ return sendResponsesErrorResponse(
+ res,
+ 404,
+ `Response not found: ${responseId}`,
+ 'not_found',
+ 'response_not_found',
+ );
+ }
+
+ // Load messages for this conversation
+ const messages = await db.getMessages({ conversationId: responseId, user: userId });
+
+ if (!messages || messages.length === 0) {
+ return sendResponsesErrorResponse(
+ res,
+ 404,
+ `No messages found for response: ${responseId}`,
+ 'not_found',
+ 'response_not_found',
+ );
+ }
+
+ // Convert messages to Open Responses output format
+ const output = convertMessagesToOutputItems(messages);
+
+ // Find the last assistant message for usage info
+ const lastAssistantMessage = messages.filter((m) => !m.isCreatedByUser).pop();
+
+ // Build the response object
+ const response = {
+ id: responseId,
+ object: 'response',
+ created_at: Math.floor(new Date(conversation.createdAt || Date.now()).getTime() / 1000),
+ completed_at: Math.floor(new Date(conversation.updatedAt || Date.now()).getTime() / 1000),
+ status: 'completed',
+ incomplete_details: null,
+ model: conversation.agentId || conversation.model || 'unknown',
+ previous_response_id: null,
+ instructions: null,
+ output,
+ error: null,
+ tools: [],
+ tool_choice: 'auto',
+ truncation: 'disabled',
+ parallel_tool_calls: true,
+ text: { format: { type: 'text' } },
+ temperature: 1,
+ top_p: 1,
+ presence_penalty: 0,
+ frequency_penalty: 0,
+ top_logprobs: null,
+ reasoning: null,
+ user: userId,
+ usage: lastAssistantMessage?.tokenCount
+ ? {
+ input_tokens: 0,
+ output_tokens: lastAssistantMessage.tokenCount,
+ total_tokens: lastAssistantMessage.tokenCount,
+ }
+ : null,
+ max_output_tokens: null,
+ max_tool_calls: null,
+ store: true,
+ background: false,
+ service_tier: 'default',
+ metadata: {},
+ safety_identifier: null,
+ prompt_cache_key: null,
+ };
+
+ res.json(response);
+ } catch (error) {
+ logger.error('[Responses API] Error getting response:', error);
+ sendResponsesErrorResponse(
+ res,
+ 500,
+ error instanceof Error ? error.message : 'Failed to get response',
+ 'server_error',
+ );
+ }
+};
+
+module.exports = {
+ createResponse,
+ getResponse,
+ listModels,
+};
diff --git a/api/server/controllers/agents/resume.js b/api/server/controllers/agents/resume.js
new file mode 100644
index 0000000..4c01063
--- /dev/null
+++ b/api/server/controllers/agents/resume.js
@@ -0,0 +1,725 @@
+const { logger } = require('@librechat/data-schemas');
+const { Constants, EModelEndpoint } = require('librechat-data-provider');
+const {
+ GenerationJobManager,
+ isPendingActionStale,
+ mapToolApprovalResolutions,
+ mapAskUserAnswer,
+ attachAskUserQuestionAnswer,
+ findUndecidedToolCalls,
+ findDisallowedDecisions,
+ findIncompleteDecisions,
+ computeAgentRequestFingerprint,
+ deleteAgentCheckpoint,
+ buildAbortedResponseMetadata,
+ sanitizeMessageForTransmit,
+ filterMalformedContentParts,
+ decrementPendingRequest,
+ checkAndIncrementPendingRequest,
+} = require('@librechat/api');
+const { disposeClient } = require('~/server/cleanup');
+const {
+ getMCPRequestContext,
+ cleanupMCPRequestContextForReq,
+} = require('~/server/services/MCPRequestContext');
+const { saveMessage, getConvo, getMessages } = require('~/models');
+
+/**
+ * Upper bound on an `ask_user_question` answer (characters). Generous for any real
+ * reply typed into the question card while still bounding what a crafted POST can
+ * inject into the resumed run's ToolMessage.
+ */
+const MAX_ASK_ANSWER_LENGTH = 16_000;
+
+/** De-duplicate a merged attachment list by a stable artifact identity. */
+function mergeAttachments(existing, incoming) {
+ const seen = new Set();
+ const out = [];
+ for (const attachment of [...(existing ?? []), ...(incoming ?? [])]) {
+ if (!attachment) {
+ continue;
+ }
+ const key =
+ attachment.file_id ??
+ attachment.filepath ??
+ attachment.filename ??
+ JSON.stringify(attachment);
+ if (seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ out.push(attachment);
+ }
+ return out;
+}
+
+/**
+ * Resolve the current segment's tool artifacts and merge them with any already
+ * persisted on the response row. A resumed turn can span multiple pause segments;
+ * each rebuilt client has its own `artifactPromises`, and the final finalize would
+ * otherwise OVERWRITE the row's attachments with only the last segment's. Reading
+ * the persisted row and merging keeps every segment's artifacts on the saved message.
+ */
+async function resolveAccumulatedAttachments({ client, conversationId, responseMessageId }) {
+ const promises = Array.isArray(client?.artifactPromises) ? client.artifactPromises : [];
+ const resolved = promises.length > 0 ? (await Promise.all(promises)).filter(Boolean) : [];
+ let existing = [];
+ if (responseMessageId) {
+ try {
+ const [row] = await getMessages(
+ { conversationId, messageId: responseMessageId },
+ 'attachments',
+ );
+ existing = Array.isArray(row?.attachments) ? row.attachments : [];
+ } catch (err) {
+ logger.warn(
+ '[ResumeAgentController] Failed to read prior attachments for merge',
+ err?.message ?? err,
+ );
+ }
+ }
+ return mergeAttachments(existing, resolved);
+}
+
+/** Resolve the segment's content for an unfinished save (mirrors finalize's source). */
+async function resolveSegmentContent(client, streamId) {
+ const liveContent = Array.isArray(client?.contentParts) ? client.contentParts : [];
+ const rawContent =
+ liveContent.length > 0
+ ? liveContent
+ : ((await GenerationJobManager.getResumeState(streamId))?.aggregatedContent ?? []);
+ return filterMalformedContentParts(rawContent);
+}
+
+/**
+ * A resumed segment that streamed content / produced artifacts and then paused AGAIN
+ * must persist that progress before returning. The next resume rebuilds a fresh client
+ * (empty `contentParts`/`artifactPromises`), so without this an approval that later
+ * expires or is reaped would leave only the EARLIER pause's content on the saved row —
+ * the user loses everything streamed during this segment. Saved as a partial (`$set`,
+ * still `unfinished`) so a subsequent successful resume overwrites it on finalize.
+ */
+async function persistRePauseProgress({ req, client, job, streamId, conversationId }) {
+ const userId = req.user.id;
+ const meta = job.metadata ?? {};
+ const responseMessageId = meta.responseMessageId ?? client.responseMessageId;
+ if (!responseMessageId) {
+ return;
+ }
+ const content = await resolveSegmentContent(client, streamId);
+ const attachments = await resolveAccumulatedAttachments({
+ client,
+ conversationId,
+ responseMessageId,
+ });
+ if (content.length === 0 && attachments.length === 0) {
+ return;
+ }
+ try {
+ await saveMessage(
+ {
+ userId,
+ isTemporary: meta.isTemporary ?? req.body?.isTemporary,
+ interfaceConfig: req?.config?.interfaceConfig,
+ },
+ {
+ messageId: responseMessageId,
+ conversationId,
+ ...(content.length > 0 && { content }),
+ ...(attachments.length > 0 && { attachments }),
+ unfinished: true,
+ user: userId,
+ },
+ { context: 'api/server/controllers/agents/resume.js - re-pause progress persist' },
+ );
+ } catch (err) {
+ logger.error('[ResumeAgentController] Failed to persist re-pause progress', err);
+ }
+}
+
+/** Untenanted jobs (pre-multi-tenancy) remain accessible if the userId check passes. */
+function hasTenantMismatch(job, user) {
+ return job.metadata?.tenantId != null && job.metadata.tenantId !== user.tenantId;
+}
+
+/**
+ * Build the SDK resume value from the wire decision payload, validating against the
+ * pending action. Returns `{ resumeValue }` on success or `{ error }` with an HTTP
+ * status for the route to surface.
+ */
+function resolveResumeValue(pendingAction, body) {
+ const payload = pendingAction.payload;
+ if (payload?.type === 'tool_approval') {
+ const resolutions = Array.isArray(body.decisions) ? body.decisions : [];
+ const undecided = findUndecidedToolCalls(payload, resolutions);
+ if (undecided.length > 0) {
+ return { status: 400, error: 'Every paused tool call must be decided', undecided };
+ }
+ // Enforce the policy's per-tool allowed_decisions — a crafted POST must not
+ // approve a tool the policy restricted to (e.g.) reject/respond.
+ const disallowed = findDisallowedDecisions(payload, resolutions);
+ if (disallowed.length > 0) {
+ return { status: 403, error: 'Decision not permitted for one or more tools', disallowed };
+ }
+ // `edit`/`respond` must carry their payload — otherwise toSdkDecision's defensive
+ // defaults ({} / '') would resume with an empty input/result the user didn't approve.
+ const incomplete = findIncompleteDecisions(resolutions);
+ if (incomplete.length > 0) {
+ return {
+ status: 400,
+ error: 'edit requires editedArguments and respond requires responseText',
+ incomplete,
+ };
+ }
+ return { resumeValue: mapToolApprovalResolutions(resolutions) };
+ }
+ if (payload?.type === 'ask_user_question') {
+ if (typeof body.answer !== 'string' || body.answer.length === 0) {
+ return { status: 400, error: 'An answer is required' };
+ }
+ // The answer becomes a ToolMessage the model must ingest — bound it like any
+ // other user-controlled wire field rather than trusting the client.
+ if (body.answer.length > MAX_ASK_ANSWER_LENGTH) {
+ return { status: 400, error: 'Answer exceeds the maximum length' };
+ }
+ return { resumeValue: mapAskUserAnswer({ answer: body.answer }) };
+ }
+ return { status: 400, error: 'Unsupported pending action type' };
+}
+
+/**
+ * Finalize a resumed turn that ran to completion: persist the (now complete)
+ * response message, emit the terminal event over the existing SSE, complete the
+ * job, and prune the checkpoint. Mirrors the abort route's save shape but for a
+ * successful finish. Best-effort title generation for a first-turn pause.
+ */
+async function finalizeResumedTurn({ req, client, job, streamId, conversationId, addTitle }) {
+ const userId = req.user.id;
+ const checkpointerCfg = req.config?.endpoints?.[EModelEndpoint.agents]?.checkpointer;
+ const meta = job.metadata ?? {};
+ const userMessage = meta.userMessage;
+ // The response hangs off the user message; the *user* message's own parent decides
+ // whether this is the first turn of the conversation (title eligibility).
+ const parentMessageId = userMessage?.messageId ?? Constants.NO_PARENT;
+ const isFirstTurn = (userMessage?.parentMessageId ?? Constants.NO_PARENT) === Constants.NO_PARENT;
+ const responseMessageId = meta.responseMessageId ?? `${userMessage?.messageId ?? 'resumed'}_`;
+ // Sourced from the paused job (persisted at creation), not the resume body — a
+ // temporary chat must stay temporary on resume so its messages aren't persisted.
+ const isTemporary = meta.isTemporary ?? req.body?.isTemporary;
+
+ // Read the raw job data BEFORE completeJob deletes it — its tracked token/context
+ // usage backs the response message's cost rollup (parity with normal completion).
+ const jobData = await GenerationJobManager.getJobStore().getJob(streamId);
+
+ // Job-replacement guard (mirrors the normal request path): jobs are keyed by streamId
+ // (== conversationId), so a new/concurrent request reusing this conversation overwrites
+ // the record with a fresh createdAt. If that happened while we were resuming, finalizing
+ // now would emit `done` to / complete / delete the NEWER turn's job. Skip all terminal
+ // side effects when the job we paused is no longer the live one; the caller's `finally`
+ // still disposes the client + releases the slot.
+ if (!jobData || jobData.createdAt !== job.createdAt) {
+ logger.warn(
+ `[ResumeAgentController] Skipping resumed finalization — job ${streamId} was replaced`,
+ );
+ return;
+ }
+ // Prefer the resumed run's live content: it's complete (seeded with the pre-pause
+ // content) and avoids a Redis re-read that can race appendChunk writes still in
+ // flight. Fall back to the aggregated store content only when the live array is empty.
+ const liveContent = Array.isArray(client?.contentParts) ? client.contentParts : [];
+ const rawContent =
+ liveContent.length > 0
+ ? liveContent
+ : ((await GenerationJobManager.getResumeState(streamId))?.aggregatedContent ?? []);
+ // Parity with the normal agents path (AgentClient strips these before saving):
+ // drop empty/malformed tool_call parts so a resumed turn can't persist an invalid
+ // part that breaks reload/rendering.
+ const content = filterMalformedContentParts(rawContent);
+
+ const responseMessage = {
+ messageId: responseMessageId,
+ parentMessageId,
+ conversationId,
+ content,
+ sender: meta.sender ?? client?.sender ?? 'AI',
+ endpoint: meta.endpoint,
+ iconURL: meta.iconURL,
+ model: meta.model,
+ unfinished: false,
+ error: false,
+ isCreatedByUser: false,
+ user: userId,
+ };
+ if (meta.agent_id ?? req.body?.agent_id) {
+ responseMessage.agent_id = meta.agent_id ?? req.body.agent_id;
+ }
+ // Persist tool artifacts (code files, images, UI resources) the resumed continuation
+ // produced — BaseClient.sendMessage awaits these before saving, but the lean resume
+ // path bypasses it, so do it here or they vanish on reload / for late subscribers.
+ // MERGE with any already on the row (earlier pause segments) rather than overwrite —
+ // the final segment's client only holds its own segment's artifacts.
+ const attachments = await resolveAccumulatedAttachments({
+ client,
+ conversationId,
+ responseMessageId,
+ });
+ if (attachments.length > 0) {
+ responseMessage.attachments = attachments;
+ }
+
+ // Response metadata: the resume client only sees POST-resume usage, while the job's
+ // tracked tokenUsage is cumulative across the pause. Take the cumulative usage (+
+ // summary marker) from the job, and contextUsage / thoughtSignatures from the client
+ // (which the abort-only helper drops). Cumulative usage wins so cost isn't underreported.
+ const clientMeta = client?.buildResponseMetadata?.() ?? null;
+ const cumulativeMeta = jobData ? buildAbortedResponseMetadata(jobData) : null;
+ const responseMetadata = {
+ ...(clientMeta ?? {}),
+ ...(cumulativeMeta?.usage ? { usage: cumulativeMeta.usage } : {}),
+ ...(cumulativeMeta?.summaryUsedTokens != null
+ ? { summaryUsedTokens: cumulativeMeta.summaryUsedTokens }
+ : {}),
+ };
+ if (Object.keys(responseMetadata).length > 0) {
+ responseMessage.metadata = responseMetadata;
+ }
+ // Carry the resumed run's context-window calibration (BaseClient.sendMessage persists
+ // this on the response). Without it, the NEXT turn can't seed its pruner from this
+ // run and falls back to uncalibrated token accounting.
+ if (client?.contextMeta != null) {
+ responseMessage.contextMeta = client.contextMeta;
+ }
+
+ await saveMessage(
+ { userId, isTemporary, interfaceConfig: req?.config?.interfaceConfig },
+ responseMessage,
+ { context: 'api/server/controllers/agents/resume.js - resumed response end' },
+ );
+
+ const convo = await getConvo(userId, conversationId);
+ const conversation = { ...(convo ?? {}), conversationId };
+
+ // First-turn pause: the title was deferred when the turn paused. Generate it BEFORE
+ // completing the stream so the `title` event still reaches the live client (emitChunk
+ // no-ops once completeJob tears down the runtime) and the final event carries the real
+ // title instead of "New Chat". Best-effort — a failure must not fail the resumed turn.
+ if (
+ addTitle &&
+ isFirstTurn &&
+ !isTemporary &&
+ userMessage?.text &&
+ (!convo || !convo.title || convo.title === 'New Chat')
+ ) {
+ try {
+ await addTitle(req, {
+ text: userMessage.text,
+ conversationId,
+ client,
+ onTitleGenerated: ({ conversationId: titleConvoId, title }) => {
+ conversation.title = title;
+ return GenerationJobManager.emitChunk(streamId, {
+ event: 'title',
+ data: { conversationId: titleConvoId, title },
+ });
+ },
+ });
+ } catch (err) {
+ logger.error('[ResumeAgentController] Title generation failed after resume', err);
+ }
+ }
+ conversation.title = conversation.title || 'New Chat';
+
+ // Re-check ownership immediately before the terminal writes. The start-of-function
+ // guard can go stale across the awaits above: saveMessage and (first-turn) title
+ // generation can take long enough for a new request to replace this job on the same
+ // conversationId (streamId == conversationId). Without this second read, emitDone /
+ // completeJob / prune below would emit `done` to and tear down the REPLACEMENT job —
+ // the same hazard the catch-path guard prevents on the failure path.
+ const liveJobBeforeFinalize = await GenerationJobManager.getJobStore().getJob(streamId);
+ if (!liveJobBeforeFinalize || liveJobBeforeFinalize.createdAt !== job.createdAt) {
+ logger.warn(
+ `[ResumeAgentController] Skipping resumed terminal writes — job ${streamId} was replaced mid-finalize`,
+ );
+ return;
+ }
+
+ const finalEvent = {
+ final: true,
+ conversation,
+ title: conversation.title,
+ requestMessage: userMessage
+ ? sanitizeMessageForTransmit({
+ ...userMessage,
+ conversationId,
+ isCreatedByUser: true,
+ // job.metadata.userMessage is persisted without files; carry the restored
+ // uploads (seeded onto req.body.files before reconstruction) so the final SSE
+ // doesn't blank the user bubble's attachments — matching the normal path.
+ ...(Array.isArray(req.body?.files) && req.body.files.length > 0
+ ? { files: req.body.files }
+ : {}),
+ })
+ : null,
+ responseMessage: { ...responseMessage },
+ };
+
+ await GenerationJobManager.emitDone(streamId, finalEvent);
+ // Awaited (not fire-and-forget) so the job's terminal write lands before the
+ // checkpoint prune, and so a failure here doesn't race the controller's error path.
+ try {
+ await GenerationJobManager.completeJob(streamId);
+ } catch (completeErr) {
+ logger.error('[ResumeAgentController] Failed to complete resumed turn', completeErr);
+ }
+ await deleteAgentCheckpoint(conversationId, checkpointerCfg);
+}
+
+/**
+ * Resume a generation that paused for human-in-the-loop review.
+ *
+ * The original run lives in a detached background task that exits when the run
+ * pauses, so this REBUILDS the run from the durable checkpoint (same `thread_id`)
+ * and continues it with the user's decision. The continuation streams over the
+ * client's existing SSE (events flow through the same `streamId`).
+ *
+ * Flow: authorize → map decisions → atomically claim the resume (single-winner) →
+ * ACK → reconstruct the client → `resumeCompletion` → finalize (or re-pause).
+ *
+ * Shares chat.js's middleware (auth, agent access, `buildEndpointOption`) so the
+ * agent/endpoint are reconstructed from the request exactly like a normal turn.
+ *
+ * @param {express.Request} req
+ * @param {express.Response} res
+ * @param {express.NextFunction} next
+ * @param {Function} initializeClient
+ * @param {Function} addTitle
+ */
+const ResumeAgentController = async (req, res, next, initializeClient, addTitle) => {
+ const userId = req.user.id;
+ const { conversationId, actionId } = req.body;
+ const streamId = conversationId;
+
+ if (!streamId || streamId === 'new') {
+ return res.status(400).json({ error: 'conversationId is required to resume' });
+ }
+
+ const job = await GenerationJobManager.getJob(streamId);
+ if (!job) {
+ return res.status(404).json({ error: 'No paused generation for this conversation' });
+ }
+ if (job.metadata?.userId && job.metadata.userId !== userId) {
+ return res.status(403).json({ error: 'Unauthorized' });
+ }
+ if (hasTenantMismatch(job, req.user)) {
+ return res.status(403).json({ error: 'Unauthorized' });
+ }
+
+ // The resume must rebuild the SAME agent/endpoint that paused. Require an EXACT
+ // agent_id match when the paused job had one — a request that omits agent_id (or
+ // claims an ephemeral / non-agents endpoint) must not rebuild the claimed checkpoint
+ // on a different graph. The conversation's agent is stable, so a correct client always
+ // sends the right one.
+ const originalAgentId = job.metadata?.agent_id;
+ if (originalAgentId && req.body.agent_id !== originalAgentId) {
+ return res.status(403).json({ error: 'Cannot resume with a different agent' });
+ }
+ // Require an EXACT endpoint match (like agent_id): a request that OMITS endpoint must
+ // not fall through — the shared chat middleware treats a missing/non-agents endpoint
+ // as the ephemeral agent, so omitting it could rebuild the claimed checkpoint on a
+ // different graph. A correct client always echoes the paused endpoint.
+ const originalEndpoint = job.metadata?.endpoint;
+ if (originalEndpoint && req.body.endpoint !== originalEndpoint) {
+ return res.status(403).json({ error: 'Cannot resume on a different endpoint' });
+ }
+
+ const pendingAction = job.metadata?.pendingAction;
+ if (job.status !== 'requires_action') {
+ return res.status(409).json({ error: 'No live pending action to resume' });
+ }
+ if (isPendingActionStale({ pendingAction })) {
+ // The action expired between the pending-action SSE and this submit. Drive the expiry
+ // NOW (expire CAS + terminal SSE) instead of waiting for the periodic sweeper —
+ // otherwise the job sits `requires_action` with a dead action and any attached SSE
+ // client never gets a terminal event, so the stream appears to hang even though the
+ // UI already reported the action as expired.
+ try {
+ await GenerationJobManager.expireApproval(streamId, pendingAction?.actionId);
+ } catch (err) {
+ logger.warn(
+ '[ResumeAgentController] Failed to expire stale action on submit',
+ err?.message ?? err,
+ );
+ }
+ return res.status(409).json({ error: 'No live pending action to resume' });
+ }
+ // Require the actionId the UI sends: without it, a stale/malformed client could
+ // resolve whatever action is currently pending (e.g. answer a different question).
+ if (!actionId) {
+ return res.status(400).json({ error: 'actionId is required to resume' });
+ }
+ if (pendingAction.actionId !== actionId) {
+ return res.status(409).json({ error: 'This decision targets a stale action' });
+ }
+
+ // Pin the graph identity: the resume must rebuild the SAME agent/graph + tool set the
+ // run paused on. The agent_id + endpoint guards above cover saved agents; the
+ // fingerprint additionally catches an ephemeral-agent config swap (its agent_id is
+ // undefined, so the id guard can't tell two ephemeral configs apart). Enforced only
+ // when the paused action carries a fingerprint (in-flight pauses from before this
+ // change won't), and recomputed from the resume body's graph-determining fields.
+ const pinnedFingerprint = pendingAction.requestFingerprint;
+ if (pinnedFingerprint && pinnedFingerprint !== computeAgentRequestFingerprint(req.body ?? {})) {
+ return res.status(403).json({ error: 'Cannot resume with a different agent configuration' });
+ }
+
+ const mapped = resolveResumeValue(pendingAction, req.body);
+ if (mapped.error) {
+ return res.status(mapped.status).json({
+ error: mapped.error,
+ ...(mapped.undecided && { undecided: mapped.undecided }),
+ ...(mapped.disallowed && { disallowed: mapped.disallowed }),
+ ...(mapped.incomplete && { incomplete: mapped.incomplete }),
+ });
+ }
+
+ // Count the resume against the concurrency limit. The original turn released its slot
+ // when it paused, so resuming must re-acquire one — otherwise pausing several turns
+ // and resuming them at once would bypass LIMIT_CONCURRENT_MESSAGES.
+ const { allowed } = await checkAndIncrementPendingRequest(userId);
+ if (!allowed) {
+ return res.status(429).json({ error: 'Too many concurrent requests' });
+ }
+
+ // Atomically claim the resume. The single winner drives the run; a racing second
+ // submit (double-click, two tabs) gets false and must not re-drive — that would
+ // re-execute tools and double-bill.
+ //
+ // The claim runs AFTER the slot increment above but BEFORE the run's own try/finally
+ // that releases it, so a store/Redis error here (unlike the clean `!claimed` branch)
+ // would leak the concurrency slot until the counter TTL expires — spuriously 429'ing
+ // the user when they retry the still-paused approval. Release the slot on that path too.
+ let claimed;
+ try {
+ claimed = await GenerationJobManager.approvals.resolve(streamId, pendingAction.actionId);
+ } catch (err) {
+ await decrementPendingRequest(userId);
+ logger.error('[ResumeAgentController] Failed to claim resume', err);
+ return res.status(500).json({ error: 'Failed to resume' });
+ }
+ if (!claimed) {
+ await decrementPendingRequest(userId);
+ return res.status(409).json({ error: 'This action was already resolved or has expired' });
+ }
+
+ // Seed the run-scoped MCP request-context store BEFORE the ACK: once `res.json`
+ // finishes the response, a later `getMCPRequestContext(req, res)` (from tool loading)
+ // sees `res` as ended and returns undefined, leaving the resumed run without its MCP
+ // connection store — approved MCP / OAuth-overlay tools would then run without their
+ // request-scoped connections. Pre-seeding with a null `res` + `cleanupOnResponse:false`
+ // mirrors the normal stream path (request.js); torn down in the `finally` below.
+ req._resumableStreamId = streamId;
+ getMCPRequestContext(req, undefined, { cleanupOnResponse: false });
+
+ // ACK immediately; the continuation streams over the client's existing SSE.
+ res.json({ streamId, conversationId, status: 'resuming' });
+
+ // Seed the original thread parent BEFORE initializeClient: initializeAgent scopes
+ // thread files / code artifacts off `req.body.parentMessageId`, and the resume body
+ // doesn't carry it. This is the user message's parent (the thread position);
+ // `client.parentMessageId` below is a different value — the response's parent, i.e.
+ // the user message id.
+ req.body.parentMessageId = job.metadata.userMessage?.parentMessageId ?? Constants.NO_PARENT;
+
+ // Restore the paused user message's OWN uploaded files. initializeAgent rebuilds
+ // code/file sessions by walking the conversation from `parentMessageId`, but
+ // execute-code files are excluded from that lookup, so files uploaded on the paused
+ // turn would be dropped — an approved code/read-file tool would resume without them.
+ //
+ // SECURITY: ALWAYS source files from the paused job, never from the `/resume` body.
+ // `files` is not pinned by the resume fingerprint or replayed via resumeContext, so
+ // honoring a client-supplied `files` array would let a crafted/buggy client resume an
+ // approved code/read-file tool against a DIFFERENT file set than the one the user
+ // approved. A resume reconstructs the SAME paused turn, so there is no legitimate
+ // reason for the client to supply its own files. Prefer the files persisted on the JOB
+ // at onStart (race-free), fall back to the DB row for older jobs, and CLEAR otherwise
+ // so a client-supplied set can never leak through.
+ const metaFiles = job.metadata.userMessage?.files;
+ if (Array.isArray(metaFiles) && metaFiles.length > 0) {
+ req.body.files = metaFiles;
+ } else {
+ let restoredFiles = false;
+ const pausedUserMessageId = job.metadata.userMessage?.messageId;
+ if (pausedUserMessageId) {
+ try {
+ const [row] = await getMessages(
+ { conversationId, messageId: pausedUserMessageId },
+ 'files',
+ );
+ if (Array.isArray(row?.files) && row.files.length > 0) {
+ req.body.files = row.files;
+ restoredFiles = true;
+ }
+ } catch (err) {
+ logger.warn(
+ '[ResumeAgentController] Failed to restore paused user message files',
+ err?.message ?? err,
+ );
+ }
+ }
+ if (!restoredFiles) {
+ // No paused files (or the lookup failed): drop any client-supplied files so a
+ // crafted resume body can't inject a file set the paused turn never had.
+ req.body.files = [];
+ }
+ }
+
+ // Restore the conversation's createdAt so temporal prompt vars ({{current_datetime}},
+ // {{iso_datetime}}, ...) resolve against the SAME anchor the paused graph used rather
+ // than the resume wall-clock. initializeAgent reads `req.conversationCreatedAt`; the
+ // normal path sets it from the convo timestamp (resolveConversationCreatedAt), so mirror
+ // that here. (The original `timezone` is replayed onto req.body via RESUME_CONTEXT_KEYS.)
+ try {
+ const resumedConvo = await getConvo(userId, conversationId);
+ const createdAt = resumedConvo?.createdAt ? new Date(resumedConvo.createdAt) : null;
+ if (createdAt && !Number.isNaN(createdAt.getTime())) {
+ req.conversationCreatedAt = createdAt.toISOString();
+ }
+ } catch (err) {
+ logger.warn(
+ '[ResumeAgentController] Failed to restore conversation timestamp anchor',
+ err?.message ?? err,
+ );
+ }
+
+ let client = null;
+ try {
+ const result = await initializeClient({
+ req,
+ res,
+ endpointOption: req.body.endpointOption,
+ signal: job.abortController.signal,
+ });
+ client = result.client;
+
+ // Bind the rebuilt client to the in-flight turn's identity (no new user message).
+ client.conversationId = streamId;
+ // The resume operates on the SAME job (it moved it running again), so its identity is
+ // the paused job's createdAt — used by the re-pause CAS pre-check + checkpoint prune to
+ // avoid acting on a job a newer request has since replaced.
+ client.jobCreatedAt = job.createdAt;
+ client.responseMessageId = job.metadata.responseMessageId;
+ client.parentMessageId = job.metadata.userMessage?.messageId ?? Constants.NO_PARENT;
+ // Read the pre-pause content BEFORE swapping the store's content reference: the
+ // in-memory store's setContentParts REPLACES the stored array, so reading the
+ // resume state afterward would see the new (empty) client array and lose the seed.
+ const resumeState = await GenerationJobManager.getResumeState(streamId);
+ let seedContent = resumeState?.aggregatedContent ?? [];
+ // Stamp the answered question onto the paused ask_user_question tool-call part
+ // (args = the pendingAction's authoritative question, output = the user's answer):
+ // the streamed arg chunks carry no tool name so the aggregator dropped them, and
+ // no completion event ever fires for this tool — without this the saved part is
+ // an empty "cancelled-looking" tool call. See attachAskUserQuestionAnswer.
+ if (pendingAction.payload?.type === 'ask_user_question') {
+ seedContent = attachAskUserQuestionAnswer(
+ seedContent,
+ pendingAction.payload.question,
+ req.body.answer,
+ );
+ }
+ if (client.contentParts) {
+ GenerationJobManager.setContentParts(streamId, client.contentParts);
+ }
+
+ await client.resumeCompletion({
+ resumeValue: mapped.resumeValue,
+ seedContent,
+ abortController: job.abortController,
+ // Carry the user's MCP auth so approved MCP tools run with their credentials.
+ userMCPAuthMap: result.userMCPAuthMap,
+ // Replay deferred tools discovered before the pause (captured at pause). The rebuilt
+ // graph passes `messages: []`, so without these an approved deferred tool would be
+ // absent from the schema-only toolMap and resume would fail with "unknown tool".
+ discoveredToolNames: job.metadata?.discoveredTools,
+ });
+
+ // The model may pause AGAIN (another tool, or a follow-up question). The pending
+ // action is already persisted + emitted; leave the job `requires_action`.
+ if (client.pendingApproval) {
+ logger.debug(`[ResumeAgentController] Re-paused for approval: ${streamId}`);
+ // Persist this segment's content + artifacts before the fresh client (next
+ // resume) drops them, so an expiring re-pause doesn't lose them; finalize later
+ // overwrites content and merges attachments onto the saved message.
+ await persistRePauseProgress({ req, client, job, streamId, conversationId });
+ return;
+ }
+
+ // If the user aborted mid-resume, the abort route already emitted the terminal
+ // event and finalized the job — don't double-save / double-finalize here.
+ if (job.abortController.signal.aborted) {
+ logger.debug(
+ `[ResumeAgentController] Aborted during resume; abort route finalizes: ${streamId}`,
+ );
+ return;
+ }
+
+ await finalizeResumedTurn({ req, client, job, streamId, conversationId, addTitle });
+ } catch (err) {
+ logger.error('[ResumeAgentController] Resume failed', err);
+ // Job-replacement guard (mirrors finalizeResumedTurn's success-path guard): if a
+ // newer request reused this conversationId while the resume was failing, do NOT emit
+ // the error to / complete / prune the NEWER turn's job. The finally still releases
+ // the slot + disposes. Proceed with finalization if the replacement check itself fails.
+ let stillLive = true;
+ try {
+ const liveJob = await GenerationJobManager.getJobStore().getJob(streamId);
+ stillLive = !!liveJob && liveJob.createdAt === job.createdAt;
+ } catch (readErr) {
+ logger.warn('[ResumeAgentController] Replacement check failed; finalizing anyway', readErr);
+ }
+ if (!stillLive) {
+ logger.warn(
+ `[ResumeAgentController] Skipping failed-resume finalization — job ${streamId} was replaced`,
+ );
+ } else {
+ try {
+ await GenerationJobManager.emitError(streamId, err?.message ?? 'Resume failed');
+ } catch (emitErr) {
+ logger.error('[ResumeAgentController] Failed to emit resume error', emitErr);
+ }
+ try {
+ await GenerationJobManager.completeJob(streamId, err?.message ?? 'Resume failed');
+ } catch (completeErr) {
+ logger.error('[ResumeAgentController] Failed to finalize failed resume', completeErr);
+ // Last resort: force a terminal state so the job isn't orphaned in `running`.
+ await GenerationJobManager.getJobStore()
+ .updateJob(streamId, {
+ status: 'error',
+ completedAt: Date.now(),
+ error: 'Resume failed',
+ })
+ .catch((updErr) =>
+ logger.error('[ResumeAgentController] Fallback job finalize failed', updErr),
+ );
+ }
+ await deleteAgentCheckpoint(
+ conversationId,
+ req.config?.endpoints?.[EModelEndpoint.agents]?.checkpointer,
+ );
+ }
+ } finally {
+ // Tear down the MCP request-context store seeded before the ACK (parity with
+ // request.js's finishResumableRequest). No-op if it was never seeded.
+ await cleanupMCPRequestContextForReq(req);
+ // Release the concurrency slot taken above — UNLESS handleRunInterrupt already
+ // released it on a re-pause (so a fast /resume isn't 429'd). On a normal finish or
+ // error it didn't, so release here. A re-pause re-acquires its own slot next resume.
+ if (!client?.pendingRequestReleased) {
+ await decrementPendingRequest(userId);
+ }
+ if (client) {
+ disposeClient(client);
+ }
+ }
+};
+
+module.exports = ResumeAgentController;
diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js
new file mode 100644
index 0000000..ec5b81a
--- /dev/null
+++ b/api/server/controllers/agents/v1.js
@@ -0,0 +1,1386 @@
+const { z } = require('zod');
+const fs = require('fs').promises;
+const { nanoid } = require('nanoid');
+const { logger } = require('@librechat/data-schemas');
+const {
+ refreshS3Url,
+ agentCreateSchema,
+ agentUpdateSchema,
+ refreshListAvatars,
+ collectEdgeAgentIds,
+ mergeAgentOcrConversion,
+ sanitizeModelParameters,
+ MAX_AVATAR_REFRESH_AGENTS,
+ collectToolResourceFileIds,
+ convertOcrToContextInPlace,
+ stripFileIdsFromToolResources,
+} = require('@librechat/api');
+const {
+ Time,
+ Tools,
+ CacheKeys,
+ Constants,
+ FileSources,
+ ResourceType,
+ AccessRoleIds,
+ PrincipalType,
+ EToolResources,
+ isActionTool,
+ PermissionBits,
+ actionDelimiter,
+ AgentCapabilities,
+ EModelEndpoint,
+ removeNullishValues,
+} = require('librechat-data-provider');
+const {
+ findPubliclyAccessibleResources,
+ getResourcePermissionsMap,
+ findAccessibleResources,
+ hasPublicPermission,
+ grantPermission,
+} = require('~/server/services/PermissionService');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { resizeAvatar } = require('~/server/services/Files/images/avatar');
+const { getFileStrategy } = require('~/server/utils/getFileStrategy');
+const { filterFile } = require('~/server/services/Files/process');
+const { getCachedTools } = require('~/server/services/Config');
+const {
+ createMCPPermissionContext,
+ resolveConfigServers,
+ userCanUseMCPServers,
+} = require('~/server/services/MCP');
+const { attachOwnerContacts } = require('~/server/services/Agents/ownerContact');
+const { getMCPServersRegistry } = require('~/config');
+const { getLogStores } = require('~/cache');
+const db = require('~/models');
+
+const systemTools = {
+ [Tools.execute_code]: true,
+ [Tools.file_search]: true,
+ [Tools.web_search]: true,
+ [Tools.memory]: true,
+};
+
+const MAX_SEARCH_LEN = 100;
+const escapeRegex = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+const getSafeModelParameters = (modelParameters) => {
+ const { useResponsesApi } = modelParameters ?? {};
+ return typeof useResponsesApi === 'boolean' ? { useResponsesApi } : {};
+};
+const hasEditBit = (permission) => (permission & PermissionBits.EDIT) === PermissionBits.EDIT;
+
+const sanitizeViewerSkillScope = (agent, accessibleSkillSet) => {
+ const skillScopeEnabled = agent.skills_enabled === true;
+ delete agent.skills_enabled;
+
+ if (!skillScopeEnabled) {
+ delete agent.skills;
+ return agent;
+ }
+
+ const configuredSkills = Array.isArray(agent.skills) ? agent.skills : [];
+ if (configuredSkills.length === 0) {
+ // Empty allowlist means the viewer's full accessible catalog.
+ delete agent.skills;
+ agent.skills_enabled = true;
+ return agent;
+ }
+
+ const visibleSkills = configuredSkills
+ .map((skillId) => String(skillId))
+ .filter((skillId) => accessibleSkillSet.has(skillId));
+
+ if (visibleSkills.length === 0) {
+ delete agent.skills;
+ return agent;
+ }
+
+ agent.skills = visibleSkills;
+ agent.skills_enabled = true;
+ return agent;
+};
+
+/**
+ * Looks up each referenced agent id in Mongo, splits them into three
+ * buckets the caller needs for validation: ids that don't exist at all,
+ * ids the user lacks VIEW permission on, and ids that are fully
+ * accessible. Missing ids are intentionally NOT treated as unauthorized
+ * — for `edges`, a self-referential `from` can legitimately name the
+ * agent being created (no DB record yet); callers that should reject
+ * missing ids (like the subagent path) read the `missing` bucket
+ * instead.
+ * @param {Iterable} agentIds
+ * @param {string} userId
+ * @param {string} userRole
+ * @returns {Promise<{ missing: string[], unauthorized: string[] }>}
+ */
+const classifyAgentReferences = async (agentIds, userId, userRole) => {
+ const ids = [...new Set(agentIds)];
+ if (ids.length === 0) return { missing: [], unauthorized: [] };
+
+ const agents = await db.getAgents({ id: { $in: ids } });
+ const foundIds = new Set(agents.map((a) => a.id));
+ const missing = ids.filter((id) => !foundIds.has(id));
+
+ if (agents.length === 0) return { missing, unauthorized: [] };
+
+ const permissionsMap = await getResourcePermissionsMap({
+ userId,
+ role: userRole,
+ resourceType: ResourceType.AGENT,
+ resourceIds: agents.map((a) => a._id),
+ });
+
+ const unauthorized = agents
+ .filter((a) => {
+ const bits = permissionsMap.get(a._id.toString()) ?? 0;
+ return (bits & PermissionBits.VIEW) === 0;
+ })
+ .map((a) => a.id);
+
+ return { missing, unauthorized };
+};
+
+/**
+ * Validates VIEW access for every agent referenced in `edges`.
+ * Missing ids are NOT errors here — at create time a self-referential
+ * `from` often names the agent being built, which has no DB record
+ * yet. Only unauthorized (existing but unviewable) ids are returned.
+ */
+const validateEdgeAgentAccess = async (edges, userId, userRole) => {
+ const { unauthorized } = await classifyAgentReferences(
+ collectEdgeAgentIds(edges),
+ userId,
+ userRole,
+ );
+ return unauthorized;
+};
+
+/**
+ * Validates `subagents.agent_ids` more strictly than edges: both
+ * missing AND unauthorized ids are errors. `subagents.agent_ids`
+ * can't self-reference (subagents spawn *other* agents), so a
+ * missing id is always a typo or a reference to a deleted agent —
+ * `initializeClient` would silently drop it at runtime, leaving the
+ * persisted config out of sync with actual spawn targets (Codex P2).
+ * Returning the split lets the caller report each bucket with the
+ * appropriate status.
+ */
+const validateSubagentReferences = (subagents, userId, userRole) =>
+ classifyAgentReferences(subagents?.agent_ids ?? [], userId, userRole);
+
+/**
+ * Returns true when the agents-endpoint `subagents` capability is
+ * enabled in this request's resolved app config. When disabled,
+ * `initializeClient` already strips the `subagents` block at runtime
+ * so persisted `agent_ids` are inert — gating the ACL check on this
+ * keeps stale references in legacy records from blocking unrelated
+ * edits after a capability-off rollback (Codex P2).
+ * @param {Express.Request} req
+ */
+const isSubagentsCapabilityEnabled = (req) => {
+ const capabilities = req.config?.endpoints?.[EModelEndpoint.agents]?.capabilities;
+ if (!Array.isArray(capabilities)) return false;
+ return capabilities.includes(AgentCapabilities.subagents);
+};
+
+/**
+ * Filters tools to only include those the user is authorized to use.
+ * MCP tools must match the exact format `{toolName}_mcp_{serverName}` (exactly 2 segments).
+ * Multi-delimiter keys are rejected to prevent authorization/execution mismatch.
+ * Non-MCP tools must appear in availableTools (global tool cache) or systemTools.
+ *
+ * When `existingTools` is provided and the MCP registry is unavailable (e.g. server restart),
+ * tools already present on the agent are preserved rather than stripped — they were validated
+ * when originally added, and we cannot re-verify them without the registry.
+ * @param {object} params
+ * @param {string[]} params.tools - Raw tool strings from the request
+ * @param {string} params.userId - Requesting user ID for MCP server access check
+ * @param {string} [params.role] - Requesting user's role for ACL principal resolution
+ * @param {object} [params.user] - Requesting user for MCP server use permission checks
+ * @param {{ canUseServers: (user?: object) => Promise }} [params.mcpPermissionContext] - Request-scoped MCP permission context
+ * @param {Record} params.availableTools - Global non-MCP tool cache
+ * @param {string[]} [params.existingTools] - Tools already persisted on the agent document
+ * @param {Record} [params.configServers] - Config-source MCP servers resolved from appConfig overrides
+ * @returns {Promise} Only the authorized subset of tools
+ */
+const filterAuthorizedTools = async ({
+ tools,
+ userId,
+ role,
+ user,
+ mcpPermissionContext,
+ availableTools,
+ existingTools,
+ configServers,
+}) => {
+ const filteredTools = [];
+ let mcpServerConfigs;
+ let registryUnavailable = false;
+ const existingToolSet = existingTools?.length ? new Set(existingTools) : null;
+ const hasMCPTools = tools.some((tool) => tool?.includes(Constants.mcp_delimiter));
+ const canUseMCP = hasMCPTools
+ ? await (mcpPermissionContext
+ ? mcpPermissionContext.canUseServers(user)
+ : userCanUseMCPServers(user))
+ : true;
+ let loggedMCPDenied = false;
+
+ for (const tool of tools) {
+ const isActionToolName = typeof tool === 'string' && isActionTool(tool);
+ const isMCPTool = tool?.includes(Constants.mcp_delimiter) && !isActionToolName;
+
+ if (!isMCPTool) {
+ if (availableTools[tool] || systemTools[tool] || isActionToolName) {
+ filteredTools.push(tool);
+ }
+ continue;
+ }
+
+ if (!canUseMCP) {
+ if (!loggedMCPDenied) {
+ logger.warn(`[filterAuthorizedTools] User ${userId} lacks MCP server use permission`);
+ loggedMCPDenied = true;
+ }
+ continue;
+ }
+
+ if (mcpServerConfigs === undefined) {
+ try {
+ mcpServerConfigs =
+ (role
+ ? await getMCPServersRegistry().getAllServerConfigs(userId, configServers, role)
+ : await getMCPServersRegistry().getAllServerConfigs(userId, configServers)) ?? {};
+ } catch (e) {
+ logger.warn(
+ '[filterAuthorizedTools] MCP registry unavailable, filtering all MCP tools',
+ e.message,
+ );
+ mcpServerConfigs = {};
+ registryUnavailable = true;
+ }
+ }
+
+ const parts = tool.split(Constants.mcp_delimiter);
+ if (parts.length !== 2) {
+ logger.warn(
+ `[filterAuthorizedTools] Rejected malformed MCP tool key "${tool}" for user ${userId}`,
+ );
+ continue;
+ }
+
+ if (registryUnavailable && existingToolSet?.has(tool)) {
+ filteredTools.push(tool);
+ continue;
+ }
+
+ const [, serverName] = parts;
+ if (!serverName || !Object.hasOwn(mcpServerConfigs, serverName)) {
+ logger.warn(
+ `[filterAuthorizedTools] Rejected MCP tool "${tool}" — server "${serverName}" not accessible to user ${userId}`,
+ );
+ continue;
+ }
+
+ filteredTools.push(tool);
+ }
+
+ return filteredTools;
+};
+
+/**
+ * Removes file IDs from tool resources unless they are already attached to the
+ * agent or owned by an allowed uploader.
+ * @param {object} params
+ * @param {object} params.tool_resources
+ * @param {string | object | Array} params.ownerIds
+ * @param {object} [params.existingToolResources]
+ * @param {string} params.logPrefix
+ * @returns {Promise} Count of removed file references.
+ */
+const pruneToolResourceFileIdsForAgent = async ({
+ tool_resources,
+ ownerIds,
+ existingToolResources,
+ logPrefix,
+}) => {
+ const referencedFileIds = collectToolResourceFileIds(tool_resources);
+ if (referencedFileIds.length === 0) {
+ return 0;
+ }
+ const ownerIdSet = new Set(
+ (Array.isArray(ownerIds) ? ownerIds : [ownerIds])
+ .filter(Boolean)
+ .map((ownerId) => ownerId.toString()),
+ );
+ const existingFileIds = new Set(collectToolResourceFileIds(existingToolResources ?? {}));
+
+ try {
+ const files = await db.getFiles({ file_id: { $in: referencedFileIds } }, null, {
+ file_id: 1,
+ user: 1,
+ });
+ const allowedIds = new Set(
+ (files ?? [])
+ .filter((file) => {
+ if (!file.user) {
+ return false;
+ }
+ return existingFileIds.has(file.file_id) || ownerIdSet.has(file.user.toString());
+ })
+ .map((file) => file.file_id),
+ );
+ const disallowedIds = referencedFileIds.filter((id) => !allowedIds.has(id));
+ if (disallowedIds.length > 0) {
+ logger.warn(`${logPrefix} Pruning ${disallowedIds.length} invalid file reference(s)`);
+ return stripFileIdsFromToolResources(tool_resources, disallowedIds).removedCount;
+ }
+ return 0;
+ } catch (fileCheckError) {
+ logger.warn(`${logPrefix} File ownership check failed, pruning incoming file references`, {
+ error: fileCheckError?.message,
+ });
+ return stripFileIdsFromToolResources(tool_resources, referencedFileIds).removedCount;
+ }
+};
+
+/**
+ * Creates an Agent.
+ * @route POST /Agents
+ * @param {ServerRequest} req - The request object.
+ * @param {AgentCreateParams} req.body - The request body.
+ * @param {ServerResponse} res - The response object.
+ * @returns {Promise} 201 - success response - application/json
+ */
+const createAgentHandler = async (req, res) => {
+ try {
+ const validatedData = agentCreateSchema.parse(req.body);
+ const { tools = [], ...agentData } = removeNullishValues(validatedData);
+
+ if (agentData.model_parameters && typeof agentData.model_parameters === 'object') {
+ agentData.model_parameters = removeNullishValues(
+ sanitizeModelParameters(agentData.model_parameters),
+ true,
+ );
+ }
+
+ const { id: userId, role: userRole } = req.user;
+
+ if (agentData.tool_resources) {
+ await pruneToolResourceFileIdsForAgent({
+ tool_resources: agentData.tool_resources,
+ ownerIds: userId,
+ logPrefix: '[/Agents]',
+ });
+ }
+
+ if (agentData.edges?.length) {
+ const unauthorized = await validateEdgeAgentAccess(agentData.edges, userId, userRole);
+ if (unauthorized.length > 0) {
+ return res.status(403).json({
+ error: 'You do not have access to one or more agents referenced in edges',
+ agent_ids: unauthorized,
+ });
+ }
+ }
+
+ /**
+ * Only validate subagent ACL when the feature is actually enabled
+ * on BOTH the endpoint (capability flag in appConfig) AND the
+ * agent payload. Runtime (`initializeClient` + `run.ts`) checks
+ * `subagents?.enabled` as a truthy predicate — so `undefined` /
+ * `null` / missing `enabled` all disable the feature. The ACL
+ * check must match exactly: only enforce when `enabled === true`.
+ * Otherwise a payload that omits `enabled` (e.g. API clients, or
+ * legacy records that never set the field) could 403 here while
+ * runtime would happily no-op on the subagent tool. Disable-path
+ * is also untouched: toggling `enabled: false` always passes the
+ * gate, so a user who lost VIEW on a child can still save the
+ * disable edit.
+ */
+ if (
+ isSubagentsCapabilityEnabled(req) &&
+ agentData.subagents?.enabled === true &&
+ agentData.subagents?.agent_ids?.length
+ ) {
+ const { missing, unauthorized } = await validateSubagentReferences(
+ agentData.subagents,
+ userId,
+ userRole,
+ );
+ if (missing.length > 0) {
+ return res.status(400).json({
+ error: 'One or more agents referenced in subagents do not exist',
+ agent_ids: missing,
+ });
+ }
+ if (unauthorized.length > 0) {
+ return res.status(403).json({
+ error: 'You do not have access to one or more agents referenced in subagents',
+ agent_ids: unauthorized,
+ });
+ }
+ }
+
+ agentData.id = `agent_${nanoid()}`;
+ agentData.author = userId;
+ agentData.tools = [];
+
+ 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),
+ ]);
+ const mcpPermissionContext = createMCPPermissionContext(req);
+ agentData.tools = await filterAuthorizedTools({
+ tools,
+ userId,
+ role: req.user.role,
+ user: req.user,
+ mcpPermissionContext,
+ availableTools,
+ configServers,
+ });
+
+ const agent = await db.createAgent(agentData);
+
+ try {
+ await Promise.all([
+ grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: userId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_OWNER,
+ grantedBy: userId,
+ }),
+ grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: userId,
+ resourceType: ResourceType.REMOTE_AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.REMOTE_AGENT_OWNER,
+ grantedBy: userId,
+ }),
+ ]);
+ logger.debug(
+ `[createAgent] Granted owner permissions to user ${userId} for agent ${agent.id}`,
+ );
+ } catch (permissionError) {
+ logger.error(
+ `[createAgent] Failed to grant owner permissions for agent ${agent.id}:`,
+ permissionError,
+ );
+ }
+
+ res.status(201).json(agent);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ logger.error('[/Agents] Validation error', error.errors);
+ return res.status(400).json({ error: 'Invalid request data', details: error.errors });
+ }
+ logger.error('[/Agents] Error creating agent', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Retrieves an Agent by ID.
+ * @route GET /Agents/:id
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.id - Agent identifier.
+ * @param {object} req.user - Authenticated user information
+ * @param {string} req.user.id - User ID
+ * @returns {Promise} 200 - success response - application/json
+ * @returns {Error} 404 - Agent not found
+ */
+const getAgentHandler = async (req, res, expandProperties = false) => {
+ try {
+ const id = req.params.id;
+ const author = req.user.id;
+
+ // Permissions are validated by middleware before calling this function.
+ // Load the agent with a `version` count but without the heavy `versions`
+ // array; version history is fetched lazily via GET /agents/:id/versions.
+ const agent = await db.getAgentWithVersionCount({ id });
+
+ if (!agent) {
+ return res.status(404).json({ error: 'Agent not found' });
+ }
+
+ if (agent.avatar && agent.avatar?.source === FileSources.s3) {
+ try {
+ agent.avatar = {
+ ...agent.avatar,
+ filepath: await refreshS3Url(agent.avatar),
+ };
+ } catch (e) {
+ logger.warn('[/Agents/:id] Failed to refresh S3 URL', e);
+ }
+ }
+
+ agent.author = agent.author.toString();
+
+ // Check if agent is public
+ const isPublic = await hasPublicPermission({
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ requiredPermissions: PermissionBits.VIEW,
+ });
+ agent.isPublic = isPublic;
+
+ await attachOwnerContacts([agent]);
+
+ if (agent.author !== author) {
+ delete agent.author;
+ }
+
+ if (!expandProperties) {
+ // VIEW permission: Basic agent info only
+ const responseAgent = {
+ _id: agent._id,
+ id: agent.id,
+ name: agent.name,
+ description: agent.description,
+ conversation_starters: agent.conversation_starters,
+ avatar: agent.avatar,
+ author: agent.author,
+ provider: agent.provider,
+ model: agent.model,
+ model_parameters: getSafeModelParameters(agent.model_parameters),
+ isPublic: agent.isPublic,
+ version: agent.version,
+ // Safe metadata
+ createdAt: agent.createdAt,
+ updatedAt: agent.updatedAt,
+ };
+
+ if (agent.support_contact !== undefined) {
+ responseAgent.support_contact = agent.support_contact;
+ }
+ if (agent.owner_contact !== undefined) {
+ responseAgent.owner_contact = agent.owner_contact;
+ }
+
+ return res.status(200).json(responseAgent);
+ }
+
+ // EDIT permission: Full agent details including sensitive configuration
+ return res.status(200).json(agent);
+ } catch (error) {
+ logger.error('[/Agents/:id] Error retrieving agent', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Retrieves an agent's version history.
+ * Loaded lazily so the editor doesn't transfer large histories up front.
+ * @route GET /agents/:id/versions
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.id - Agent identifier.
+ * @returns {Promise} 200 - The agent's version history - application/json
+ * @returns {Error} 404 - Agent not found
+ */
+const getAgentVersionsHandler = async (req, res) => {
+ try {
+ const id = req.params.id;
+ const versions = await db.getAgentVersions({ id });
+
+ if (versions == null) {
+ return res.status(404).json({ error: 'Agent not found' });
+ }
+
+ return res.status(200).json(versions);
+ } catch (error) {
+ logger.error('[/Agents/:id/versions] Error retrieving agent versions', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Updates an Agent.
+ * @route PATCH /Agents/:id
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.id - Agent identifier.
+ * @param {AgentUpdateParams} req.body - The Agent update parameters.
+ * @returns {Promise} 200 - success response - application/json
+ */
+const updateAgentHandler = async (req, res) => {
+ try {
+ const id = req.params.id;
+ const validatedData = agentUpdateSchema.parse(req.body);
+ // Preserve explicit null for avatar to allow resetting the avatar
+ const { avatar: avatarField, _id, ...rest } = validatedData;
+ const updateData = removeNullishValues(rest);
+
+ if (updateData.model_parameters && typeof updateData.model_parameters === 'object') {
+ updateData.model_parameters = removeNullishValues(
+ sanitizeModelParameters(updateData.model_parameters),
+ true,
+ );
+ }
+
+ if (avatarField === null) {
+ updateData.avatar = avatarField;
+ }
+
+ if (updateData.edges?.length) {
+ const { id: userId, role: userRole } = req.user;
+ const unauthorized = await validateEdgeAgentAccess(updateData.edges, userId, userRole);
+ if (unauthorized.length > 0) {
+ return res.status(403).json({
+ error: 'You do not have access to one or more agents referenced in edges',
+ agent_ids: unauthorized,
+ });
+ }
+ }
+
+ /** Same guard as the create path: capability on the endpoint,
+ * AND `subagents.enabled === true` on the payload (runtime's
+ * truthy check treats `undefined` / `null` / `false` as
+ * disabled, so the ACL check must too). Missing or explicitly-
+ * disabled payloads always pass the gate — that preserves the
+ * "can always save a disable edit" behavior a user might need
+ * after losing VIEW on a referenced child. */
+ if (
+ isSubagentsCapabilityEnabled(req) &&
+ updateData.subagents?.enabled === true &&
+ updateData.subagents?.agent_ids?.length
+ ) {
+ const { id: userId, role: userRole } = req.user;
+ const { missing, unauthorized } = await validateSubagentReferences(
+ updateData.subagents,
+ userId,
+ userRole,
+ );
+ if (missing.length > 0) {
+ return res.status(400).json({
+ error: 'One or more agents referenced in subagents do not exist',
+ agent_ids: missing,
+ });
+ }
+ if (unauthorized.length > 0) {
+ return res.status(403).json({
+ error: 'You do not have access to one or more agents referenced in subagents',
+ agent_ids: unauthorized,
+ });
+ }
+ }
+
+ // Convert OCR to context in incoming updateData
+ convertOcrToContextInPlace(updateData);
+
+ const existingAgent = await db.getAgent({ id });
+
+ if (!existingAgent) {
+ return res.status(404).json({ error: 'Agent not found' });
+ }
+
+ // Convert legacy OCR tool resource to context format in existing agent
+ const ocrConversion = mergeAgentOcrConversion(existingAgent, updateData);
+ if (ocrConversion.tool_resources) {
+ updateData.tool_resources = ocrConversion.tool_resources;
+ }
+ if (ocrConversion.tools) {
+ updateData.tools = ocrConversion.tools;
+ }
+
+ if (updateData.tool_resources) {
+ await pruneToolResourceFileIdsForAgent({
+ tool_resources: updateData.tool_resources,
+ ownerIds: req.user.id,
+ existingToolResources: existingAgent.tool_resources,
+ logPrefix: `[/Agents/:id] Agent ${id}`,
+ });
+ }
+
+ const isMCPTool = (t) =>
+ typeof t === 'string' && t.includes(Constants.mcp_delimiter) && !isActionTool(t);
+ const hasToolUpdate = updateData.tools !== undefined;
+ const editingOwnAgent = existingAgent.author?.toString() === req.user.id;
+ const existingTools = existingAgent.tools ?? [];
+ const effectiveTools = (hasToolUpdate ? updateData.tools : existingAgent.tools) ?? [];
+ const requestedMCPTools = effectiveTools.filter(isMCPTool);
+ const existingMCPTools = existingTools.filter(isMCPTool);
+
+ if (requestedMCPTools.length > 0 || (hasToolUpdate && existingMCPTools.length > 0)) {
+ const mcpPermissionContext = createMCPPermissionContext(req);
+ if (!(await mcpPermissionContext.canUseServers(req.user))) {
+ if (editingOwnAgent) {
+ updateData.tools = effectiveTools.filter((t) => !isMCPTool(t));
+ } else if (hasToolUpdate) {
+ const existingMCPToolSet = new Set(existingMCPTools);
+ const nextTools = updateData.tools.filter(
+ (t) => !isMCPTool(t) || existingMCPToolSet.has(t),
+ );
+ const nextToolSet = new Set(nextTools);
+ for (const existingMCPTool of existingMCPTools) {
+ if (!nextToolSet.has(existingMCPTool)) {
+ nextTools.push(existingMCPTool);
+ }
+ }
+ updateData.tools = nextTools;
+ }
+ } else if (hasToolUpdate) {
+ const existingToolSet = new Set(existingTools);
+ const newMCPTools = requestedMCPTools.filter((t) => !existingToolSet.has(t));
+
+ if (newMCPTools.length > 0) {
+ const [availableTools, configServers] = await Promise.all([
+ getCachedTools().then((t) => t ?? {}),
+ resolveConfigServers(req),
+ ]);
+ const approvedNew = await filterAuthorizedTools({
+ tools: newMCPTools,
+ userId: req.user.id,
+ role: req.user.role,
+ user: req.user,
+ mcpPermissionContext,
+ availableTools,
+ configServers,
+ });
+ const rejectedSet = new Set(newMCPTools.filter((t) => !approvedNew.includes(t)));
+ if (rejectedSet.size > 0) {
+ updateData.tools = updateData.tools.filter((t) => !rejectedSet.has(t));
+ }
+ }
+ }
+ }
+
+ let updatedAgent =
+ Object.keys(updateData).length > 0
+ ? await db.updateAgent({ id }, updateData, {
+ updatingUserId: req.user.id,
+ })
+ : existingAgent;
+
+ // Add version count to the response
+ updatedAgent.version = updatedAgent.versions ? updatedAgent.versions.length : 0;
+
+ if (updatedAgent.author) {
+ updatedAgent.author = updatedAgent.author.toString();
+ }
+
+ await attachOwnerContacts([updatedAgent]);
+
+ if (updatedAgent.author !== req.user.id) {
+ delete updatedAgent.author;
+ }
+
+ return res.json(updatedAgent);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ logger.error('[/Agents/:id] Validation error', error.errors);
+ return res.status(400).json({ error: 'Invalid request data', details: error.errors });
+ }
+
+ logger.error('[/Agents/:id] Error updating Agent', error);
+
+ if (error.statusCode === 409) {
+ return res.status(409).json({
+ error: error.message,
+ details: error.details,
+ });
+ }
+
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Duplicates an Agent based on the provided ID.
+ * @route POST /Agents/:id/duplicate
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.id - Agent identifier.
+ * @returns {Promise} 201 - success response - application/json
+ */
+const duplicateAgentHandler = async (req, res) => {
+ const { id } = req.params;
+ const { id: userId } = req.user;
+ const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
+
+ try {
+ const agent = await db.getAgent({ id });
+ if (!agent) {
+ return res.status(404).json({
+ error: 'Agent not found',
+ status: 'error',
+ });
+ }
+
+ const {
+ id: _id,
+ _id: __id,
+ author: _author,
+ createdAt: _createdAt,
+ updatedAt: _updatedAt,
+ tool_resources: _tool_resources = {},
+ versions: _versions,
+ __v: _v,
+ ...cloneData
+ } = agent;
+ cloneData.name = `${agent.name} (${new Date().toLocaleString('en-US', {
+ dateStyle: 'short',
+ timeStyle: 'short',
+ hour12: false,
+ })})`;
+
+ if (_tool_resources?.[EToolResources.context]) {
+ cloneData.tool_resources = {
+ [EToolResources.context]: _tool_resources[EToolResources.context],
+ };
+ }
+
+ if (_tool_resources?.[EToolResources.ocr]) {
+ cloneData.tool_resources = {
+ /** Legacy conversion from `ocr` to `context` */
+ [EToolResources.context]: {
+ ...(_tool_resources[EToolResources.context] ?? {}),
+ ..._tool_resources[EToolResources.ocr],
+ },
+ };
+ }
+
+ const newAgentId = `agent_${nanoid()}`;
+ const newAgentData = Object.assign(cloneData, {
+ id: newAgentId,
+ author: userId,
+ });
+
+ const newActionsList = [];
+ const originalActions = (await db.getActions({ agent_id: id }, true)) ?? [];
+ const promises = [];
+
+ /**
+ * Duplicates an action and returns the new action ID.
+ * @param {Action} action
+ * @returns {Promise}
+ */
+ const duplicateAction = async (action) => {
+ const newActionId = nanoid();
+ const { domain } = action.metadata;
+ const fullActionId = `${domain}${actionDelimiter}${newActionId}`;
+
+ // Sanitize sensitive metadata before persisting
+ const filteredMetadata = { ...(action.metadata || {}) };
+ for (const field of sensitiveFields) {
+ delete filteredMetadata[field];
+ }
+
+ const newAction = await db.updateAction(
+ { action_id: newActionId, agent_id: newAgentId },
+ {
+ metadata: filteredMetadata,
+ agent_id: newAgentId,
+ user: userId,
+ },
+ );
+
+ newActionsList.push(newAction);
+ return fullActionId;
+ };
+
+ for (const action of originalActions) {
+ promises.push(
+ duplicateAction(action).catch((error) => {
+ logger.error('[/agents/:id/duplicate] Error duplicating Action:', error);
+ }),
+ );
+ }
+
+ const agentActions = await Promise.all(promises);
+ newAgentData.actions = agentActions;
+
+ if (newAgentData.tools?.length) {
+ const [availableTools, configServers] = await Promise.all([
+ getCachedTools().then((t) => t ?? {}),
+ resolveConfigServers(req),
+ ]);
+ const mcpPermissionContext = createMCPPermissionContext(req);
+ newAgentData.tools = await filterAuthorizedTools({
+ tools: newAgentData.tools,
+ userId,
+ role: req.user.role,
+ user: req.user,
+ mcpPermissionContext,
+ availableTools,
+ existingTools: newAgentData.tools,
+ configServers,
+ });
+ }
+
+ if (newAgentData.tool_resources) {
+ await pruneToolResourceFileIdsForAgent({
+ tool_resources: newAgentData.tool_resources,
+ ownerIds: userId,
+ logPrefix: '[/Agents/:id/duplicate]',
+ });
+ }
+
+ const newAgent = await db.createAgent(newAgentData);
+
+ try {
+ await Promise.all([
+ grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: userId,
+ resourceType: ResourceType.AGENT,
+ resourceId: newAgent._id,
+ accessRoleId: AccessRoleIds.AGENT_OWNER,
+ grantedBy: userId,
+ }),
+ grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: userId,
+ resourceType: ResourceType.REMOTE_AGENT,
+ resourceId: newAgent._id,
+ accessRoleId: AccessRoleIds.REMOTE_AGENT_OWNER,
+ grantedBy: userId,
+ }),
+ ]);
+ logger.debug(
+ `[duplicateAgent] Granted owner permissions to user ${userId} for duplicated agent ${newAgent.id}`,
+ );
+ } catch (permissionError) {
+ logger.error(
+ `[duplicateAgent] Failed to grant owner permissions for duplicated agent ${newAgent.id}:`,
+ permissionError,
+ );
+ }
+
+ return res.status(201).json({
+ agent: newAgent,
+ actions: newActionsList,
+ });
+ } catch (error) {
+ logger.error('[/Agents/:id/duplicate] Error duplicating Agent:', error);
+
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Deletes an Agent based on the provided ID.
+ * @route DELETE /Agents/:id
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.id - Agent identifier.
+ * @returns {Promise} 200 - success response - application/json
+ */
+const deleteAgentHandler = async (req, res) => {
+ try {
+ const id = req.params.id;
+ const agent = await db.getAgent({ id });
+ if (!agent) {
+ return res.status(404).json({ error: 'Agent not found' });
+ }
+ await db.deleteAgent({ id });
+ return res.json({ message: 'Agent deleted' });
+ } catch (error) {
+ logger.error('[/Agents/:id] Error deleting Agent', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Lists agents using ACL-aware permissions (ownership + explicit shares).
+ * @route GET /Agents
+ * @param {object} req - Express Request
+ * @param {object} req.query - Request query
+ * @param {string} [req.query.user] - The user ID of the agent's author.
+ * @returns {Promise} 200 - success response - application/json
+ */
+const getListAgentsHandler = async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { category, search, limit = 100, cursor, promoted } = req.query;
+ let requiredPermission = req.query.requiredPermission;
+ if (typeof requiredPermission === 'string') {
+ requiredPermission = parseInt(requiredPermission, 10);
+ if (isNaN(requiredPermission)) {
+ requiredPermission = PermissionBits.VIEW;
+ }
+ } else if (typeof requiredPermission !== 'number') {
+ requiredPermission = PermissionBits.VIEW;
+ }
+ const canReturnSkillConfig = hasEditBit(requiredPermission);
+ // Base filter
+ const filter = {};
+
+ // Handle category filter - only apply if category is defined
+ if (category !== undefined && category.trim() !== '') {
+ filter.category = category;
+ }
+
+ // Handle promoted filter - only from query param
+ if (promoted === '1') {
+ filter.is_promoted = true;
+ } else if (promoted === '0') {
+ filter.is_promoted = { $ne: true };
+ }
+
+ // Handle search filter (escape regex and cap length)
+ if (search && search.trim() !== '') {
+ const safeSearch = escapeRegex(search.trim().slice(0, MAX_SEARCH_LEN));
+ const regex = new RegExp(safeSearch, 'i');
+ filter.$or = [{ name: regex }, { description: regex }];
+ }
+
+ // Get agent IDs the user has VIEW access to via ACL
+ const accessibleIds = await findAccessibleResources({
+ userId,
+ role: req.user.role,
+ resourceType: ResourceType.AGENT,
+ requiredPermissions: requiredPermission,
+ });
+
+ const publiclyAccessibleIds = await findPubliclyAccessibleResources({
+ resourceType: ResourceType.AGENT,
+ requiredPermissions: PermissionBits.VIEW,
+ });
+
+ /**
+ * Refresh all S3 avatars for this user's accessible agent set (not only the current page)
+ * This addresses page-size limits preventing refresh of agents beyond the first page
+ */
+ const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
+ const refreshKey = `${userId}:agents_avatar_refresh`;
+ let cachedRefresh = await cache.get(refreshKey);
+ const isValidCachedRefresh =
+ cachedRefresh != null && typeof cachedRefresh === 'object' && cachedRefresh.urlCache != null;
+ if (!isValidCachedRefresh) {
+ try {
+ const fullList = await db.getListAgentsByAccess({
+ accessibleIds,
+ otherParams: {},
+ limit: MAX_AVATAR_REFRESH_AGENTS,
+ after: null,
+ });
+ const { urlCache } = await refreshListAvatars({
+ agents: fullList?.data ?? [],
+ userId,
+ refreshS3Url,
+ updateAgent: db.updateAgent,
+ });
+ cachedRefresh = { urlCache };
+ await cache.set(refreshKey, cachedRefresh, Time.THIRTY_MINUTES);
+ } catch (err) {
+ logger.error('[/Agents] Error refreshing avatars for full list: %o', err);
+ }
+ } else {
+ logger.debug('[/Agents] S3 avatar refresh already checked, skipping');
+ }
+
+ // Use the new ACL-aware function
+ const data = await db.getListAgentsByAccess({
+ accessibleIds,
+ otherParams: filter,
+ limit,
+ after: cursor,
+ includeSkillConfig: true,
+ });
+
+ const agents = data?.data ?? [];
+ if (!agents.length) {
+ return res.json(data);
+ }
+
+ let accessibleSkillSet = null;
+ if (!canReturnSkillConfig) {
+ const accessibleSkillIds = await findAccessibleResources({
+ userId,
+ role: req.user.role,
+ resourceType: ResourceType.SKILL,
+ requiredPermissions: PermissionBits.VIEW,
+ });
+ accessibleSkillSet = new Set(accessibleSkillIds.map((oid) => oid.toString()));
+ }
+
+ const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString()));
+ const agentsWithContacts = await attachOwnerContacts(agents);
+
+ const urlCache = cachedRefresh?.urlCache;
+ data.data = agentsWithContacts.map((agent) => {
+ if (accessibleSkillSet) {
+ sanitizeViewerSkillScope(agent, accessibleSkillSet);
+ }
+ try {
+ if (agent?._id && publicSet.has(agent._id.toString())) {
+ agent.isPublic = true;
+ }
+ if (
+ urlCache &&
+ agent?.id &&
+ agent?.avatar?.source === FileSources.s3 &&
+ urlCache[agent.id]
+ ) {
+ agent.avatar = { ...agent.avatar, filepath: urlCache[agent.id] };
+ }
+ } catch (e) {
+ // Silently ignore mapping errors
+ void e;
+ }
+ return agent;
+ });
+
+ return res.json(data);
+ } catch (error) {
+ logger.error('[/Agents] Error listing Agents: %o', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Uploads and updates an avatar for a specific agent.
+ * @route POST /:agent_id/avatar
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.agent_id - The ID of the agent.
+ * @param {Express.Multer.File} req.file - The avatar image file.
+ * @param {object} req.body - Request body
+ * @param {string} [req.body.avatar] - Optional avatar for the agent's avatar.
+ * @returns {Promise} 200 - success response - application/json
+ */
+const uploadAgentAvatarHandler = async (req, res) => {
+ try {
+ const appConfig = req.config;
+ if (!req.file) {
+ return res.status(400).json({ message: 'No file uploaded' });
+ }
+ filterFile({ req, file: req.file, image: true, isAvatar: true });
+ const { agent_id } = req.params;
+ if (!agent_id) {
+ return res.status(400).json({ message: 'Agent ID is required' });
+ }
+
+ const existingAgent = await db.getAgent({ id: agent_id });
+
+ if (!existingAgent) {
+ return res.status(404).json({ error: 'Agent not found' });
+ }
+
+ const buffer = await fs.readFile(req.file.path);
+ const fileStrategy = getFileStrategy(appConfig, { isAvatar: true });
+ const resizedBuffer = await resizeAvatar({
+ userId: req.user.id,
+ input: buffer,
+ });
+
+ const { processAvatar } = getStrategyFunctions(fileStrategy);
+ const avatarUrl = await processAvatar({
+ buffer: resizedBuffer,
+ userId: req.user.id,
+ manual: 'false',
+ agentId: agent_id,
+ tenantId: req.user.tenantId,
+ });
+
+ const image = {
+ filepath: avatarUrl,
+ source: fileStrategy,
+ };
+
+ let _avatar = existingAgent.avatar;
+
+ if (_avatar && _avatar.source) {
+ const { deleteFile } = getStrategyFunctions(_avatar.source);
+ try {
+ await deleteFile(req, {
+ filepath: _avatar.filepath,
+ user: req.user.id,
+ tenantId: req.user.tenantId,
+ });
+ await db.deleteFileByFilter({ user: req.user.id, filepath: _avatar.filepath });
+ } catch (error) {
+ logger.error('[/:agent_id/avatar] Error deleting old avatar', error);
+ }
+ }
+
+ const data = {
+ avatar: {
+ filepath: image.filepath,
+ source: image.source,
+ },
+ };
+
+ const updatedAgent = await db.updateAgent({ id: agent_id }, data, {
+ updatingUserId: req.user.id,
+ });
+ await attachOwnerContacts([updatedAgent]);
+
+ try {
+ const avatarCache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
+ await avatarCache.delete(`${req.user.id}:agents_avatar_refresh`);
+ } catch (cacheErr) {
+ logger.error('[/:agent_id/avatar] Error invalidating avatar refresh cache', cacheErr);
+ }
+
+ res.status(201).json(updatedAgent);
+ } catch (error) {
+ const message = 'An error occurred while updating the Agent Avatar';
+ logger.error(
+ `[/:agent_id/avatar] ${message} (${req.params?.agent_id ?? 'unknown agent'})`,
+ error,
+ );
+ res.status(500).json({ message });
+ } finally {
+ try {
+ await fs.unlink(req.file.path);
+ logger.debug('[/:agent_id/avatar] Temp. image upload file deleted');
+ } catch {
+ logger.debug('[/:agent_id/avatar] Temp. image upload file already deleted');
+ }
+ }
+};
+
+/**
+ * Reverts an agent to a previous version from its version history.
+ * @route PATCH /agents/:id/revert
+ * @param {object} req - Express Request object
+ * @param {object} req.params - Request parameters
+ * @param {string} req.params.id - The ID of the agent to revert
+ * @param {object} req.body - Request body
+ * @param {number} req.body.version_index - The index of the version to revert to
+ * @param {object} req.user - Authenticated user information
+ * @param {string} req.user.id - User ID
+ * @param {string} req.user.role - User role
+ * @param {ServerResponse} res - Express Response object
+ * @returns {Promise} 200 - The updated agent after reverting to the specified version
+ * @throws {Error} 400 - If version_index is missing
+ * @throws {Error} 403 - If user doesn't have permission to modify the agent
+ * @throws {Error} 404 - If agent not found
+ * @throws {Error} 500 - If there's an internal server error during the reversion process
+ */
+const revertAgentVersionHandler = async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { version_index } = req.body;
+
+ if (version_index === undefined) {
+ return res.status(400).json({ error: 'version_index is required' });
+ }
+
+ const existingAgent = await db.getAgent({ id });
+
+ if (!existingAgent) {
+ return res.status(404).json({ error: 'Agent not found' });
+ }
+
+ // Permissions are enforced via route middleware (ACL EDIT)
+
+ let updatedAgent = await db.revertAgentVersion({ id }, version_index);
+ const revertUpdates = {};
+
+ if (updatedAgent.tools?.length) {
+ const [availableTools, configServers] = await Promise.all([
+ getCachedTools().then((t) => t ?? {}),
+ resolveConfigServers(req),
+ ]);
+ const mcpPermissionContext = createMCPPermissionContext(req);
+ const filteredTools = await filterAuthorizedTools({
+ tools: updatedAgent.tools,
+ userId: req.user.id,
+ role: req.user.role,
+ user: req.user,
+ mcpPermissionContext,
+ availableTools,
+ existingTools: updatedAgent.tools,
+ configServers,
+ });
+ if (filteredTools.length !== updatedAgent.tools.length) {
+ revertUpdates.tools = filteredTools;
+ }
+ }
+
+ if (updatedAgent.tool_resources) {
+ const removedCount = await pruneToolResourceFileIdsForAgent({
+ tool_resources: updatedAgent.tool_resources,
+ ownerIds: req.user.id,
+ existingToolResources: updatedAgent.tool_resources,
+ logPrefix: '[/Agents/:id/revert]',
+ });
+ if (removedCount > 0) {
+ revertUpdates.tool_resources = updatedAgent.tool_resources;
+ }
+ }
+
+ if (Object.keys(revertUpdates).length > 0) {
+ updatedAgent = await db.updateAgent({ id }, revertUpdates, { updatingUserId: req.user.id });
+ }
+
+ if (updatedAgent.author) {
+ updatedAgent.author = updatedAgent.author.toString();
+ }
+
+ await attachOwnerContacts([updatedAgent]);
+
+ if (updatedAgent.author !== req.user.id) {
+ delete updatedAgent.author;
+ }
+
+ return res.json(updatedAgent);
+ } catch (error) {
+ logger.error('[/agents/:id/revert] Error reverting Agent version', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+/**
+ * Get all agent categories with counts
+ *
+ * @param {Object} _req - Express request object (unused)
+ * @param {Object} res - Express response object
+ */
+const getAgentCategories = async (_req, res) => {
+ try {
+ const categories = await db.getCategoriesWithCounts();
+ const promotedCount = await db.countPromotedAgents();
+ const formattedCategories = categories.map((category) => ({
+ value: category.value,
+ label: category.label,
+ count: category.agentCount,
+ description: category.description,
+ }));
+
+ if (promotedCount > 0) {
+ formattedCategories.unshift({
+ value: 'promoted',
+ label: 'Promoted',
+ count: promotedCount,
+ description: 'Our recommended agents',
+ });
+ }
+
+ formattedCategories.push({
+ value: 'all',
+ label: 'All',
+ description: 'All available agents',
+ });
+
+ res.status(200).json(formattedCategories);
+ } catch (error) {
+ logger.error('[/Agents/Marketplace] Error fetching agent categories:', error);
+ res.status(500).json({
+ error: 'Failed to fetch agent categories',
+ userMessage: 'Unable to load categories. Please refresh the page.',
+ suggestion: 'Try refreshing the page or check your network connection',
+ });
+ }
+};
+module.exports = {
+ createAgent: createAgentHandler,
+ getAgent: getAgentHandler,
+ getAgentVersions: getAgentVersionsHandler,
+ updateAgent: updateAgentHandler,
+ duplicateAgent: duplicateAgentHandler,
+ deleteAgent: deleteAgentHandler,
+ getListAgents: getListAgentsHandler,
+ uploadAgentAvatar: uploadAgentAvatarHandler,
+ revertAgentVersion: revertAgentVersionHandler,
+ getAgentCategories,
+ filterAuthorizedTools,
+};
diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js
new file mode 100644
index 0000000..998156b
--- /dev/null
+++ b/api/server/controllers/agents/v1.spec.js
@@ -0,0 +1,2535 @@
+const mongoose = require('mongoose');
+const { nanoid } = require('nanoid');
+const { v4: uuidv4 } = require('uuid');
+const { agentSchema, aclEntrySchema, fileSchema, userSchema } = require('@librechat/data-schemas');
+const {
+ FileSources,
+ PermissionBits,
+ PrincipalModel,
+ PrincipalType,
+ ResourceType,
+} = require('librechat-data-provider');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+
+// Only mock the dependencies that are not database-related
+jest.mock('~/server/services/Config', () => ({
+ getCachedTools: jest.fn().mockResolvedValue({
+ web_search: true,
+ execute_code: true,
+ file_search: true,
+ }),
+}));
+
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/images/avatar', () => ({
+ resizeAvatar: 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'),
+ refreshS3Url: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/process', () => ({
+ filterFile: jest.fn(),
+}));
+
+jest.mock('~/server/services/PermissionService', () => ({
+ findAccessibleResources: jest.fn().mockResolvedValue([]),
+ findPubliclyAccessibleResources: jest.fn().mockResolvedValue([]),
+ getResourcePermissionsMap: jest.fn().mockResolvedValue(new Map()),
+ grantPermission: jest.fn(),
+ hasPublicPermission: jest.fn().mockResolvedValue(false),
+}));
+
+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 = {
+ get: jest.fn(),
+ set: jest.fn(),
+ delete: jest.fn(),
+};
+jest.mock('~/cache', () => ({
+ getLogStores: jest.fn(() => mockCache),
+}));
+
+const {
+ createAgent: createAgentHandler,
+ getAgent: getAgentHandler,
+ getAgentVersions: getAgentVersionsHandler,
+ duplicateAgent: duplicateAgentHandler,
+ revertAgentVersion: revertAgentVersionHandler,
+ updateAgent: updateAgentHandler,
+ getListAgents: getListAgentsHandler,
+} = require('./v1');
+
+const {
+ findAccessibleResources,
+ findPubliclyAccessibleResources,
+ getResourcePermissionsMap,
+} = require('~/server/services/PermissionService');
+
+const { refreshS3Url } = require('@librechat/api');
+
+/**
+ * @type {import('mongoose').Model}
+ */
+let Agent;
+let AclEntry;
+let User;
+
+const OWNER_PERMISSION_BITS =
+ PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE;
+
+const createOwner = (overrides = {}) =>
+ User.create({
+ name: 'Agent Owner',
+ email: `owner-${nanoid(8)}@example.com`,
+ provider: 'local',
+ emailVerified: true,
+ ...overrides,
+ });
+
+const grantAgentOwner = ({ agent, owner, grantedAt = new Date() }) =>
+ AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalModel: PrincipalModel.USER,
+ principalId: owner._id,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: OWNER_PERMISSION_BITS,
+ grantedBy: owner._id,
+ grantedAt,
+ });
+
+describe('Agent Controllers - Mass Assignment Protection', () => {
+ let mongoServer;
+ let mockReq;
+ let mockRes;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+ Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
+ AclEntry = mongoose.models.AclEntry || mongoose.model('AclEntry', aclEntrySchema);
+ User = mongoose.models.User || mongoose.model('User', userSchema);
+ // Register File so orphan-pruning tests (and the tool_resources validation
+ // test, which now needs real File docs for its ids) have a working model.
+ mongoose.models.File || mongoose.model('File', fileSchema);
+ }, 20000);
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await Agent.deleteMany({});
+ await AclEntry.deleteMany({});
+ await User.deleteMany({});
+ await mongoose.models.File.deleteMany({});
+
+ // Reset all mocks
+ jest.clearAllMocks();
+
+ // Setup mock request and response objects
+ mockReq = {
+ user: {
+ id: new mongoose.Types.ObjectId().toString(),
+ role: 'USER',
+ },
+ body: {},
+ params: {},
+ query: {},
+ app: {
+ locals: {
+ fileStrategy: 'local',
+ },
+ },
+ };
+
+ mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+ });
+
+ describe('createAgentHandler', () => {
+ test('should create agent with allowed fields only', async () => {
+ const validData = {
+ name: 'Test Agent',
+ description: 'A test agent',
+ instructions: 'Be helpful',
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: ['web_search'],
+ model_parameters: { temperature: 0.7 },
+ tool_resources: {
+ file_search: { file_ids: ['file1', 'file2'] },
+ },
+ };
+
+ mockReq.body = validData;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+ expect(mockRes.json).toHaveBeenCalled();
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+ expect(createdAgent.name).toBe('Test Agent');
+ expect(createdAgent.description).toBe('A test agent');
+ expect(createdAgent.provider).toBe('openai');
+ expect(createdAgent.model).toBe('gpt-4');
+ expect(createdAgent.author.toString()).toBe(mockReq.user.id);
+ expect(createdAgent.tools).toContain('web_search');
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: createdAgent.id });
+ expect(agentInDb).toBeDefined();
+ expect(agentInDb.name).toBe('Test Agent');
+ expect(agentInDb.author.toString()).toBe(mockReq.user.id);
+ });
+
+ test('should reject creation with unauthorized fields (mass assignment protection)', async () => {
+ const maliciousData = {
+ // Required fields
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Malicious Agent',
+
+ // Unauthorized fields that should be stripped
+ author: new mongoose.Types.ObjectId().toString(), // Should not be able to set author
+ authorName: 'Hacker', // Should be stripped
+ versions: [], // Should be stripped
+ _id: new mongoose.Types.ObjectId(), // Should be stripped
+ id: 'custom_agent_id', // Should be overridden
+ createdAt: new Date('2020-01-01'), // Should be stripped
+ updatedAt: new Date('2020-01-01'), // Should be stripped
+ };
+
+ mockReq.body = maliciousData;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+
+ // 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.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
+
+ // Verify timestamps are recent (not the malicious dates)
+ const createdTime = new Date(createdAgent.createdAt).getTime();
+ const now = Date.now();
+ expect(now - createdTime).toBeLessThan(5000); // Created within last 5 seconds
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: createdAgent.id });
+ expect(agentInDb.author.toString()).toBe(mockReq.user.id);
+ expect(agentInDb.authorName).toBeUndefined();
+ });
+
+ test('should validate required fields', async () => {
+ const invalidData = {
+ name: 'Missing Required Fields',
+ // Missing provider and model
+ };
+
+ mockReq.body = invalidData;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(mockRes.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: 'Invalid request data',
+ details: expect.any(Array),
+ }),
+ );
+
+ // Verify nothing was created in database
+ const count = await Agent.countDocuments();
+ expect(count).toBe(0);
+ });
+
+ test('should handle tool_resources validation', async () => {
+ const dataWithInvalidToolResources = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Agent with Tool Resources',
+ tool_resources: {
+ // Valid resources
+ file_search: {
+ file_ids: ['file1', 'file2'],
+ vector_store_ids: ['vs1'],
+ },
+ execute_code: {
+ file_ids: ['file3'],
+ },
+ // Invalid resource (should be stripped by schema)
+ invalid_resource: {
+ file_ids: ['file4'],
+ },
+ },
+ };
+
+ mockReq.body = dataWithInvalidToolResources;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+ expect(createdAgent.tool_resources).toBeDefined();
+ expect(createdAgent.tool_resources.file_search).toBeDefined();
+ expect(createdAgent.tool_resources.execute_code).toBeDefined();
+ expect(createdAgent.tool_resources.invalid_resource).toBeUndefined(); // Should be stripped
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: createdAgent.id });
+ expect(agentInDb.tool_resources.invalid_resource).toBeUndefined();
+ });
+
+ test('should strip file_ids not owned by the creator from tool_resources', async () => {
+ const File = mongoose.models.File;
+
+ const ownedFileId = `file_${uuidv4()}`;
+ const otherFileId = `file_${uuidv4()}`;
+ await File.create({
+ file_id: ownedFileId,
+ user: mockReq.user.id,
+ filename: `${ownedFileId}.txt`,
+ filepath: `/tmp/${ownedFileId}`,
+ object: 'file',
+ type: 'text/plain',
+ bytes: 1,
+ source: FileSources.local,
+ });
+ await File.create({
+ file_id: otherFileId,
+ user: new mongoose.Types.ObjectId(),
+ filename: `${otherFileId}.txt`,
+ filepath: `/tmp/${otherFileId}`,
+ object: 'file',
+ type: 'text/plain',
+ bytes: 1,
+ source: FileSources.local,
+ });
+
+ mockReq.body = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Agent with Files',
+ tool_resources: {
+ file_search: { file_ids: [ownedFileId, otherFileId] },
+ },
+ };
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+ const createdAgent = mockRes.json.mock.calls[0][0];
+ expect(createdAgent.tool_resources.file_search.file_ids).toEqual([ownedFileId]);
+ });
+
+ test('should handle support_contact with empty strings', async () => {
+ const dataWithEmptyContact = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Agent with Empty Contact',
+ support_contact: {
+ name: '',
+ email: '',
+ },
+ };
+
+ mockReq.body = dataWithEmptyContact;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+ expect(createdAgent.name).toBe('Agent with Empty Contact');
+ expect(createdAgent.support_contact).toBeDefined();
+ expect(createdAgent.support_contact.name).toBe('');
+ expect(createdAgent.support_contact.email).toBe('');
+ });
+
+ test('should handle support_contact with valid email', async () => {
+ const dataWithValidContact = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Agent with Valid Contact',
+ support_contact: {
+ name: 'Support Team',
+ email: 'support@example.com',
+ },
+ };
+
+ mockReq.body = dataWithValidContact;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+ expect(createdAgent.support_contact).toBeDefined();
+ expect(createdAgent.support_contact.name).toBe('Support Team');
+ expect(createdAgent.support_contact.email).toBe('support@example.com');
+ });
+
+ test('should reject support_contact with invalid email', async () => {
+ const dataWithInvalidEmail = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Agent with Invalid Email',
+ support_contact: {
+ name: 'Support',
+ email: 'not-an-email',
+ },
+ };
+
+ mockReq.body = dataWithInvalidEmail;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(mockRes.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: 'Invalid request data',
+ details: expect.arrayContaining([
+ expect.objectContaining({
+ path: ['support_contact', 'email'],
+ }),
+ ]),
+ }),
+ );
+ });
+
+ test('should handle avatar validation', async () => {
+ const dataWithAvatar = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Agent with Avatar',
+ avatar: {
+ filepath: 'https://example.com/avatar.png',
+ source: 's3',
+ },
+ };
+
+ mockReq.body = dataWithAvatar;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+ expect(createdAgent.avatar).toEqual({
+ filepath: 'https://example.com/avatar.png',
+ source: 's3',
+ });
+ });
+
+ test('should remove empty strings from model_parameters (Issue Fix)', async () => {
+ // This tests the fix for empty strings being sent to API instead of being omitted
+ // When a user clears a numeric field (like max_tokens), it should be removed, not sent as ""
+ const dataWithEmptyModelParams = {
+ provider: 'azureOpenAI',
+ model: 'gpt-4',
+ name: 'Agent with Empty Model Params',
+ model_parameters: {
+ temperature: 0.7, // Valid number - should be preserved
+ max_tokens: '', // Empty string - should be removed
+ maxContextTokens: '', // Empty string - should be removed
+ topP: 0, // Zero value - should be preserved (not treated as empty)
+ frequency_penalty: '', // Empty string - should be removed
+ },
+ };
+
+ mockReq.body = dataWithEmptyModelParams;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+ expect(createdAgent.model_parameters).toBeDefined();
+ // Valid numbers should be preserved
+ expect(createdAgent.model_parameters.temperature).toBe(0.7);
+ expect(createdAgent.model_parameters.topP).toBe(0);
+ // Empty strings should be removed
+ expect(createdAgent.model_parameters.max_tokens).toBeUndefined();
+ expect(createdAgent.model_parameters.maxContextTokens).toBeUndefined();
+ expect(createdAgent.model_parameters.frequency_penalty).toBeUndefined();
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: createdAgent.id });
+ expect(agentInDb.model_parameters.temperature).toBe(0.7);
+ expect(agentInDb.model_parameters.topP).toBe(0);
+ expect(agentInDb.model_parameters.max_tokens).toBeUndefined();
+ expect(agentInDb.model_parameters.maxContextTokens).toBeUndefined();
+ });
+
+ test('should drop non-numeric strings and coerce numeric strings in model_parameters', async () => {
+ // Regression test for #12920: a stray placeholder string ("System") persisted
+ // into max_tokens was forwarded to the provider, causing a 400
+ const dataWithCorruptModelParams = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Agent with Corrupt Model Params',
+ model_parameters: {
+ max_tokens: 'System',
+ maxContextTokens: '256000',
+ fileTokenLimit: 256000,
+ useResponsesApi: true,
+ },
+ };
+
+ mockReq.body = dataWithCorruptModelParams;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+ expect(createdAgent.model_parameters.max_tokens).toBeUndefined();
+ expect(createdAgent.model_parameters.maxContextTokens).toBe(256000);
+ expect(createdAgent.model_parameters.fileTokenLimit).toBe(256000);
+ expect(createdAgent.model_parameters.useResponsesApi).toBe(true);
+
+ const agentInDb = await Agent.findOne({ id: createdAgent.id });
+ expect(agentInDb.model_parameters.max_tokens).toBeUndefined();
+ expect(agentInDb.model_parameters.maxContextTokens).toBe(256000);
+ });
+
+ test('should handle invalid avatar format', async () => {
+ const dataWithInvalidAvatar = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Agent with Invalid Avatar',
+ avatar: 'just-a-string', // Invalid format
+ };
+
+ mockReq.body = dataWithInvalidAvatar;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(mockRes.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: 'Invalid request data',
+ }),
+ );
+ });
+ });
+
+ describe('getAgentHandler', () => {
+ test('should return the safe Responses API flag in the basic VIEW response', async () => {
+ const agent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Azure Agent',
+ description: 'Uses Responses API',
+ provider: 'azureOpenAI',
+ model: 'gpt-5.5',
+ author: mockReq.user.id,
+ model_parameters: {
+ useResponsesApi: true,
+ temperature: 0.7,
+ apiKey: 'secret-value',
+ },
+ });
+
+ mockReq.params = { id: agent.id };
+
+ await getAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.model_parameters).toEqual({ useResponsesApi: true });
+ expect(response.model_parameters.temperature).toBeUndefined();
+ expect(response.model_parameters.apiKey).toBeUndefined();
+ });
+
+ test('should return owner_contact from the first ACL owner when support_contact is missing', async () => {
+ const owner = await createOwner({
+ name: 'Primary Owner',
+ email: 'primary.owner@example.com',
+ });
+ const agent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Owner Contact Agent',
+ description: 'Uses owner fallback',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: owner._id,
+ });
+ await grantAgentOwner({ agent, owner });
+
+ mockReq.params = { id: agent.id };
+
+ await getAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.owner_contact).toEqual({
+ name: 'Primary Owner',
+ email: 'primary.owner@example.com',
+ });
+ });
+
+ test('should not return owner_contact when support_contact is present', async () => {
+ const owner = await createOwner({
+ name: 'Primary Owner',
+ email: 'primary.owner@example.com',
+ });
+ const agent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Support Contact Agent',
+ description: 'Uses support contact',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: owner._id,
+ support_contact: { name: 'Support Team', email: 'support@example.com' },
+ });
+ await grantAgentOwner({ agent, owner });
+
+ mockReq.params = { id: agent.id };
+
+ await getAgentHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.support_contact).toEqual({
+ name: 'Support Team',
+ email: 'support@example.com',
+ });
+ expect(response.owner_contact).toBeUndefined();
+ });
+
+ test('should include conversation_starters in the basic VIEW response', async () => {
+ const starters = ['Summarize this page', 'What can you do?'];
+ const agent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Starter Agent',
+ description: 'Exposes conversation starters',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: mockReq.user.id,
+ conversation_starters: starters,
+ });
+
+ mockReq.params = { id: agent.id };
+
+ await getAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.conversation_starters).toEqual(starters);
+ });
+ });
+
+ describe('getAgentVersionsHandler', () => {
+ test('returns the version history and excludes it from the basic VIEW response', async () => {
+ const agent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Versioned Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: mockReq.user.id,
+ versions: [
+ { name: 'V1', provider: 'openai', model: 'gpt-4', updatedAt: new Date() },
+ { name: 'V2', provider: 'openai', model: 'gpt-4', updatedAt: new Date() },
+ ],
+ });
+ mockReq.params = { id: agent.id };
+
+ await getAgentHandler(mockReq, mockRes);
+ const basicResponse = mockRes.json.mock.calls[0][0];
+ expect(basicResponse.versions).toBeUndefined();
+ expect(basicResponse.version).toBe(2);
+
+ mockRes.json.mockClear();
+ await getAgentVersionsHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ const versions = mockRes.json.mock.calls[0][0];
+ expect(Array.isArray(versions)).toBe(true);
+ expect(versions).toHaveLength(2);
+ expect(versions.map((v) => v.name)).toEqual(['V1', 'V2']);
+ });
+
+ test('returns 404 when the agent does not exist', async () => {
+ mockReq.params = { id: `agent_${uuidv4()}` };
+
+ await getAgentVersionsHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(404);
+ });
+ });
+
+ describe('updateAgentHandler', () => {
+ let existingAgentId;
+ let existingAgentAuthorId;
+
+ beforeEach(async () => {
+ // Create an existing agent for update tests
+ existingAgentAuthorId = new mongoose.Types.ObjectId();
+ const agent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Original Agent',
+ provider: 'openai',
+ model: 'gpt-3.5-turbo',
+ author: existingAgentAuthorId,
+ description: 'Original description',
+ versions: [
+ {
+ name: 'Original Agent',
+ provider: 'openai',
+ model: 'gpt-3.5-turbo',
+ description: 'Original description',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+ existingAgentId = agent.id;
+ });
+
+ test('should update agent with allowed fields only', async () => {
+ mockReq.user.id = existingAgentAuthorId.toString(); // Set as author
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ name: 'Updated Agent',
+ description: 'Updated description',
+ model: 'gpt-4',
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).not.toHaveBeenCalledWith(400);
+ expect(mockRes.status).not.toHaveBeenCalledWith(403);
+ expect(mockRes.json).toHaveBeenCalled();
+
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.name).toBe('Updated Agent');
+ expect(updatedAgent.description).toBe('Updated description');
+ expect(updatedAgent.model).toBe('gpt-4');
+ expect(updatedAgent.author).toBe(existingAgentAuthorId.toString());
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(agentInDb.name).toBe('Updated Agent');
+ });
+
+ test('should sanitize corrupt numeric model_parameters on update', async () => {
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ name: 'Healed Agent',
+ model_parameters: {
+ max_tokens: 'System',
+ maxContextTokens: 256000,
+ temperature: '0.7',
+ },
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.model_parameters.max_tokens).toBeUndefined();
+ expect(updatedAgent.model_parameters.maxContextTokens).toBe(256000);
+ expect(updatedAgent.model_parameters.temperature).toBe(0.7);
+
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(agentInDb.model_parameters.max_tokens).toBeUndefined();
+ expect(agentInDb.model_parameters.maxContextTokens).toBe(256000);
+ });
+
+ test('should reject update with unauthorized fields (mass assignment protection)', async () => {
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ name: 'Updated Name',
+
+ // Unauthorized fields that should be stripped
+ author: new mongoose.Types.ObjectId().toString(), // Should not be able to change author
+ authorName: 'Hacker', // Should be stripped
+ id: 'different_agent_id', // Should be stripped
+ _id: new mongoose.Types.ObjectId(), // Should be stripped
+ versions: [], // Should be stripped
+ createdAt: new Date('2020-01-01'), // Should be stripped
+ updatedAt: new Date('2020-01-01'), // Should be stripped
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+
+ // Verify unauthorized fields were not changed
+ expect(updatedAgent.author).toBe(existingAgentAuthorId.toString()); // Should not have changed
+ expect(updatedAgent.authorName).toBeUndefined();
+ expect(updatedAgent.id).toBe(existingAgentId); // Should not have changed
+ expect(updatedAgent.name).toBe('Updated Name'); // Only this should have changed
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(agentInDb.author.toString()).toBe(existingAgentAuthorId.toString());
+ expect(agentInDb.id).toBe(existingAgentId);
+ });
+
+ test('should allow admin to update any agent', async () => {
+ const adminUserId = new mongoose.Types.ObjectId().toString();
+ mockReq.user.id = adminUserId;
+ mockReq.user.role = 'ADMIN'; // Set as admin
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ name: 'Admin Update',
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).not.toHaveBeenCalledWith(403);
+ expect(mockRes.json).toHaveBeenCalled();
+
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.name).toBe('Admin Update');
+ });
+
+ test('should allow an editor to add their own file but not another user file', async () => {
+ const File = mongoose.models.File;
+ const adminUserId = new mongoose.Types.ObjectId().toString();
+ const authorFileId = `file_${uuidv4()}`;
+ const adminFileId = `file_${uuidv4()}`;
+
+ await File.create({
+ file_id: authorFileId,
+ user: existingAgentAuthorId,
+ filename: `${authorFileId}.txt`,
+ filepath: `/tmp/${authorFileId}`,
+ object: 'file',
+ type: 'text/plain',
+ bytes: 1,
+ source: FileSources.local,
+ });
+ await File.create({
+ file_id: adminFileId,
+ user: adminUserId,
+ filename: `${adminFileId}.txt`,
+ filepath: `/tmp/${adminFileId}`,
+ object: 'file',
+ type: 'text/plain',
+ bytes: 1,
+ source: FileSources.local,
+ });
+
+ mockReq.user.id = adminUserId;
+ mockReq.user.role = 'ADMIN';
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ tool_resources: {
+ file_search: { file_ids: [authorFileId, adminFileId] },
+ },
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
+ expect(agentInDb.tool_resources.file_search.file_ids).toEqual([adminFileId]);
+ });
+
+ test('should validate tool_resources in updates', async () => {
+ // Back these ids with real File docs so the orphan-pruning added for
+ // issue #12776 does not strip them — this test is about OCR conversion
+ // and schema filtering, not file existence.
+ const File = mongoose.models.File;
+ for (const id of ['ocr1', 'ocr2', 'img1']) {
+ await File.create({
+ file_id: id,
+ user: existingAgentAuthorId,
+ filename: `${id}.txt`,
+ filepath: `/tmp/${id}`,
+ object: 'file',
+ type: 'text/plain',
+ bytes: 1,
+ source: FileSources.local,
+ });
+ }
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ tool_resources: {
+ /** Legacy conversion from `ocr` to `context` */
+ ocr: {
+ file_ids: ['ocr1', 'ocr2'],
+ },
+ execute_code: {
+ file_ids: ['img1'],
+ },
+ // Invalid tool resource
+ invalid_tool: {
+ file_ids: ['invalid'],
+ },
+ },
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.tool_resources).toBeDefined();
+ expect(updatedAgent.tool_resources.ocr).toBeUndefined();
+ expect(updatedAgent.tool_resources.context).toBeDefined();
+ expect(updatedAgent.tool_resources.execute_code).toBeDefined();
+ expect(updatedAgent.tool_resources.invalid_tool).toBeUndefined();
+ });
+
+ test('should remove empty strings from model_parameters during update (Issue Fix)', async () => {
+ // First create an agent with valid model_parameters
+ await Agent.updateOne(
+ { id: existingAgentId },
+ {
+ model_parameters: {
+ temperature: 0.5,
+ max_tokens: 1000,
+ maxContextTokens: 2000,
+ },
+ },
+ );
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ // Simulate user clearing the fields (sends empty strings)
+ mockReq.body = {
+ model_parameters: {
+ temperature: 0.7, // Change to new value
+ max_tokens: '', // Clear this field (should be removed, not sent as "")
+ maxContextTokens: '', // Clear this field (should be removed, not sent as "")
+ },
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.model_parameters).toBeDefined();
+ // Valid number should be updated
+ expect(updatedAgent.model_parameters.temperature).toBe(0.7);
+ // Empty strings should be removed, not sent as ""
+ expect(updatedAgent.model_parameters.max_tokens).toBeUndefined();
+ expect(updatedAgent.model_parameters.maxContextTokens).toBeUndefined();
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(agentInDb.model_parameters.temperature).toBe(0.7);
+ expect(agentInDb.model_parameters.max_tokens).toBeUndefined();
+ expect(agentInDb.model_parameters.maxContextTokens).toBeUndefined();
+ });
+
+ test('should return 404 for non-existent agent', async () => {
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = `agent_${uuidv4()}`; // Non-existent ID
+ mockReq.body = {
+ name: 'Update Non-existent',
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(404);
+ expect(mockRes.json).toHaveBeenCalledWith({ error: 'Agent not found' });
+ });
+
+ test('should include version field in update response', async () => {
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ name: 'Updated with Version Check',
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+
+ // Verify version field is included and is a number
+ expect(updatedAgent).toHaveProperty('version');
+ expect(typeof updatedAgent.version).toBe('number');
+ expect(updatedAgent.version).toBeGreaterThanOrEqual(1);
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(updatedAgent.version).toBe(agentInDb.versions.length);
+ });
+
+ test('should allow resetting avatar when value is explicitly null', async () => {
+ await Agent.updateOne(
+ { id: existingAgentId },
+ {
+ avatar: {
+ filepath: 'https://example.com/avatar.png',
+ source: 's3',
+ },
+ },
+ );
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ avatar: null,
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.avatar).toBeNull();
+
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(agentInDb.avatar).toBeNull();
+ });
+
+ test('should ignore avatar field when value is undefined', async () => {
+ const originalAvatar = {
+ filepath: 'https://example.com/original.png',
+ source: 's3',
+ };
+ await Agent.updateOne({ id: existingAgentId }, { avatar: originalAvatar });
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ avatar: undefined,
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(agentInDb.avatar.filepath).toBe(originalAvatar.filepath);
+ expect(agentInDb.avatar.source).toBe(originalAvatar.source);
+ });
+
+ test('should not bump version when no mutable fields change', async () => {
+ const existingAgent = await Agent.findOne({ id: existingAgentId });
+ const originalVersionCount = existingAgent.versions.length;
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ avatar: undefined,
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(agentInDb.versions.length).toBe(originalVersionCount);
+ });
+
+ test('should handle validation errors properly', async () => {
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ model_parameters: 'invalid-not-an-object', // Should be an object
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(mockRes.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: 'Invalid request data',
+ details: expect.any(Array),
+ }),
+ );
+ });
+
+ describe('orphan file_id pruning (issue #12776)', () => {
+ const File = () => mongoose.models.File;
+
+ const createFileDoc = async (file_id, userId) =>
+ File().create({
+ file_id,
+ user: userId,
+ filename: `${file_id}.txt`,
+ filepath: `/tmp/${file_id}`,
+ object: 'file',
+ type: 'text/plain',
+ bytes: 1,
+ source: FileSources.local,
+ });
+
+ beforeEach(async () => {
+ await File().deleteMany({});
+ });
+
+ test('strips orphan file_ids from incoming tool_resources before persisting', async () => {
+ const keeper = `file_${uuidv4()}`;
+ const orphan = `file_${uuidv4()}`;
+ await createFileDoc(keeper, existingAgentAuthorId);
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ tool_resources: {
+ file_search: { file_ids: [keeper, orphan] },
+ },
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
+ expect(agentInDb.tool_resources.file_search.file_ids).toEqual([keeper]);
+ });
+
+ test('leaves tool_resources alone when the update omits it', async () => {
+ const orphan = `file_${uuidv4()}`;
+ await Agent.updateOne(
+ { id: existingAgentId },
+ { $set: { tool_resources: { file_search: { file_ids: [orphan] } } } },
+ );
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = { name: 'Unrelated Rename' };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
+ expect(agentInDb.name).toBe('Unrelated Rename');
+ // Save-time pruning is intentionally scoped to tool_resources updates.
+ // The delete-time fix and migration script cover the untouched case.
+ expect(agentInDb.tool_resources.file_search.file_ids).toEqual([orphan]);
+ });
+
+ test('prunes incoming file_ids when the file ownership check fails', async () => {
+ const db = require('~/models');
+ jest.spyOn(db, 'getFiles').mockRejectedValueOnce(new Error('transient DB error'));
+
+ const orphan = `file_${uuidv4()}`;
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ name: 'Save Succeeds',
+ tool_resources: { file_search: { file_ids: [orphan] } },
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).not.toHaveBeenCalledWith(500);
+ expect(mockRes.json).toHaveBeenCalled();
+ const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
+ expect(agentInDb.name).toBe('Save Succeeds');
+ expect(agentInDb.tool_resources.file_search.file_ids).toEqual([]);
+ });
+
+ test('strips file_ids owned by another user from incoming tool_resources', async () => {
+ const keeper = `file_${uuidv4()}`;
+ const otherUsersFile = `file_${uuidv4()}`;
+ await createFileDoc(keeper, existingAgentAuthorId);
+ await createFileDoc(otherUsersFile, new mongoose.Types.ObjectId());
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ tool_resources: {
+ file_search: { file_ids: [keeper, otherUsersFile] },
+ },
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
+ expect(agentInDb.tool_resources.file_search.file_ids).toEqual([keeper]);
+ });
+
+ test('preserves existing attached file_ids owned by another user', async () => {
+ const authorFile = `file_${uuidv4()}`;
+ const editorFile = `file_${uuidv4()}`;
+ const editorId = new mongoose.Types.ObjectId();
+ await createFileDoc(authorFile, existingAgentAuthorId);
+ await createFileDoc(editorFile, editorId);
+ await Agent.updateOne(
+ { id: existingAgentId },
+ { $set: { tool_resources: { file_search: { file_ids: [editorFile] } } } },
+ );
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ tool_resources: {
+ file_search: { file_ids: [authorFile, editorFile] },
+ },
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
+ expect(agentInDb.tool_resources.file_search.file_ids).toEqual([authorFile, editorFile]);
+ });
+ });
+ });
+
+ describe('tool_resources ownership pruning in alternate write paths', () => {
+ const createFileDoc = (file_id, userId) =>
+ mongoose.models.File.create({
+ file_id,
+ user: userId,
+ filename: `${file_id}.txt`,
+ filepath: `/tmp/${file_id}`,
+ object: 'file',
+ type: 'text/plain',
+ bytes: 1,
+ source: FileSources.local,
+ });
+
+ test('duplicateAgentHandler should prune file_ids not owned by the clone author', async () => {
+ const sourceAuthorId = new mongoose.Types.ObjectId();
+ const cloneAuthorId = new mongoose.Types.ObjectId();
+ const sourceFileId = `file_${uuidv4()}`;
+ const cloneAuthorFileId = `file_${uuidv4()}`;
+
+ await createFileDoc(sourceFileId, sourceAuthorId);
+ await createFileDoc(cloneAuthorFileId, cloneAuthorId);
+ const sourceAgent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Source Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: sourceAuthorId,
+ tool_resources: {
+ context: { file_ids: [sourceFileId, cloneAuthorFileId] },
+ },
+ });
+
+ const db = require('~/models');
+ jest.spyOn(db, 'getActions').mockResolvedValueOnce([]);
+
+ mockReq.user.id = cloneAuthorId.toString();
+ mockReq.params.id = sourceAgent.id;
+
+ await duplicateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+ const { agent } = mockRes.json.mock.calls[0][0];
+ expect(agent.author.toString()).toBe(cloneAuthorId.toString());
+ expect(agent.tool_resources.context.file_ids).toEqual([cloneAuthorFileId]);
+ });
+
+ test('revertAgentVersionHandler should preserve restored attached file_ids with metadata', async () => {
+ const agentAuthorId = new mongoose.Types.ObjectId();
+ const otherUserId = new mongoose.Types.ObjectId();
+ const ownedFileId = `file_${uuidv4()}`;
+ const otherFileId = `file_${uuidv4()}`;
+ const orphanFileId = `file_${uuidv4()}`;
+
+ await createFileDoc(ownedFileId, agentAuthorId);
+ await createFileDoc(otherFileId, otherUserId);
+ const agent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Current Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: agentAuthorId,
+ tool_resources: {},
+ versions: [
+ {
+ name: 'Historical Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ tool_resources: {
+ file_search: { file_ids: [ownedFileId, otherFileId, orphanFileId] },
+ },
+ },
+ ],
+ });
+
+ mockReq.user.id = agentAuthorId.toString();
+ mockReq.params.id = agent.id;
+ mockReq.body = { version_index: 0 };
+
+ await revertAgentVersionHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+ const agentInDb = await Agent.findOne({ id: agent.id }).lean();
+ expect(agentInDb.tool_resources.file_search.file_ids).toEqual([ownedFileId, otherFileId]);
+ });
+ });
+
+ describe('Mass Assignment Attack Scenarios', () => {
+ test('should prevent setting system fields during creation', async () => {
+ const systemFields = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'System Fields Test',
+
+ // System fields that should never be settable by users
+ __v: 99,
+ _id: new mongoose.Types.ObjectId(),
+ versions: [
+ {
+ name: 'Fake Version',
+ provider: 'fake',
+ model: 'fake-model',
+ },
+ ],
+ };
+
+ mockReq.body = systemFields;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+
+ // Verify system fields were not affected
+ expect(createdAgent.__v).not.toBe(99);
+ expect(createdAgent.versions).toHaveLength(1); // Should only have the auto-created version
+ expect(createdAgent.versions[0].name).toBe('System Fields Test'); // From actual creation
+ expect(createdAgent.versions[0].provider).toBe('openai'); // From actual creation
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: createdAgent.id });
+ expect(agentInDb.__v).not.toBe(99);
+ });
+
+ test('should prevent author hijacking', async () => {
+ const originalAuthorId = new mongoose.Types.ObjectId();
+ const attackerId = new mongoose.Types.ObjectId();
+
+ // Admin creates an agent
+ mockReq.user.id = originalAuthorId.toString();
+ mockReq.user.role = 'ADMIN';
+ mockReq.body = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Admin Agent',
+ author: attackerId.toString(), // Trying to set different author
+ };
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+
+ // Author should be the actual user, not the attempted value
+ expect(createdAgent.author.toString()).toBe(originalAuthorId.toString());
+ expect(createdAgent.author.toString()).not.toBe(attackerId.toString());
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: createdAgent.id });
+ expect(agentInDb.author.toString()).toBe(originalAuthorId.toString());
+ });
+
+ test('should strip unknown fields to prevent future vulnerabilities', async () => {
+ mockReq.body = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Future Proof Test',
+
+ // Unknown fields that might be added in future
+ superAdminAccess: true,
+ bypassAllChecks: true,
+ internalFlag: 'secret',
+ futureFeature: 'exploit',
+ };
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+
+ // Verify unknown fields were stripped
+ expect(createdAgent.superAdminAccess).toBeUndefined();
+ expect(createdAgent.bypassAllChecks).toBeUndefined();
+ expect(createdAgent.internalFlag).toBeUndefined();
+ expect(createdAgent.futureFeature).toBeUndefined();
+
+ // Also check in database
+ const agentInDb = await Agent.findOne({ id: createdAgent.id }).lean();
+ expect(agentInDb.superAdminAccess).toBeUndefined();
+ expect(agentInDb.bypassAllChecks).toBeUndefined();
+ expect(agentInDb.internalFlag).toBeUndefined();
+ expect(agentInDb.futureFeature).toBeUndefined();
+ });
+ });
+
+ describe('getListAgentsHandler - Security Tests', () => {
+ let userA, userB;
+ let agentA1, agentA2, agentA3, agentB1;
+
+ beforeEach(async () => {
+ await Agent.deleteMany({});
+ jest.clearAllMocks();
+
+ // Create two test users
+ userA = new mongoose.Types.ObjectId();
+ userB = new mongoose.Types.ObjectId();
+
+ // Create agents for User A
+ agentA1 = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: 'Agent A1',
+ description: 'User A agent 1',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ versions: [
+ {
+ name: 'Agent A1',
+ description: 'User A agent 1',
+ provider: 'openai',
+ model: 'gpt-4',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+
+ agentA2 = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: 'Agent A2',
+ description: 'User A agent 2',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ versions: [
+ {
+ name: 'Agent A2',
+ description: 'User A agent 2',
+ provider: 'openai',
+ model: 'gpt-4',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+
+ agentA3 = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: 'Agent A3',
+ description: 'User A agent 3',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ category: 'productivity',
+ versions: [
+ {
+ name: 'Agent A3',
+ description: 'User A agent 3',
+ provider: 'openai',
+ model: 'gpt-4',
+ category: 'productivity',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+
+ // Create an agent for User B
+ agentB1 = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: 'Agent B1',
+ description: 'User B agent 1',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userB,
+ versions: [
+ {
+ name: 'Agent B1',
+ description: 'User B agent 1',
+ provider: 'openai',
+ model: 'gpt-4',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+ });
+
+ test('should return empty list when user has no accessible agents', async () => {
+ // User B has no permissions and no owned agents
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockResolvedValue([]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ expect(findAccessibleResources).toHaveBeenCalledWith({
+ userId: userB.toString(),
+ role: 'USER',
+ resourceType: 'agent',
+ requiredPermissions: 1, // VIEW permission
+ });
+
+ expect(mockRes.json).toHaveBeenCalledWith({
+ object: 'list',
+ data: [],
+ first_id: null,
+ last_id: null,
+ has_more: false,
+ after: null,
+ });
+ });
+
+ test('should not return other users agents when accessibleIds is empty', async () => {
+ // User B trying to see agents with no permissions
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockResolvedValue([]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(0);
+
+ // Verify User A's agents are not included
+ const agentIds = response.data.map((a) => a.id);
+ expect(agentIds).not.toContain(agentA1.id);
+ expect(agentIds).not.toContain(agentA2.id);
+ expect(agentIds).not.toContain(agentA3.id);
+ });
+
+ test('should only return agents user has access to', async () => {
+ // User B has access to one of User A's agents
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockResolvedValue([agentA1._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(1);
+ expect(response.data[0].id).toBe(agentA1.id);
+ expect(response.data[0].name).toBe('Agent A1');
+ });
+
+ test('should return owner_contact for list agents missing support_contact', async () => {
+ const owner = await createOwner({
+ _id: userA,
+ name: 'List Owner',
+ email: 'list.owner@example.com',
+ });
+ await grantAgentOwner({ agent: agentA1, owner });
+
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockResolvedValue([agentA1._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data[0].owner_contact).toEqual({
+ name: 'List Owner',
+ email: 'list.owner@example.com',
+ });
+ });
+
+ test('should use the first ACL owner when an agent has multiple owners', async () => {
+ const firstOwner = await createOwner({
+ name: 'First Owner',
+ email: 'first.owner@example.com',
+ });
+ const secondOwner = await createOwner({
+ name: 'Second Owner',
+ email: 'second.owner@example.com',
+ });
+ await grantAgentOwner({
+ agent: agentA1,
+ owner: secondOwner,
+ grantedAt: new Date('2024-02-01T00:00:00.000Z'),
+ });
+ await grantAgentOwner({
+ agent: agentA1,
+ owner: firstOwner,
+ grantedAt: new Date('2024-01-01T00:00:00.000Z'),
+ });
+
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockResolvedValue([agentA1._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data[0].owner_contact).toEqual({
+ name: 'First Owner',
+ email: 'first.owner@example.com',
+ });
+ });
+
+ test('should omit owner_contact when no owner user can be resolved', async () => {
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockResolvedValue([agentA1._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data[0].owner_contact).toBeUndefined();
+ });
+
+ test('should return only expected safe list fields for VIEW callers', async () => {
+ const hiddenSkillId = new mongoose.Types.ObjectId();
+ await Agent.findByIdAndUpdate(agentA1._id, {
+ avatar: { filepath: '/avatars/a1.png', source: FileSources.local },
+ category: 'general',
+ support_contact: { name: 'Support', email: 'support@example.com' },
+ is_promoted: true,
+ instructions: 'private system instructions',
+ tools: ['execute_code'],
+ actions: ['example.com::action'],
+ model_parameters: { temperature: 0.7 },
+ tool_resources: { file_search: { file_ids: ['file-1'] } },
+ tool_options: { execute_code: { defer_loading: true } },
+ subagents: { enabled: true, agent_ids: [agentA2.id] },
+ edges: [{ from: agentA1.id, to: agentA2.id }],
+ skills_enabled: true,
+ skills: [hiddenSkillId.toString()],
+ });
+
+ mockReq.user.id = userB.toString();
+ mockReq.query.requiredPermission = String(PermissionBits.VIEW);
+ findAccessibleResources.mockImplementation(({ resourceType }) => {
+ if (resourceType === ResourceType.AGENT) {
+ return Promise.resolve([agentA1._id]);
+ }
+ if (resourceType === ResourceType.SKILL) {
+ return Promise.resolve([]);
+ }
+ return Promise.resolve([]);
+ });
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ const agent = response.data[0];
+ expect(Object.keys(agent).sort()).toEqual(
+ [
+ '_id',
+ 'author',
+ 'avatar',
+ 'category',
+ 'conversation_starters',
+ 'description',
+ 'id',
+ 'is_promoted',
+ 'name',
+ 'support_contact',
+ 'updatedAt',
+ ].sort(),
+ );
+ expect(agent).toEqual(
+ expect.objectContaining({
+ id: agentA1.id,
+ name: 'Agent A1',
+ description: 'User A agent 1',
+ author: userA.toString(),
+ category: 'general',
+ is_promoted: true,
+ }),
+ );
+ });
+
+ test('should return multiple accessible agents', async () => {
+ // User B has access to multiple agents
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockResolvedValue([agentA1._id, agentA3._id, agentB1._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(3);
+
+ const agentIds = response.data.map((a) => a.id);
+ expect(agentIds).toContain(agentA1.id);
+ expect(agentIds).toContain(agentA3.id);
+ expect(agentIds).toContain(agentB1.id);
+ expect(agentIds).not.toContain(agentA2.id);
+ });
+
+ test('should apply category filter correctly with ACL', async () => {
+ // User has access to all agents but filters by category
+ mockReq.user.id = userB.toString();
+ mockReq.query.category = 'productivity';
+ findAccessibleResources.mockResolvedValue([agentA1._id, agentA2._id, agentA3._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(1);
+ expect(response.data[0].id).toBe(agentA3.id);
+ expect(response.data[0].category).toBe('productivity');
+ });
+
+ test('should apply search filter correctly with ACL', async () => {
+ // User has access to multiple agents but searches for specific one
+ mockReq.user.id = userB.toString();
+ mockReq.query.search = 'A2';
+ findAccessibleResources.mockResolvedValue([agentA1._id, agentA2._id, agentA3._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(1);
+ expect(response.data[0].id).toBe(agentA2.id);
+ });
+
+ test('should handle pagination with ACL filtering', async () => {
+ // Create more agents for pagination testing
+ const moreAgents = [];
+ for (let i = 4; i <= 10; i++) {
+ const agent = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: `Agent A${i}`,
+ description: `User A agent ${i}`,
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ versions: [
+ {
+ name: `Agent A${i}`,
+ description: `User A agent ${i}`,
+ provider: 'openai',
+ model: 'gpt-4',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+ moreAgents.push(agent);
+ }
+
+ // User has access to all agents
+ const allAgentIds = [agentA1, agentA2, agentA3, ...moreAgents].map((a) => a._id);
+ mockReq.user.id = userB.toString();
+ mockReq.query.limit = '5';
+ findAccessibleResources.mockResolvedValue(allAgentIds);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(5);
+ expect(response.has_more).toBe(true);
+ expect(response.after).toBeTruthy();
+ });
+
+ test('should mark publicly accessible agents', async () => {
+ // User has access to agents, some are public
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockResolvedValue([agentA1._id, agentA2._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([agentA2._id]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(2);
+
+ const publicAgent = response.data.find((a) => a.id === agentA2.id);
+ const privateAgent = response.data.find((a) => a.id === agentA1.id);
+
+ expect(publicAgent.isPublic).toBe(true);
+ expect(privateAgent.isPublic).toBeUndefined();
+ });
+
+ test('should handle requiredPermission parameter', async () => {
+ // Test with different permission levels
+ mockReq.user.id = userB.toString();
+ mockReq.query.requiredPermission = '15'; // FULL_ACCESS
+ findAccessibleResources.mockResolvedValue([agentA1._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ expect(findAccessibleResources).toHaveBeenCalledWith({
+ userId: userB.toString(),
+ role: 'USER',
+ resourceType: 'agent',
+ requiredPermissions: 15,
+ });
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(1);
+ });
+
+ test('should return only viewer-accessible skill scope for VIEW list callers', async () => {
+ const visibleSkillId = new mongoose.Types.ObjectId();
+ const hiddenSkillId = new mongoose.Types.ObjectId();
+ await Agent.findByIdAndUpdate(agentA1._id, {
+ skills_enabled: true,
+ skills: [visibleSkillId.toString(), hiddenSkillId.toString()],
+ });
+
+ mockReq.user.id = userB.toString();
+ mockReq.query.requiredPermission = String(PermissionBits.VIEW);
+ findAccessibleResources.mockImplementation(({ resourceType }) => {
+ if (resourceType === ResourceType.AGENT) {
+ return Promise.resolve([agentA1._id]);
+ }
+ if (resourceType === ResourceType.SKILL) {
+ return Promise.resolve([visibleSkillId]);
+ }
+ return Promise.resolve([]);
+ });
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(1);
+ expect(response.data[0].skills_enabled).toBe(true);
+ expect(response.data[0].skills).toEqual([visibleSkillId.toString()]);
+ expect(response.data[0].skills).not.toContain(hiddenSkillId.toString());
+ });
+
+ test('should omit skill scope for VIEW list callers with no accessible configured skills', async () => {
+ const hiddenSkillId = new mongoose.Types.ObjectId();
+ await Agent.findByIdAndUpdate(agentA1._id, {
+ skills_enabled: true,
+ skills: [hiddenSkillId.toString()],
+ });
+
+ mockReq.user.id = userB.toString();
+ mockReq.query.requiredPermission = String(PermissionBits.VIEW);
+ findAccessibleResources.mockImplementation(({ resourceType }) => {
+ if (resourceType === ResourceType.AGENT) {
+ return Promise.resolve([agentA1._id]);
+ }
+ if (resourceType === ResourceType.SKILL) {
+ return Promise.resolve([]);
+ }
+ return Promise.resolve([]);
+ });
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(1);
+ expect(response.data[0].skills).toBeUndefined();
+ expect(response.data[0].skills_enabled).toBeUndefined();
+ });
+
+ test('should preserve enabled skill scope for VIEW list callers with an empty allowlist', async () => {
+ await Agent.findByIdAndUpdate(agentA1._id, {
+ skills_enabled: true,
+ skills: [],
+ });
+
+ mockReq.user.id = userB.toString();
+ mockReq.query.requiredPermission = String(PermissionBits.VIEW);
+ findAccessibleResources.mockImplementation(({ resourceType }) => {
+ if (resourceType === ResourceType.AGENT) {
+ return Promise.resolve([agentA1._id]);
+ }
+ if (resourceType === ResourceType.SKILL) {
+ return Promise.resolve([]);
+ }
+ return Promise.resolve([]);
+ });
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(1);
+ expect(response.data[0].skills).toBeUndefined();
+ expect(response.data[0].skills_enabled).toBe(true);
+ });
+
+ test('should return raw skill configuration for EDIT list callers', async () => {
+ const visibleSkillId = new mongoose.Types.ObjectId();
+ const hiddenSkillId = new mongoose.Types.ObjectId();
+ await Agent.findByIdAndUpdate(agentA1._id, {
+ skills_enabled: true,
+ skills: [visibleSkillId.toString(), hiddenSkillId.toString()],
+ });
+
+ mockReq.user.id = userB.toString();
+ mockReq.query.requiredPermission = String(PermissionBits.EDIT);
+ findAccessibleResources.mockResolvedValue([agentA1._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(1);
+ expect(response.data[0].skills_enabled).toBe(true);
+ expect(response.data[0].skills).toEqual([
+ visibleSkillId.toString(),
+ hiddenSkillId.toString(),
+ ]);
+ expect(findAccessibleResources).not.toHaveBeenCalledWith(
+ expect.objectContaining({ resourceType: ResourceType.SKILL }),
+ );
+ });
+
+ test('should handle promoted filter with ACL', async () => {
+ // Create a promoted agent
+ const promotedAgent = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: 'Promoted Agent',
+ description: 'A promoted agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ is_promoted: true,
+ versions: [
+ {
+ name: 'Promoted Agent',
+ description: 'A promoted agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ is_promoted: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+
+ mockReq.user.id = userB.toString();
+ mockReq.query.promoted = '1';
+ findAccessibleResources.mockResolvedValue([agentA1._id, agentA2._id, promotedAgent._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(1);
+ expect(response.data[0].id).toBe(promotedAgent.id);
+ expect(response.data[0].is_promoted).toBe(true);
+ });
+
+ test('should handle errors gracefully', async () => {
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockRejectedValue(new Error('Permission service error'));
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(500);
+ expect(mockRes.json).toHaveBeenCalledWith({
+ error: 'Permission service error',
+ });
+ });
+
+ test('should respect combined filters with ACL', async () => {
+ // Create agents with specific attributes
+ const productivityPromoted = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: 'Productivity Pro',
+ description: 'A promoted productivity agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ category: 'productivity',
+ is_promoted: true,
+ versions: [
+ {
+ name: 'Productivity Pro',
+ description: 'A promoted productivity agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ category: 'productivity',
+ is_promoted: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+
+ mockReq.user.id = userB.toString();
+ mockReq.query.category = 'productivity';
+ mockReq.query.promoted = '1';
+ findAccessibleResources.mockResolvedValue([
+ agentA1._id,
+ agentA2._id,
+ agentA3._id,
+ productivityPromoted._id,
+ ]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(1);
+ expect(response.data[0].id).toBe(productivityPromoted.id);
+ expect(response.data[0].category).toBe('productivity');
+ expect(response.data[0].is_promoted).toBe(true);
+ });
+ });
+
+ describe('S3 Avatar Refresh', () => {
+ let userA, userB;
+ let agentWithS3Avatar, agentWithLocalAvatar, agentOwnedByOther;
+
+ beforeEach(async () => {
+ await Agent.deleteMany({});
+ jest.clearAllMocks();
+
+ // Reset cache mock
+ mockCache.get.mockResolvedValue(false);
+ mockCache.set.mockResolvedValue(undefined);
+
+ userA = new mongoose.Types.ObjectId();
+ userB = new mongoose.Types.ObjectId();
+
+ // Create agent with S3 avatar owned by userA
+ agentWithS3Avatar = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: 'Agent with S3 Avatar',
+ description: 'Has S3 avatar',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ avatar: {
+ source: FileSources.s3,
+ filepath: 'old-s3-path.jpg',
+ },
+ versions: [
+ {
+ name: 'Agent with S3 Avatar',
+ description: 'Has S3 avatar',
+ provider: 'openai',
+ model: 'gpt-4',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+
+ // Create agent with local avatar owned by userA
+ agentWithLocalAvatar = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: 'Agent with Local Avatar',
+ description: 'Has local avatar',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ avatar: {
+ source: 'local',
+ filepath: 'local-path.jpg',
+ },
+ versions: [
+ {
+ name: 'Agent with Local Avatar',
+ description: 'Has local avatar',
+ provider: 'openai',
+ model: 'gpt-4',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+
+ // Create agent with S3 avatar owned by userB
+ agentOwnedByOther = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: 'Agent Owned By Other',
+ description: 'Owned by userB',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userB,
+ avatar: {
+ source: FileSources.s3,
+ filepath: 'other-s3-path.jpg',
+ },
+ versions: [
+ {
+ name: 'Agent Owned By Other',
+ description: 'Owned by userB',
+ provider: 'openai',
+ model: 'gpt-4',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+ });
+
+ test('should skip avatar refresh if cache hit', async () => {
+ mockCache.get.mockResolvedValue({ urlCache: {} });
+ findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ const mockReq = {
+ user: { id: userA.toString(), role: 'USER' },
+ query: {},
+ };
+ const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ // Should not call refreshS3Url when cache hit
+ expect(refreshS3Url).not.toHaveBeenCalled();
+ });
+
+ test('should refresh and persist S3 avatars on cache miss', async () => {
+ mockCache.get.mockResolvedValue(false);
+ findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+ refreshS3Url.mockResolvedValue('new-s3-path.jpg');
+
+ const mockReq = {
+ user: { id: userA.toString(), role: 'USER' },
+ query: {},
+ };
+ const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ // Verify S3 URL was refreshed
+ expect(refreshS3Url).toHaveBeenCalled();
+
+ // Verify cache was set with urlCache map, not a plain boolean
+ expect(mockCache.set).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ urlCache: expect.any(Object) }),
+ expect.any(Number),
+ );
+
+ // Verify response was returned
+ expect(mockRes.json).toHaveBeenCalled();
+ });
+
+ test('should refresh avatars for all accessible agents (VIEW permission)', async () => {
+ mockCache.get.mockResolvedValue(false);
+ // User A has access to both their own agent and userB's agent
+ findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id, agentOwnedByOther._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+ refreshS3Url.mockResolvedValue('new-path.jpg');
+
+ const mockReq = {
+ user: { id: userA.toString(), role: 'USER' },
+ query: {},
+ };
+ const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ // Should be called for both agents - any user with VIEW access can refresh
+ expect(refreshS3Url).toHaveBeenCalledTimes(2);
+ });
+
+ test('should skip non-S3 avatars', async () => {
+ mockCache.get.mockResolvedValue(false);
+ findAccessibleResources.mockResolvedValue([agentWithLocalAvatar._id, agentWithS3Avatar._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+ refreshS3Url.mockResolvedValue('new-path.jpg');
+
+ const mockReq = {
+ user: { id: userA.toString(), role: 'USER' },
+ query: {},
+ };
+ const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ // Should only be called for S3 avatar agent
+ expect(refreshS3Url).toHaveBeenCalledTimes(1);
+ });
+
+ test('should not update if S3 URL unchanged', async () => {
+ mockCache.get.mockResolvedValue(false);
+ findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+ // Return the same path - no update needed
+ refreshS3Url.mockResolvedValue('old-s3-path.jpg');
+
+ const mockReq = {
+ user: { id: userA.toString(), role: 'USER' },
+ query: {},
+ };
+ const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ // Verify refreshS3Url was called
+ expect(refreshS3Url).toHaveBeenCalled();
+
+ // Response should still be returned
+ expect(mockRes.json).toHaveBeenCalled();
+ });
+
+ test('should handle S3 refresh errors gracefully', async () => {
+ mockCache.get.mockResolvedValue(false);
+ findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+ refreshS3Url.mockRejectedValue(new Error('S3 error'));
+
+ const mockReq = {
+ user: { id: userA.toString(), role: 'USER' },
+ query: {},
+ };
+ const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+
+ // Should not throw - handles error gracefully
+ await expect(getListAgentsHandler(mockReq, mockRes)).resolves.not.toThrow();
+
+ // Response should still be returned
+ expect(mockRes.json).toHaveBeenCalled();
+ });
+
+ test('should process agents in batches', async () => {
+ mockCache.get.mockResolvedValue(false);
+
+ // Create 25 agents (should be processed in batches of 20)
+ const manyAgents = [];
+ for (let i = 0; i < 25; i++) {
+ const agent = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: `Agent ${i}`,
+ description: `Agent ${i} description`,
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ avatar: {
+ source: FileSources.s3,
+ filepath: `path${i}.jpg`,
+ },
+ versions: [
+ {
+ name: `Agent ${i}`,
+ description: `Agent ${i} description`,
+ provider: 'openai',
+ model: 'gpt-4',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+ manyAgents.push(agent);
+ }
+
+ const allAgentIds = manyAgents.map((a) => a._id);
+ findAccessibleResources.mockResolvedValue(allAgentIds);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+ refreshS3Url.mockImplementation((avatar) =>
+ Promise.resolve(avatar.filepath.replace('.jpg', '-new.jpg')),
+ );
+
+ const mockReq = {
+ user: { id: userA.toString(), role: 'USER' },
+ query: {},
+ };
+ const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ // All 25 should be processed
+ expect(refreshS3Url).toHaveBeenCalledTimes(25);
+ });
+
+ test('should skip agents without id or author', async () => {
+ mockCache.get.mockResolvedValue(false);
+
+ // Create agent without proper id field (edge case)
+ const agentWithoutId = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: 'Agent without ID field',
+ description: 'Testing',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ avatar: {
+ source: FileSources.s3,
+ filepath: 'test-path.jpg',
+ },
+ versions: [
+ {
+ name: 'Agent without ID field',
+ description: 'Testing',
+ provider: 'openai',
+ model: 'gpt-4',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+
+ findAccessibleResources.mockResolvedValue([agentWithoutId._id, agentWithS3Avatar._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+ refreshS3Url.mockResolvedValue('new-path.jpg');
+
+ const mockReq = {
+ user: { id: userA.toString(), role: 'USER' },
+ query: {},
+ };
+ const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ // Should still complete without errors
+ expect(mockRes.json).toHaveBeenCalled();
+ });
+
+ test('should use MAX_AVATAR_REFRESH_AGENTS limit for full list query', async () => {
+ mockCache.get.mockResolvedValue(false);
+ findAccessibleResources.mockResolvedValue([]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ const mockReq = {
+ user: { id: userA.toString(), role: 'USER' },
+ query: {},
+ };
+ const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ // Verify that the handler completed successfully
+ expect(mockRes.json).toHaveBeenCalled();
+ });
+
+ test('should treat legacy boolean cache entry as a miss and run refresh', async () => {
+ // Simulate a cache entry written by the pre-fix code
+ mockCache.get.mockResolvedValue(true);
+ findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+ refreshS3Url.mockResolvedValue('new-s3-path.jpg');
+
+ const mockReq = {
+ user: { id: userA.toString(), role: 'USER' },
+ query: {},
+ };
+ const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ // Boolean true fails the shape guard, so refresh must run
+ expect(refreshS3Url).toHaveBeenCalled();
+ // Cache is overwritten with the proper format
+ expect(mockCache.set).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ urlCache: expect.any(Object) }),
+ expect.any(Number),
+ );
+ });
+
+ test('should apply cached urlCache filepath to paginated response on cache hit', async () => {
+ const agentId = agentWithS3Avatar.id;
+ const cachedUrl = 'cached-presigned-url.jpg';
+
+ mockCache.get.mockResolvedValue({ urlCache: { [agentId]: cachedUrl } });
+ findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ const mockReq = {
+ user: { id: userA.toString(), role: 'USER' },
+ query: {},
+ };
+ const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ expect(refreshS3Url).not.toHaveBeenCalled();
+
+ const responseData = mockRes.json.mock.calls[0][0];
+ const agent = responseData.data.find((a) => a.id === agentId);
+ // Cached URL is served, not the stale DB value 'old-s3-path.jpg'
+ expect(agent.avatar.filepath).toBe(cachedUrl);
+ });
+
+ test('should preserve DB filepath for agents absent from urlCache on cache hit', async () => {
+ mockCache.get.mockResolvedValue({ urlCache: {} });
+ findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ const mockReq = {
+ user: { id: userA.toString(), role: 'USER' },
+ query: {},
+ };
+ const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ expect(refreshS3Url).not.toHaveBeenCalled();
+
+ const responseData = mockRes.json.mock.calls[0][0];
+ const agent = responseData.data.find((a) => a.id === agentWithS3Avatar.id);
+ expect(agent.avatar.filepath).toBe('old-s3-path.jpg');
+ });
+ });
+
+ describe('Edge ACL validation', () => {
+ let targetAgent;
+
+ beforeEach(async () => {
+ targetAgent = await Agent.create({
+ id: `agent_${nanoid()}`,
+ author: new mongoose.Types.ObjectId().toString(),
+ name: 'Target Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: [],
+ });
+ });
+
+ test('createAgentHandler should return 403 when user lacks VIEW on an edge-referenced agent', async () => {
+ const permMap = new Map();
+ getResourcePermissionsMap.mockResolvedValueOnce(permMap);
+
+ mockReq.body = {
+ name: 'Attacker Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ edges: [{ from: 'self_placeholder', to: targetAgent.id, edgeType: 'handoff' }],
+ };
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(403);
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.agent_ids).toContain(targetAgent.id);
+ });
+
+ test('createAgentHandler should succeed when user has VIEW on all edge-referenced agents', async () => {
+ const permMap = new Map([[targetAgent._id.toString(), 1]]);
+ getResourcePermissionsMap.mockResolvedValueOnce(permMap);
+
+ mockReq.body = {
+ name: 'Legit Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ edges: [{ from: 'self_placeholder', to: targetAgent.id, edgeType: 'handoff' }],
+ };
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+ });
+
+ test('createAgentHandler should allow edges referencing non-existent agents (self-reference at create time)', async () => {
+ mockReq.body = {
+ name: 'Self-Ref Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ edges: [{ from: 'agent_does_not_exist_yet', to: 'agent_also_new', edgeType: 'handoff' }],
+ };
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+ });
+
+ test('updateAgentHandler should return 403 when user lacks VIEW on an edge-referenced agent', async () => {
+ const ownedAgent = await Agent.create({
+ id: `agent_${nanoid()}`,
+ author: mockReq.user.id,
+ name: 'Owned Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: [],
+ });
+
+ const permMap = new Map([[ownedAgent._id.toString(), PermissionBits.VIEW]]);
+ getResourcePermissionsMap.mockResolvedValueOnce(permMap);
+
+ mockReq.params = { id: ownedAgent.id };
+ mockReq.body = {
+ edges: [{ from: ownedAgent.id, to: targetAgent.id, edgeType: 'handoff' }],
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(403);
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.agent_ids).toContain(targetAgent.id);
+ expect(response.agent_ids).not.toContain(ownedAgent.id);
+ });
+
+ test('updateAgentHandler should succeed when edges field is absent from payload', async () => {
+ const ownedAgent = await Agent.create({
+ id: `agent_${nanoid()}`,
+ author: mockReq.user.id,
+ name: 'Owned Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: [],
+ });
+
+ mockReq.params = { id: ownedAgent.id };
+ mockReq.body = { name: 'Renamed Agent' };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).not.toHaveBeenCalledWith(403);
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.name).toBe('Renamed Agent');
+ });
+ });
+});
diff --git a/api/server/controllers/assistants/chatV1.js b/api/server/controllers/assistants/chatV1.js
new file mode 100644
index 0000000..631831e
--- /dev/null
+++ b/api/server/controllers/assistants/chatV1.js
@@ -0,0 +1,676 @@
+const { v4 } = require('uuid');
+const { sleep } = require('@librechat/agents');
+const { logger } = require('@librechat/data-schemas');
+const {
+ sendEvent,
+ countTokens,
+ checkBalance,
+ getBalanceConfig,
+ getModelMaxTokens,
+} = require('@librechat/api');
+const {
+ Time,
+ Constants,
+ RunStatus,
+ CacheKeys,
+ VisionModes,
+ ContentTypes,
+ EModelEndpoint,
+ ViolationTypes,
+ ImageVisionTool,
+ checkOpenAIStorage,
+ AssistantStreamEvents,
+} = require('librechat-data-provider');
+const {
+ initThread,
+ recordUsage,
+ saveUserMessage,
+ checkMessageGaps,
+ addThreadMetadata,
+ saveAssistantMessage,
+} = require('~/server/services/Threads');
+const { runAssistant, createOnTextProgress } = require('~/server/services/AssistantService');
+const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
+const { formatMessage, createVisionPrompt } = require('~/app/clients/prompts');
+const { encodeAndFormat } = require('~/server/services/Files/images/encode');
+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 {
+ createAutoRefillTransaction,
+ findBalanceByUser,
+ upsertBalanceFields,
+ getTransactions,
+ getMultiplier,
+ getConvo,
+} = require('~/models');
+const { logViolation, getLogStores } = require('~/cache');
+const { getOpenAIClient } = require('./helpers');
+
+/**
+ * @route POST /
+ * @desc Chat with an assistant
+ * @access Public
+ * @param {object} req - The request object, containing the request data.
+ * @param {object} req.body - The request payload.
+ * @param {Express.Response} res - The response object, used to send back a response.
+ * @returns {void}
+ */
+const chatV1 = async (req, res) => {
+ const appConfig = req.config;
+ logger.debug('[/assistants/chat/] req.body', req.body);
+
+ const {
+ text,
+ model,
+ endpoint,
+ files = [],
+ promptPrefix,
+ assistant_id,
+ instructions,
+ endpointOption,
+ thread_id: _thread_id,
+ messageId: _messageId,
+ conversationId: convoId,
+ parentMessageId: _parentId = Constants.NO_PARENT,
+ clientTimestamp,
+ } = req.body;
+
+ /** @type {OpenAI} */
+ let openai;
+ /** @type {string|undefined} - the current thread id */
+ let thread_id = _thread_id;
+ /** @type {string|undefined} - the current run id */
+ let run_id;
+ /** @type {string|undefined} - the parent messageId */
+ let parentMessageId = _parentId;
+ /** @type {TMessage[]} */
+ let previousMessages = [];
+ /** @type {import('librechat-data-provider').TConversation | null} */
+ let conversation = null;
+ /** @type {string[]} */
+ let file_ids = [];
+ /** @type {Set} */
+ let attachedFileIds = new Set();
+ /** @type {TMessage | null} */
+ let requestMessage = null;
+ /** @type {undefined | Promise} */
+ let visionPromise;
+
+ const userMessageId = v4();
+ const responseMessageId = v4();
+
+ /** @type {string} - The conversation UUID - created if undefined */
+ const conversationId = convoId ?? v4();
+
+ const cache = getLogStores(CacheKeys.ABORT_KEYS);
+ const cacheKey = `${req.user.id}:${conversationId}`;
+
+ /** @type {Run | undefined} - The completed run, undefined if incomplete */
+ let completedRun;
+
+ const handleError = async (error) => {
+ const defaultErrorMessage =
+ 'The Assistant run failed to initialize. Try sending a message in a new conversation.';
+ const messageData = {
+ thread_id,
+ assistant_id,
+ conversationId,
+ parentMessageId,
+ sender: 'System',
+ user: req.user.id,
+ shouldSaveMessage: false,
+ messageId: responseMessageId,
+ endpoint,
+ };
+
+ if (error.message === 'Run cancelled') {
+ return res.end();
+ } else if (error.message === 'Request closed' && completedRun) {
+ return;
+ } else if (error.message === 'Request closed') {
+ logger.debug('[/assistants/chat/] Request aborted on close');
+ } else if (/Files.*are invalid/.test(error.message)) {
+ const errorMessage = `Files are invalid, or may not have uploaded yet.${
+ endpoint === EModelEndpoint.azureAssistants
+ ? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
+ : ''
+ }`;
+ return sendResponse(req, res, messageData, errorMessage);
+ } else if (error?.message?.includes('string too long')) {
+ return sendResponse(
+ req,
+ res,
+ messageData,
+ 'Message too long. The Assistants API has a limit of 32,768 characters per message. Please shorten it and try again.',
+ );
+ } else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
+ return sendResponse(req, res, messageData, error.message);
+ } else {
+ logger.error('[/assistants/chat/]', error);
+ }
+
+ if (!openai || !thread_id || !run_id) {
+ return sendResponse(req, res, messageData, defaultErrorMessage);
+ }
+
+ await sleep(2000);
+
+ try {
+ const status = await cache.get(cacheKey);
+ if (status === 'cancelled') {
+ logger.debug('[/assistants/chat/] Run already cancelled');
+ return res.end();
+ }
+ await cache.delete(cacheKey);
+ const cancelledRun = await openai.beta.threads.runs.cancel(run_id, { thread_id });
+ logger.debug('[/assistants/chat/] Cancelled run:', cancelledRun);
+ } catch (error) {
+ logger.error('[/assistants/chat/] Error cancelling run', error);
+ }
+
+ await sleep(2000);
+
+ let run;
+ try {
+ run = await openai.beta.threads.runs.retrieve(run_id, { thread_id });
+ await recordUsage({
+ ...run.usage,
+ model: run.model,
+ user: req.user.id,
+ conversationId,
+ });
+ } catch (error) {
+ logger.error('[/assistants/chat/] Error fetching or processing run', error);
+ }
+
+ let finalEvent;
+ try {
+ const runMessages = await checkMessageGaps({
+ openai,
+ run_id,
+ endpoint,
+ thread_id,
+ conversationId,
+ latestMessageId: responseMessageId,
+ });
+
+ const errorContentPart = {
+ text: {
+ value:
+ error?.message ?? 'There was an error processing your request. Please try again later.',
+ },
+ type: ContentTypes.ERROR,
+ };
+
+ if (!Array.isArray(runMessages[runMessages.length - 1]?.content)) {
+ runMessages[runMessages.length - 1].content = [errorContentPart];
+ } else {
+ const contentParts = runMessages[runMessages.length - 1].content;
+ for (let i = 0; i < contentParts.length; i++) {
+ const currentPart = contentParts[i];
+ /** @type {CodeToolCall | RetrievalToolCall | FunctionToolCall | undefined} */
+ const toolCall = currentPart?.[ContentTypes.TOOL_CALL];
+ if (
+ toolCall &&
+ toolCall?.function &&
+ !(toolCall?.function?.output || toolCall?.function?.output?.length)
+ ) {
+ contentParts[i] = {
+ ...currentPart,
+ [ContentTypes.TOOL_CALL]: {
+ ...toolCall,
+ function: {
+ ...toolCall.function,
+ output: 'error processing tool',
+ },
+ },
+ };
+ }
+ }
+ runMessages[runMessages.length - 1].content.push(errorContentPart);
+ }
+
+ finalEvent = {
+ final: true,
+ conversation: await getConvo(req.user.id, conversationId),
+ runMessages,
+ };
+ } catch (error) {
+ logger.error('[/assistants/chat/] Error finalizing error process', error);
+ return sendResponse(req, res, messageData, 'The Assistant run failed');
+ }
+
+ return sendResponse(req, res, finalEvent);
+ };
+
+ try {
+ res.on('close', async () => {
+ if (!completedRun) {
+ await handleError(new Error('Request closed'));
+ }
+ });
+
+ if (convoId && !_thread_id) {
+ completedRun = true;
+ throw new Error('Missing thread_id for existing conversation');
+ }
+
+ if (!assistant_id) {
+ completedRun = true;
+ throw new Error('Missing assistant_id');
+ }
+
+ const checkBalanceBeforeRun = async () => {
+ const balanceConfig = getBalanceConfig(appConfig);
+ if (!balanceConfig?.enabled) {
+ return;
+ }
+ const transactions =
+ (await getTransactions({
+ user: req.user.id,
+ context: 'message',
+ conversationId,
+ })) ?? [];
+
+ const totalPreviousTokens = Math.abs(
+ transactions.reduce((acc, curr) => acc + curr.rawAmount, 0),
+ );
+
+ // TODO: make promptBuffer a config option; buffer for titles, needs buffer for system instructions
+ const promptBuffer = parentMessageId === Constants.NO_PARENT && !_thread_id ? 200 : 0;
+ // 5 is added for labels
+ let promptTokens = (await countTokens(text + (promptPrefix ?? ''))) + 5;
+ promptTokens += totalPreviousTokens + promptBuffer;
+ // 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,
+ },
+ },
+ {
+ findBalanceByUser,
+ getMultiplier,
+ createAutoRefillTransaction,
+ logViolation,
+ balanceConfig,
+ upsertBalanceFields,
+ },
+ );
+ };
+
+ const { openai: _openai } = await getOpenAIClient({
+ req,
+ res,
+ endpointOption,
+ });
+
+ openai = _openai;
+ await validateAuthor({ req, openai });
+
+ if (previousMessages.length) {
+ parentMessageId = previousMessages[previousMessages.length - 1].messageId;
+ }
+
+ let userMessage = {
+ role: 'user',
+ content: text,
+ metadata: {
+ messageId: userMessageId,
+ },
+ };
+
+ /** @type {CreateRunBody | undefined} */
+ const body = createRunBody({
+ assistant_id,
+ model,
+ promptPrefix,
+ instructions,
+ endpointOption,
+ clientTimestamp,
+ });
+
+ const getRequestFileIds = async () => {
+ let thread_file_ids = [];
+ if (convoId) {
+ const convo = await getConvo(req.user.id, convoId);
+ if (convo && convo.file_ids) {
+ thread_file_ids = convo.file_ids;
+ }
+ }
+
+ file_ids = files.map(({ file_id }) => file_id);
+ if (file_ids.length || thread_file_ids.length) {
+ attachedFileIds = new Set([...file_ids, ...thread_file_ids]);
+ if (endpoint === EModelEndpoint.azureAssistants) {
+ userMessage.attachments = Array.from(attachedFileIds).map((file_id) => ({
+ file_id,
+ tools: [{ type: 'file_search' }],
+ }));
+ } else {
+ userMessage.file_ids = Array.from(attachedFileIds);
+ }
+ }
+ };
+
+ const addVisionPrompt = async () => {
+ if (!endpointOption.attachments) {
+ return;
+ }
+
+ /** @type {MongoFile[]} */
+ const attachments = await endpointOption.attachments;
+ if (attachments && attachments.every((attachment) => checkOpenAIStorage(attachment.source))) {
+ return;
+ }
+
+ const assistant = await openai.beta.assistants.retrieve(assistant_id);
+ const visionToolIndex = assistant.tools.findIndex(
+ (tool) => tool?.function && tool?.function?.name === ImageVisionTool.function.name,
+ );
+
+ if (visionToolIndex === -1) {
+ return;
+ }
+
+ let visionMessage = {
+ role: 'user',
+ content: '',
+ };
+ const { files, image_urls } = await encodeAndFormat(
+ req,
+ attachments,
+ {
+ endpoint: EModelEndpoint.assistants,
+ },
+ VisionModes.generative,
+ );
+ visionMessage.image_urls = image_urls.length ? image_urls : undefined;
+ if (!visionMessage.image_urls?.length) {
+ return;
+ }
+
+ const imageCount = visionMessage.image_urls.length;
+ const plural = imageCount > 1;
+ visionMessage.content = createVisionPrompt(plural);
+ visionMessage = formatMessage({ message: visionMessage, endpoint: EModelEndpoint.openAI });
+
+ visionPromise = openai.chat.completions
+ .create({
+ messages: [visionMessage],
+ max_tokens: 4000,
+ })
+ .catch((error) => {
+ logger.error('[/assistants/chat/] Error creating vision prompt', error);
+ });
+
+ const pluralized = plural ? 's' : '';
+ body.additional_instructions = `${
+ body.additional_instructions ? `${body.additional_instructions}\n` : ''
+ }The user has uploaded ${imageCount} image${pluralized}.
+ Use the \`${ImageVisionTool.function.name}\` tool to retrieve ${
+ plural ? '' : 'a '
+ }detailed text description${pluralized} for ${plural ? 'each' : 'the'} image${pluralized}.`;
+
+ return files;
+ };
+
+ /** @type {Promise|undefined} */
+ let userMessagePromise;
+
+ const initializeThread = async () => {
+ /** @type {[ undefined | MongoFile[]]}*/
+ const [processedFiles] = await Promise.all([addVisionPrompt(), getRequestFileIds()]);
+ // TODO: may allow multiple messages to be created beforehand in a future update
+ const initThreadBody = {
+ messages: [userMessage],
+ metadata: {
+ user: req.user.id,
+ conversationId,
+ },
+ };
+
+ if (processedFiles) {
+ for (const file of processedFiles) {
+ if (!checkOpenAIStorage(file.source)) {
+ attachedFileIds.delete(file.file_id);
+ const index = file_ids.indexOf(file.file_id);
+ if (index > -1) {
+ file_ids.splice(index, 1);
+ }
+ }
+ }
+
+ userMessage.file_ids = file_ids;
+ }
+
+ const result = await initThread({ openai, body: initThreadBody, thread_id });
+ thread_id = result.thread_id;
+
+ createOnTextProgress({
+ openai,
+ conversationId,
+ userMessageId,
+ messageId: responseMessageId,
+ thread_id,
+ });
+
+ requestMessage = {
+ user: req.user.id,
+ text,
+ messageId: userMessageId,
+ parentMessageId,
+ // TODO: make sure client sends correct format for `files`, use zod
+ files,
+ file_ids,
+ conversationId,
+ isCreatedByUser: true,
+ assistant_id,
+ thread_id,
+ model: assistant_id,
+ endpoint,
+ };
+
+ previousMessages.push(requestMessage);
+
+ /* asynchronous */
+ userMessagePromise = saveUserMessage(req, { ...requestMessage, model });
+
+ conversation = {
+ conversationId,
+ endpoint,
+ promptPrefix: promptPrefix,
+ instructions: instructions,
+ assistant_id,
+ // model,
+ };
+
+ if (file_ids.length) {
+ conversation.file_ids = file_ids;
+ }
+ };
+
+ const promises = [initializeThread(), checkBalanceBeforeRun()];
+ await Promise.all(promises);
+
+ const sendInitialResponse = () => {
+ sendEvent(res, {
+ sync: true,
+ conversationId,
+ // messages: previousMessages,
+ requestMessage,
+ responseMessage: {
+ user: req.user.id,
+ messageId: openai.responseMessage.messageId,
+ parentMessageId: userMessageId,
+ conversationId,
+ assistant_id,
+ thread_id,
+ model: assistant_id,
+ },
+ });
+ };
+
+ /** @type {RunResponse | typeof StreamRunManager | undefined} */
+ let response;
+
+ const processRun = async (retry = false) => {
+ if (endpoint === EModelEndpoint.azureAssistants) {
+ body.model = openai._options.model;
+ openai.attachedFileIds = attachedFileIds;
+ openai.visionPromise = visionPromise;
+ if (retry) {
+ response = await runAssistant({
+ openai,
+ thread_id,
+ run_id,
+ in_progress: openai.in_progress,
+ });
+ return;
+ }
+
+ /* NOTE:
+ * By default, a Run will use the model and tools configuration specified in Assistant object,
+ * but you can override most of these when creating the Run for added flexibility:
+ */
+ const run = await createRun({
+ openai,
+ thread_id,
+ body,
+ });
+
+ run_id = run.id;
+ await cache.set(cacheKey, `${thread_id}:${run_id}`, Time.TEN_MINUTES);
+ sendInitialResponse();
+
+ // todo: retry logic
+ response = await runAssistant({ openai, thread_id, run_id });
+ return;
+ }
+
+ /** @type {{[AssistantStreamEvents.ThreadRunCreated]: (event: ThreadRunCreated) => Promise}} */
+ const handlers = {
+ [AssistantStreamEvents.ThreadRunCreated]: async (event) => {
+ await cache.set(cacheKey, `${thread_id}:${event.data.id}`, Time.TEN_MINUTES);
+ run_id = event.data.id;
+ sendInitialResponse();
+ },
+ };
+
+ const streamRunManager = new StreamRunManager({
+ req,
+ res,
+ openai,
+ handlers,
+ thread_id,
+ visionPromise,
+ attachedFileIds,
+ responseMessage: openai.responseMessage,
+ // streamOptions: {
+
+ // },
+ });
+
+ await streamRunManager.runAssistant({
+ thread_id,
+ body,
+ });
+
+ response = streamRunManager;
+ };
+
+ await processRun();
+ logger.debug('[/assistants/chat/] response', {
+ run: response.run,
+ steps: response.steps,
+ });
+
+ if (response.run.status === RunStatus.CANCELLED) {
+ logger.debug('[/assistants/chat/] Run cancelled, handled by `abortRun`');
+ return res.end();
+ }
+
+ if (response.run.status === RunStatus.IN_PROGRESS) {
+ processRun(true);
+ }
+
+ completedRun = response.run;
+
+ /** @type {ResponseMessage} */
+ const responseMessage = {
+ ...(response.responseMessage ?? response.finalMessage),
+ parentMessageId: userMessageId,
+ conversationId,
+ user: req.user.id,
+ assistant_id,
+ thread_id,
+ model: assistant_id,
+ endpoint,
+ spec: endpointOption.spec,
+ iconURL: endpointOption.iconURL,
+ };
+
+ sendEvent(res, {
+ final: true,
+ conversation,
+ requestMessage: {
+ parentMessageId,
+ thread_id,
+ },
+ });
+ res.end();
+
+ if (userMessagePromise) {
+ await userMessagePromise;
+ }
+ await saveAssistantMessage(req, { ...responseMessage, model });
+
+ if (parentMessageId === Constants.NO_PARENT && !_thread_id) {
+ addTitle(req, {
+ text,
+ responseText: response.text,
+ conversationId,
+ });
+ }
+
+ await addThreadMetadata({
+ openai,
+ thread_id,
+ messageId: responseMessage.messageId,
+ messages: response.messages,
+ });
+
+ if (!response.run.usage) {
+ await sleep(3000);
+ completedRun = await openai.beta.threads.runs.retrieve(response.run.id, { thread_id });
+ if (completedRun.usage) {
+ await recordUsage({
+ ...completedRun.usage,
+ user: req.user.id,
+ model: completedRun.model ?? model,
+ conversationId,
+ });
+ }
+ } else {
+ await recordUsage({
+ ...response.run.usage,
+ user: req.user.id,
+ model: response.run.model ?? model,
+ conversationId,
+ });
+ }
+ } catch (error) {
+ await handleError(error);
+ }
+};
+
+module.exports = chatV1;
diff --git a/api/server/controllers/assistants/chatV2.js b/api/server/controllers/assistants/chatV2.js
new file mode 100644
index 0000000..237af1b
--- /dev/null
+++ b/api/server/controllers/assistants/chatV2.js
@@ -0,0 +1,510 @@
+const { v4 } = require('uuid');
+const { sleep } = require('@librechat/agents');
+const { logger } = require('@librechat/data-schemas');
+const {
+ sendEvent,
+ countTokens,
+ checkBalance,
+ getBalanceConfig,
+ getModelMaxTokens,
+} = require('@librechat/api');
+const {
+ Time,
+ Constants,
+ RunStatus,
+ CacheKeys,
+ ContentTypes,
+ ToolCallTypes,
+ EModelEndpoint,
+ retrievalMimeTypes,
+ AssistantStreamEvents,
+} = require('librechat-data-provider');
+const {
+ initThread,
+ recordUsage,
+ saveUserMessage,
+ addThreadMetadata,
+ saveAssistantMessage,
+} = require('~/server/services/Threads');
+const { runAssistant, createOnTextProgress } = require('~/server/services/AssistantService');
+const { createErrorHandler } = require('~/server/controllers/assistants/errors');
+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 {
+ getConvo,
+ getMultiplier,
+ getTransactions,
+ findBalanceByUser,
+ upsertBalanceFields,
+ createAutoRefillTransaction,
+} = require('~/models');
+const { logViolation, getLogStores } = require('~/cache');
+const { getOpenAIClient } = require('./helpers');
+
+/**
+ * @route POST /
+ * @desc Chat with an assistant
+ * @access Public
+ * @param {ServerRequest} req - The request object, containing the request data.
+ * @param {Express.Response} res - The response object, used to send back a response.
+ * @returns {void}
+ */
+const chatV2 = async (req, res) => {
+ logger.debug('[/assistants/chat/] req.body', req.body);
+ const appConfig = req.config;
+
+ /** @type {{files: MongoFile[]}} */
+ const {
+ text,
+ model,
+ endpoint,
+ files = [],
+ promptPrefix,
+ assistant_id,
+ instructions,
+ endpointOption,
+ thread_id: _thread_id,
+ messageId: _messageId,
+ conversationId: convoId,
+ parentMessageId: _parentId = Constants.NO_PARENT,
+ clientTimestamp,
+ } = req.body;
+
+ /** @type {OpenAI} */
+ let openai;
+ /** @type {string|undefined} - the current thread id */
+ let thread_id = _thread_id;
+ /** @type {string|undefined} - the current run id */
+ let run_id;
+ /** @type {string|undefined} - the parent messageId */
+ let parentMessageId = _parentId;
+ /** @type {TMessage[]} */
+ let previousMessages = [];
+ /** @type {import('librechat-data-provider').TConversation | null} */
+ let conversation = null;
+ /** @type {string[]} */
+ let file_ids = [];
+ /** @type {Set} */
+ let attachedFileIds = new Set();
+ /** @type {TMessage | null} */
+ let requestMessage = null;
+
+ const userMessageId = v4();
+ const responseMessageId = v4();
+
+ /** @type {string} - The conversation UUID - created if undefined */
+ const conversationId = convoId ?? v4();
+
+ const cache = getLogStores(CacheKeys.ABORT_KEYS);
+ const cacheKey = `${req.user.id}:${conversationId}`;
+
+ /** @type {Run | undefined} - The completed run, undefined if incomplete */
+ let completedRun;
+
+ const getContext = () => ({
+ openai,
+ run_id,
+ endpoint,
+ cacheKey,
+ thread_id,
+ completedRun,
+ assistant_id,
+ conversationId,
+ parentMessageId,
+ responseMessageId,
+ });
+
+ const handleError = createErrorHandler({ req, res, getContext });
+
+ try {
+ res.on('close', async () => {
+ if (!completedRun) {
+ await handleError(new Error('Request closed'));
+ }
+ });
+
+ if (convoId && !_thread_id) {
+ completedRun = true;
+ throw new Error('Missing thread_id for existing conversation');
+ }
+
+ if (!assistant_id) {
+ completedRun = true;
+ throw new Error('Missing assistant_id');
+ }
+
+ const checkBalanceBeforeRun = async () => {
+ const balanceConfig = getBalanceConfig(appConfig);
+ if (!balanceConfig?.enabled) {
+ return;
+ }
+ const transactions =
+ (await getTransactions({
+ user: req.user.id,
+ context: 'message',
+ conversationId,
+ })) ?? [];
+
+ const totalPreviousTokens = Math.abs(
+ transactions.reduce((acc, curr) => acc + curr.rawAmount, 0),
+ );
+
+ // TODO: make promptBuffer a config option; buffer for titles, needs buffer for system instructions
+ const promptBuffer = parentMessageId === Constants.NO_PARENT && !_thread_id ? 200 : 0;
+ // 5 is added for labels
+ let promptTokens = (await countTokens(text + (promptPrefix ?? ''))) + 5;
+ promptTokens += totalPreviousTokens + promptBuffer;
+ // 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,
+ },
+ },
+ {
+ findBalanceByUser,
+ getMultiplier,
+ createAutoRefillTransaction,
+ logViolation,
+ balanceConfig,
+ upsertBalanceFields,
+ },
+ );
+ };
+
+ const { openai: _openai } = await getOpenAIClient({
+ req,
+ res,
+ endpointOption,
+ });
+
+ openai = _openai;
+ await validateAuthor({ req, openai });
+
+ if (previousMessages.length) {
+ parentMessageId = previousMessages[previousMessages.length - 1].messageId;
+ }
+
+ let userMessage = {
+ role: 'user',
+ content: [
+ {
+ type: ContentTypes.TEXT,
+ text,
+ },
+ ],
+ metadata: {
+ messageId: userMessageId,
+ },
+ };
+
+ /** @type {CreateRunBody | undefined} */
+ const body = createRunBody({
+ assistant_id,
+ model,
+ promptPrefix,
+ instructions,
+ endpointOption,
+ clientTimestamp,
+ });
+
+ const getRequestFileIds = async () => {
+ let thread_file_ids = [];
+ if (convoId) {
+ const convo = await getConvo(req.user.id, convoId);
+ if (convo && convo.file_ids) {
+ thread_file_ids = convo.file_ids;
+ }
+ }
+
+ if (files.length || thread_file_ids.length) {
+ attachedFileIds = new Set([...file_ids, ...thread_file_ids]);
+
+ let attachmentIndex = 0;
+ for (const file of files) {
+ file_ids.push(file.file_id);
+ if (file.type.startsWith('image')) {
+ userMessage.content.push({
+ type: ContentTypes.IMAGE_FILE,
+ [ContentTypes.IMAGE_FILE]: { file_id: file.file_id },
+ });
+ }
+
+ if (!userMessage.attachments) {
+ userMessage.attachments = [];
+ }
+
+ userMessage.attachments.push({
+ file_id: file.file_id,
+ tools: [{ type: ToolCallTypes.CODE_INTERPRETER }],
+ });
+
+ if (file.type.startsWith('image')) {
+ continue;
+ }
+
+ const mimeType = file.type;
+ const isSupportedByRetrieval = retrievalMimeTypes.some((regex) => regex.test(mimeType));
+ if (isSupportedByRetrieval) {
+ userMessage.attachments[attachmentIndex].tools.push({
+ type: ToolCallTypes.FILE_SEARCH,
+ });
+ }
+
+ attachmentIndex++;
+ }
+ }
+ };
+
+ /** @type {Promise|undefined} */
+ let userMessagePromise;
+
+ const initializeThread = async () => {
+ await getRequestFileIds();
+
+ // TODO: may allow multiple messages to be created beforehand in a future update
+ const initThreadBody = {
+ messages: [userMessage],
+ metadata: {
+ user: req.user.id,
+ conversationId,
+ },
+ };
+
+ const result = await initThread({ openai, body: initThreadBody, thread_id });
+ thread_id = result.thread_id;
+
+ createOnTextProgress({
+ openai,
+ conversationId,
+ userMessageId,
+ messageId: responseMessageId,
+ thread_id,
+ });
+
+ requestMessage = {
+ user: req.user.id,
+ text,
+ messageId: userMessageId,
+ parentMessageId,
+ // TODO: make sure client sends correct format for `files`, use zod
+ files,
+ file_ids,
+ conversationId,
+ isCreatedByUser: true,
+ assistant_id,
+ thread_id,
+ model: assistant_id,
+ endpoint,
+ };
+
+ previousMessages.push(requestMessage);
+
+ /* asynchronous */
+ userMessagePromise = saveUserMessage(req, { ...requestMessage, model });
+
+ conversation = {
+ conversationId,
+ endpoint,
+ promptPrefix: promptPrefix,
+ instructions: instructions,
+ assistant_id,
+ // model,
+ };
+
+ if (file_ids.length) {
+ conversation.file_ids = file_ids;
+ }
+ };
+
+ const promises = [initializeThread(), checkBalanceBeforeRun()];
+ await Promise.all(promises);
+
+ const sendInitialResponse = () => {
+ sendEvent(res, {
+ sync: true,
+ conversationId,
+ // messages: previousMessages,
+ requestMessage,
+ responseMessage: {
+ user: req.user.id,
+ messageId: openai.responseMessage.messageId,
+ parentMessageId: userMessageId,
+ conversationId,
+ assistant_id,
+ thread_id,
+ model: assistant_id,
+ },
+ });
+ };
+
+ /** @type {RunResponse | typeof StreamRunManager | undefined} */
+ let response;
+
+ const processRun = async (retry = false) => {
+ if (endpoint === EModelEndpoint.azureAssistants) {
+ body.model = openai._options.model;
+ openai.attachedFileIds = attachedFileIds;
+ if (retry) {
+ response = await runAssistant({
+ openai,
+ thread_id,
+ run_id,
+ in_progress: openai.in_progress,
+ });
+ return;
+ }
+
+ /* NOTE:
+ * By default, a Run will use the model and tools configuration specified in Assistant object,
+ * but you can override most of these when creating the Run for added flexibility:
+ */
+ const run = await createRun({
+ openai,
+ thread_id,
+ body,
+ });
+
+ run_id = run.id;
+ await cache.set(cacheKey, `${thread_id}:${run_id}`, Time.TEN_MINUTES);
+ sendInitialResponse();
+
+ // todo: retry logic
+ response = await runAssistant({ openai, thread_id, run_id });
+ return;
+ }
+
+ /** @type {{[AssistantStreamEvents.ThreadRunCreated]: (event: ThreadRunCreated) => Promise}} */
+ const handlers = {
+ [AssistantStreamEvents.ThreadRunCreated]: async (event) => {
+ await cache.set(cacheKey, `${thread_id}:${event.data.id}`, Time.TEN_MINUTES);
+ run_id = event.data.id;
+ sendInitialResponse();
+ },
+ };
+
+ /** @type {undefined | TAssistantEndpoint} */
+ const config = appConfig.endpoints?.[endpoint] ?? {};
+ /** @type {undefined | TBaseEndpoint} */
+ const allConfig = appConfig.endpoints?.all;
+
+ const streamRunManager = new StreamRunManager({
+ req,
+ res,
+ openai,
+ handlers,
+ thread_id,
+ attachedFileIds,
+ parentMessageId: userMessageId,
+ responseMessage: openai.responseMessage,
+ streamRate: allConfig?.streamRate ?? config.streamRate,
+ // streamOptions: {
+
+ // },
+ });
+
+ await streamRunManager.runAssistant({
+ thread_id,
+ body,
+ });
+
+ response = streamRunManager;
+ response.text = streamRunManager.intermediateText;
+ };
+
+ await processRun();
+ logger.debug('[/assistants/chat/] response', {
+ run: response.run,
+ steps: response.steps,
+ });
+
+ if (response.run.status === RunStatus.CANCELLED) {
+ logger.debug('[/assistants/chat/] Run cancelled, handled by `abortRun`');
+ return res.end();
+ }
+
+ if (response.run.status === RunStatus.IN_PROGRESS) {
+ processRun(true);
+ }
+
+ completedRun = response.run;
+
+ /** @type {ResponseMessage} */
+ const responseMessage = {
+ ...(response.responseMessage ?? response.finalMessage),
+ text: response.text,
+ parentMessageId: userMessageId,
+ conversationId,
+ user: req.user.id,
+ assistant_id,
+ thread_id,
+ model: assistant_id,
+ endpoint,
+ spec: endpointOption.spec,
+ iconURL: endpointOption.iconURL,
+ };
+
+ sendEvent(res, {
+ final: true,
+ conversation,
+ requestMessage: {
+ parentMessageId,
+ thread_id,
+ },
+ });
+ res.end();
+
+ if (userMessagePromise) {
+ await userMessagePromise;
+ }
+ await saveAssistantMessage(req, { ...responseMessage, model });
+
+ if (parentMessageId === Constants.NO_PARENT && !_thread_id) {
+ addTitle(req, {
+ text,
+ responseText: response.text,
+ conversationId,
+ });
+ }
+
+ await addThreadMetadata({
+ openai,
+ thread_id,
+ messageId: responseMessage.messageId,
+ messages: response.messages,
+ });
+
+ if (!response.run.usage) {
+ await sleep(3000);
+ completedRun = await openai.beta.threads.runs.retrieve(response.run.id, { thread_id });
+ if (completedRun.usage) {
+ await recordUsage({
+ ...completedRun.usage,
+ user: req.user.id,
+ model: completedRun.model ?? model,
+ conversationId,
+ });
+ }
+ } else {
+ await recordUsage({
+ ...response.run.usage,
+ user: req.user.id,
+ model: response.run.model ?? model,
+ conversationId,
+ });
+ }
+ } catch (error) {
+ await handleError(error);
+ }
+};
+
+module.exports = chatV2;
diff --git a/api/server/controllers/assistants/errors.js b/api/server/controllers/assistants/errors.js
new file mode 100644
index 0000000..f8dcf39
--- /dev/null
+++ b/api/server/controllers/assistants/errors.js
@@ -0,0 +1,193 @@
+// errorHandler.js
+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 getLogStores = require('~/cache/getLogStores');
+const { getConvo } = require('~/models');
+
+/**
+ * @typedef {Object} ErrorHandlerContext
+ * @property {OpenAIClient} openai - The OpenAI client
+ * @property {string} thread_id - The thread ID
+ * @property {string} run_id - The run ID
+ * @property {boolean} completedRun - Whether the run has completed
+ * @property {string} assistant_id - The assistant ID
+ * @property {string} conversationId - The conversation ID
+ * @property {string} parentMessageId - The parent message ID
+ * @property {string} responseMessageId - The response message ID
+ * @property {string} endpoint - The endpoint being used
+ * @property {string} cacheKey - The cache key for the current request
+ */
+
+/**
+ * @typedef {Object} ErrorHandlerDependencies
+ * @property {ServerRequest} req - The Express request object
+ * @property {Express.Response} res - The Express response object
+ * @property {() => ErrorHandlerContext} getContext - Function to get the current context
+ * @property {string} [originPath] - The origin path for the error handler
+ */
+
+/**
+ * Creates an error handler function with the given dependencies
+ * @param {ErrorHandlerDependencies} dependencies - The dependencies for the error handler
+ * @returns {(error: Error) => Promise} The error handler function
+ */
+const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/chat/' }) => {
+ const cache = getLogStores(CacheKeys.ABORT_KEYS);
+
+ /**
+ * Handles errors that occur during the chat process
+ * @param {Error} error - The error that occurred
+ * @returns {Promise}
+ */
+ return async (error) => {
+ const {
+ openai,
+ run_id,
+ endpoint,
+ cacheKey,
+ thread_id,
+ completedRun,
+ assistant_id,
+ conversationId,
+ parentMessageId,
+ responseMessageId,
+ } = getContext();
+
+ const defaultErrorMessage =
+ 'The Assistant run failed to initialize. Try sending a message in a new conversation.';
+ const messageData = {
+ thread_id,
+ assistant_id,
+ conversationId,
+ parentMessageId,
+ sender: 'System',
+ user: req.user.id,
+ shouldSaveMessage: false,
+ messageId: responseMessageId,
+ endpoint,
+ };
+
+ if (error.message === 'Run cancelled') {
+ return res.end();
+ } else if (error.message === 'Request closed' && completedRun) {
+ return;
+ } else if (error.message === 'Request closed') {
+ logger.debug(`[${originPath}] Request aborted on close`);
+ } else if (/Files.*are invalid/.test(error.message)) {
+ const errorMessage = `Files are invalid, or may not have uploaded yet.${
+ endpoint === 'azureAssistants'
+ ? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
+ : ''
+ }`;
+ return sendResponse(req, res, messageData, errorMessage);
+ } else if (error?.message?.includes('string too long')) {
+ return sendResponse(
+ req,
+ res,
+ messageData,
+ 'Message too long. The Assistants API has a limit of 32,768 characters per message. Please shorten it and try again.',
+ );
+ } else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
+ return sendResponse(req, res, messageData, error.message);
+ } else {
+ logger.error(`[${originPath}]`, error);
+ }
+
+ if (!openai || !thread_id || !run_id) {
+ return sendResponse(req, res, messageData, defaultErrorMessage);
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ try {
+ const status = await cache.get(cacheKey);
+ if (status === 'cancelled') {
+ logger.debug(`[${originPath}] Run already cancelled`);
+ return res.end();
+ }
+ await cache.delete(cacheKey);
+ const cancelledRun = await openai.beta.threads.runs.cancel(run_id, { thread_id });
+ logger.debug(`[${originPath}] Cancelled run:`, cancelledRun);
+ } catch (error) {
+ logger.error(`[${originPath}] Error cancelling run`, error);
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ let run;
+ try {
+ run = await openai.beta.threads.runs.retrieve(run_id, { thread_id });
+ await recordUsage({
+ ...run.usage,
+ model: run.model,
+ user: req.user.id,
+ conversationId,
+ });
+ } catch (error) {
+ logger.error(`[${originPath}] Error fetching or processing run`, error);
+ }
+
+ let finalEvent;
+ try {
+ const runMessages = await checkMessageGaps({
+ openai,
+ run_id,
+ endpoint,
+ thread_id,
+ conversationId,
+ latestMessageId: responseMessageId,
+ });
+
+ const errorContentPart = {
+ text: {
+ value:
+ error?.message ?? 'There was an error processing your request. Please try again later.',
+ },
+ type: ContentTypes.ERROR,
+ };
+
+ if (!Array.isArray(runMessages[runMessages.length - 1]?.content)) {
+ runMessages[runMessages.length - 1].content = [errorContentPart];
+ } else {
+ const contentParts = runMessages[runMessages.length - 1].content;
+ for (let i = 0; i < contentParts.length; i++) {
+ const currentPart = contentParts[i];
+ /** @type {CodeToolCall | RetrievalToolCall | FunctionToolCall | undefined} */
+ const toolCall = currentPart?.[ContentTypes.TOOL_CALL];
+ if (
+ toolCall &&
+ toolCall?.function &&
+ !(toolCall?.function?.output || toolCall?.function?.output?.length)
+ ) {
+ contentParts[i] = {
+ ...currentPart,
+ [ContentTypes.TOOL_CALL]: {
+ ...toolCall,
+ function: {
+ ...toolCall.function,
+ output: 'error processing tool',
+ },
+ },
+ };
+ }
+ }
+ runMessages[runMessages.length - 1].content.push(errorContentPart);
+ }
+
+ finalEvent = {
+ final: true,
+ conversation: await getConvo(req.user.id, conversationId),
+ runMessages,
+ };
+ } catch (error) {
+ logger.error(`[${originPath}] Error finalizing error process`, error);
+ return sendResponse(req, res, messageData, 'The Assistant run failed');
+ }
+
+ return sendResponse(req, res, finalEvent);
+ };
+};
+
+module.exports = { createErrorHandler };
diff --git a/api/server/controllers/assistants/helpers.js b/api/server/controllers/assistants/helpers.js
new file mode 100644
index 0000000..4630bfe
--- /dev/null
+++ b/api/server/controllers/assistants/helpers.js
@@ -0,0 +1,289 @@
+const {
+ 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');
+
+/**
+ * @param {ServerRequest} req
+ * @param {string} [endpoint]
+ * @returns {Promise}
+ */
+const getCurrentVersion = async (req, endpoint) => {
+ const index = req.baseUrl.lastIndexOf('/v');
+ let version = index !== -1 ? req.baseUrl.substring(index + 1, index + 3) : null;
+ if (!version && req.body.version) {
+ version = `v${req.body.version}`;
+ }
+ if (!version && endpoint) {
+ const endpointsConfig = await getEndpointsConfig(req);
+ version = `v${endpointsConfig?.[endpoint]?.version ?? defaultAssistantsVersion[endpoint]}`;
+ }
+ if (!version?.startsWith('v') && version.length !== 2) {
+ throw new Error(`[${req.baseUrl}] Invalid version: ${version}`);
+ }
+ return version;
+};
+
+/**
+ * Asynchronously lists assistants based on provided query parameters.
+ *
+ * Initializes the client with the current request and response objects and lists assistants
+ * according to the query parameters. This function abstracts the logic for non-Azure paths.
+ *
+ * @deprecated
+ * @async
+ * @param {object} params - The parameters object.
+ * @param {object} params.req - The request object, used for initializing the client.
+ * @param {object} params.res - The response object, used for initializing the client.
+ * @param {string} params.version - The API version to use.
+ * @param {object} params.query - The query parameters to list assistants (e.g., limit, order).
+ * @returns {Promise} A promise that resolves to the response from the `openai.beta.assistants.list` method call.
+ */
+const _listAssistants = async ({ req, res, version, query }) => {
+ const { openai } = await getOpenAIClient({ req, res, version });
+ return openai.beta.assistants.list(query);
+};
+
+/**
+ * Fetches all assistants based on provided query params, until `has_more` is `false`.
+ *
+ * @async
+ * @param {object} params - The parameters object.
+ * @param {object} params.req - The request object, used for initializing the client.
+ * @param {object} params.res - The response object, used for initializing the client.
+ * @param {string} params.version - The API version to use.
+ * @param {Omit} params.query - The query parameters to list assistants (e.g., limit, order).
+ * @returns {Promise>} A promise that resolves to the response from the `openai.beta.assistants.list` method call.
+ */
+const listAllAssistants = async ({ req, res, version, query }) => {
+ /** @type {{ openai: OpenAI }} */
+ const { openai } = await getOpenAIClient({ req, res, version });
+ const allAssistants = [];
+
+ let first_id;
+ let last_id;
+ let afterToken = query.after;
+ let hasMore = true;
+
+ while (hasMore) {
+ const response = await openai.beta.assistants.list({
+ ...query,
+ after: afterToken,
+ });
+
+ const { body } = response;
+
+ allAssistants.push(...body.data);
+ hasMore = body.has_more;
+
+ if (!first_id) {
+ first_id = body.first_id;
+ }
+
+ if (hasMore) {
+ afterToken = body.last_id;
+ } else {
+ last_id = body.last_id;
+ }
+ }
+
+ return {
+ data: allAssistants,
+ body: {
+ data: allAssistants,
+ has_more: false,
+ first_id,
+ last_id,
+ },
+ };
+};
+
+/**
+ * Asynchronously lists assistants for Azure configured groups.
+ *
+ * Iterates through Azure configured assistant groups, initializes the client with the current request and response objects,
+ * lists assistants based on the provided query parameters, and merges their data alongside the model information into a single array.
+ *
+ * @async
+ * @param {object} params - The parameters object.
+ * @param {object} params.req - The request object, used for initializing the client and manipulating the request body.
+ * @param {object} params.res - The response object, used for initializing the client.
+ * @param {string} params.version - The API version to use.
+ * @param {TAzureConfig} params.azureConfig - The Azure configuration object containing assistantGroups and groupMap.
+ * @param {object} params.query - The query parameters to list assistants (e.g., limit, order).
+ * @returns {Promise} A promise that resolves to an array of assistant data merged with their respective model information.
+ */
+const listAssistantsForAzure = async ({ req, res, version, azureConfig = {}, query }) => {
+ /** @type {Array<[string, TAzureModelConfig]>} */
+ const groupModelTuples = [];
+ const promises = [];
+ /** @type {Array} */
+ const groups = [];
+
+ const { groupMap, assistantGroups } = azureConfig;
+
+ for (const groupName of assistantGroups) {
+ const group = groupMap[groupName];
+ groups.push(group);
+
+ const currentModelTuples = Object.entries(group?.models);
+ groupModelTuples.push(currentModelTuples);
+
+ /* The specified model is only necessary to
+ fetch assistants for the shared instance */
+ req.body = req.body || {}; // Express 5: req.body is undefined instead of {} when no body parser runs
+ req.body.model = currentModelTuples[0][0];
+ promises.push(listAllAssistants({ req, res, version, query }));
+ }
+
+ const resolvedQueries = await Promise.all(promises);
+ const data = resolvedQueries.flatMap((res, i) =>
+ res.data.map((assistant) => {
+ const deploymentName = assistant.model;
+ const currentGroup = groups[i];
+ const currentModelTuples = groupModelTuples[i];
+ const firstModel = currentModelTuples[0][0];
+
+ if (currentGroup.deploymentName === deploymentName) {
+ return { ...assistant, model: firstModel };
+ }
+
+ for (const [model, modelConfig] of currentModelTuples) {
+ if (modelConfig.deploymentName === deploymentName) {
+ return { ...assistant, model };
+ }
+ }
+
+ return { ...assistant, model: firstModel };
+ }),
+ );
+
+ return {
+ first_id: data[0]?.id,
+ last_id: data[data.length - 1]?.id,
+ object: 'list',
+ has_more: false,
+ data,
+ };
+};
+
+/**
+ * Initializes the OpenAI client.
+ * @param {object} params - The parameters object.
+ * @param {ServerRequest} params.req - The request object.
+ * @param {ServerResponse} params.res - The response object.
+ * @param {TEndpointOption} params.endpointOption - The endpoint options.
+ * @param {boolean} params.initAppClient - Whether to initialize the app client.
+ * @param {string} params.overrideEndpoint - The endpoint to override.
+ * @returns {Promise<{ openai: OpenAI, openAIApiKey: string }>} - The initialized OpenAI SDK client.
+ */
+async function getOpenAIClient({ req, res, endpointOption, initAppClient, overrideEndpoint }) {
+ let endpoint = overrideEndpoint ?? req.body?.endpoint ?? req.query?.endpoint;
+ const version = await getCurrentVersion(req, endpoint);
+ if (!endpoint) {
+ throw new Error(`[${req.baseUrl}] Endpoint is required`);
+ }
+
+ let result;
+ if (endpoint === EModelEndpoint.assistants) {
+ result = await initializeClient({ req, res, version, endpointOption, initAppClient });
+ } else if (endpoint === EModelEndpoint.azureAssistants) {
+ result = await initAzureClient({ req, res, version, endpointOption, initAppClient });
+ }
+
+ return result;
+}
+
+/**
+ * Returns a list of assistants.
+ * @param {object} params
+ * @param {object} params.req - Express Request
+ * @param {AssistantListParams} [params.req.query] - The assistant list parameters for pagination and sorting.
+ * @param {object} params.res - Express Response
+ * @param {string} [params.overrideEndpoint] - The endpoint to override the request endpoint.
+ * @returns {Promise} 200 - success response - application/json
+ */
+const fetchAssistants = async ({ req, res, overrideEndpoint }) => {
+ const appConfig = req.config;
+ const {
+ limit = 100,
+ order = 'desc',
+ after,
+ before,
+ endpoint,
+ } = req.query ?? {
+ endpoint: overrideEndpoint,
+ ...defaultOrderQuery,
+ };
+
+ const version = await getCurrentVersion(req, endpoint);
+ const query = { limit, order, after, before };
+
+ /** @type {AssistantListResponse} */
+ let body;
+
+ if (endpoint === EModelEndpoint.assistants) {
+ ({ body } = await listAllAssistants({ req, res, version, query }));
+ } else if (endpoint === EModelEndpoint.azureAssistants) {
+ const azureConfig = appConfig.endpoints?.[EModelEndpoint.azureOpenAI];
+ body = await listAssistantsForAzure({ req, res, version, azureConfig, query });
+ }
+
+ if (!appConfig.endpoints?.[endpoint]) {
+ return body;
+ }
+
+ 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;
+ }
+
+ body.data = filterAssistants({
+ userId: req.user.id,
+ assistants: body.data,
+ assistantsConfig: appConfig.endpoints?.[endpoint],
+ });
+ return body;
+};
+
+/**
+ * Filter assistants based on configuration.
+ *
+ * @param {object} params - The parameters object.
+ * @param {string} params.userId - The user ID to filter private assistants.
+ * @param {Assistant[]} params.assistants - The list of assistants to filter.
+ * @param {Partial} params.assistantsConfig - The assistant configuration.
+ * @returns {Assistant[]} - The filtered list of assistants.
+ */
+function filterAssistants({ assistants, userId, assistantsConfig }) {
+ const { supportedIds, excludedIds, privateAssistants } = assistantsConfig;
+ if (privateAssistants) {
+ return assistants.filter((assistant) => userId === assistant.metadata?.author);
+ } else if (supportedIds?.length) {
+ return assistants.filter((assistant) => supportedIds.includes(assistant.id));
+ } else if (excludedIds?.length) {
+ return assistants.filter((assistant) => !excludedIds.includes(assistant.id));
+ }
+ return assistants;
+}
+
+module.exports = {
+ getOpenAIClient,
+ fetchAssistants,
+ getCurrentVersion,
+};
diff --git a/api/server/controllers/assistants/v1.js b/api/server/controllers/assistants/v1.js
new file mode 100644
index 0000000..2a835c8
--- /dev/null
+++ b/api/server/controllers/assistants/v1.js
@@ -0,0 +1,405 @@
+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 { getOpenAIClient, fetchAssistants } = require('./helpers');
+const { getCachedTools } = require('~/server/services/Config');
+const { manifestToolMap, isAgentsOnlyTool } = require('~/app/clients/tools');
+
+/**
+ * Create an assistant.
+ * @route POST /assistants
+ * @param {AssistantCreateParams} req.body - The assistant creation parameters.
+ * @returns {Assistant} 201 - success response - application/json
+ */
+const createAssistant = async (req, res) => {
+ try {
+ const { openai } = await getOpenAIClient({ req, res });
+
+ const {
+ tools = [],
+ endpoint,
+ conversation_starters,
+ append_current_datetime,
+ ...assistantData
+ } = req.body;
+ delete assistantData.conversation_starters;
+ delete assistantData.append_current_datetime;
+
+ const toolDefinitions = (await getCachedTools()) ?? {};
+
+ assistantData.tools = tools
+ .map((tool) => {
+ /** Agents-runtime-only tools (e.g. ask_user_question) cannot execute on
+ * the assistants runtime — drop them even when posted directly, since
+ * the tools-dialog scoping doesn't gate REST clients or stale payloads. */
+ if (isAgentsOnlyTool(tool)) {
+ logger.warn(
+ `[/assistants] Dropping agents-only tool from assistant payload: ${typeof tool === 'string' ? tool : tool?.function?.name}`,
+ );
+ return undefined;
+ }
+ if (typeof tool !== 'string') {
+ return tool;
+ }
+
+ const toolDef = toolDefinitions[tool];
+ if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
+ return Object.entries(toolDefinitions)
+ .filter(([key]) => key.startsWith(`${tool}_`))
+
+ .map(([_, val]) => val);
+ }
+
+ return toolDef;
+ })
+ .filter((tool) => tool)
+ .flat();
+
+ let azureModelIdentifier = null;
+ if (openai.locals?.azureOptions) {
+ azureModelIdentifier = assistantData.model;
+ assistantData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
+ }
+
+ assistantData.metadata = {
+ author: req.user.id,
+ endpoint,
+ };
+
+ const assistant = await openai.beta.assistants.create(assistantData);
+
+ const createData = { user: req.user.id };
+ if (conversation_starters) {
+ createData.conversation_starters = conversation_starters;
+ }
+ if (append_current_datetime !== undefined) {
+ createData.append_current_datetime = append_current_datetime;
+ }
+
+ const document = await updateAssistantDoc({ assistant_id: assistant.id }, createData);
+
+ if (azureModelIdentifier) {
+ assistant.model = azureModelIdentifier;
+ }
+
+ if (document.conversation_starters) {
+ assistant.conversation_starters = document.conversation_starters;
+ }
+
+ if (append_current_datetime !== undefined) {
+ assistant.append_current_datetime = append_current_datetime;
+ }
+
+ logger.debug('/assistants/', assistant);
+ res.status(201).json(assistant);
+ } catch (error) {
+ logger.error('[/assistants] Error creating assistant', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Retrieves an assistant.
+ * @route GET /assistants/:id
+ * @param {string} req.params.id - Assistant identifier.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+const retrieveAssistant = async (req, res) => {
+ try {
+ /* NOTE: not actually being used right now */
+ const { openai } = await getOpenAIClient({ req, res });
+ const assistant_id = req.params.id;
+ const assistant = await openai.beta.assistants.retrieve(assistant_id);
+ res.json(assistant);
+ } catch (error) {
+ logger.error('[/assistants/:id] Error retrieving assistant', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Modifies an assistant.
+ * @route PATCH /assistants/:id
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.id - Assistant identifier.
+ * @param {AssistantUpdateParams} req.body - The assistant update parameters.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+const patchAssistant = async (req, res) => {
+ try {
+ const { openai } = await getOpenAIClient({ req, res });
+ await validateAuthor({ req, openai });
+
+ const assistant_id = req.params.id;
+ const {
+ endpoint: _e,
+ conversation_starters,
+ append_current_datetime,
+ ...updateData
+ } = req.body;
+
+ const toolDefinitions = (await getCachedTools()) ?? {};
+
+ updateData.tools = (updateData.tools ?? [])
+ .map((tool) => {
+ /** Agents-runtime-only tools (e.g. ask_user_question) cannot execute on
+ * the assistants runtime — drop them even when posted directly, since
+ * the tools-dialog scoping doesn't gate REST clients or stale payloads. */
+ if (isAgentsOnlyTool(tool)) {
+ logger.warn(
+ `[/assistants] Dropping agents-only tool from assistant payload: ${typeof tool === 'string' ? tool : tool?.function?.name}`,
+ );
+ return undefined;
+ }
+ if (typeof tool !== 'string') {
+ return tool;
+ }
+
+ const toolDef = toolDefinitions[tool];
+ if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
+ return Object.entries(toolDefinitions)
+ .filter(([key]) => key.startsWith(`${tool}_`))
+
+ .map(([_, val]) => val);
+ }
+
+ return toolDef;
+ })
+ .filter((tool) => tool)
+ .flat();
+
+ if (openai.locals?.azureOptions && updateData.model) {
+ updateData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
+ }
+
+ const updatedAssistant = await openai.beta.assistants.update(assistant_id, updateData);
+
+ if (conversation_starters !== undefined) {
+ const conversationStartersUpdate = await updateAssistantDoc(
+ { assistant_id },
+ { conversation_starters },
+ );
+ updatedAssistant.conversation_starters = conversationStartersUpdate.conversation_starters;
+ }
+
+ if (append_current_datetime !== undefined) {
+ await updateAssistantDoc({ assistant_id }, { append_current_datetime });
+ updatedAssistant.append_current_datetime = append_current_datetime;
+ }
+
+ res.json(updatedAssistant);
+ } catch (error) {
+ logger.error('[/assistants/:id] Error updating assistant', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Deletes an assistant.
+ * @route DELETE /assistants/:id
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.id - Assistant identifier.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+const deleteAssistant = async (req, res) => {
+ try {
+ const { openai } = await getOpenAIClient({ req, res });
+ await validateAuthor({ req, openai });
+
+ const assistant_id = req.params.id;
+ const deletionStatus = await openai.beta.assistants.delete(assistant_id);
+ if (deletionStatus?.deleted) {
+ await deleteAssistantActions({ req, assistant_id });
+ }
+ res.json(deletionStatus);
+ } catch (error) {
+ logger.error('[/assistants/:id] Error deleting assistant', error);
+ res.status(500).json({ error: 'Error deleting assistant' });
+ }
+};
+
+/**
+ * Returns a list of assistants.
+ * @route GET /assistants
+ * @param {object} req - Express Request
+ * @param {AssistantListParams} req.query - The assistant list parameters for pagination and sorting.
+ * @returns {AssistantListResponse} 200 - success response - application/json
+ */
+const listAssistants = async (req, res) => {
+ try {
+ const body = await fetchAssistants({ req, res });
+ res.json(body);
+ } catch (error) {
+ logger.error('[/assistants] Error listing assistants', error);
+ res.status(500).json({ message: 'Error listing assistants' });
+ }
+};
+
+/**
+ * Filter assistants based on configuration.
+ *
+ * @param {object} params - The parameters object.
+ * @param {string} params.userId - The user ID to filter private assistants.
+ * @param {AssistantDocument[]} params.assistants - The list of assistants to filter.
+ * @param {Partial} [params.assistantsConfig] - The assistant configuration.
+ * @returns {AssistantDocument[]} - The filtered list of assistants.
+ */
+function filterAssistantDocs({ documents, userId, assistantsConfig = {} }) {
+ const { supportedIds, excludedIds, privateAssistants } = assistantsConfig;
+ const removeUserId = (doc) => {
+ const { user: _u, ...document } = doc;
+ return document;
+ };
+
+ if (privateAssistants) {
+ return documents.filter((doc) => userId === doc.user.toString()).map(removeUserId);
+ } else if (supportedIds?.length) {
+ return documents.filter((doc) => supportedIds.includes(doc.assistant_id)).map(removeUserId);
+ } else if (excludedIds?.length) {
+ return documents.filter((doc) => !excludedIds.includes(doc.assistant_id)).map(removeUserId);
+ }
+ return documents.map(removeUserId);
+}
+
+/**
+ * Returns a list of the user's assistant documents (metadata saved to database).
+ * @route GET /assistants/documents
+ * @returns {AssistantDocument[]} 200 - success response - application/json
+ */
+const getAssistantDocuments = async (req, res) => {
+ try {
+ const appConfig = req.config;
+ const endpoint = req.query?.endpoint;
+ const assistantsConfig = appConfig.endpoints?.[endpoint];
+ const documents = await getAssistants(
+ {},
+ {
+ user: 1,
+ assistant_id: 1,
+ conversation_starters: 1,
+ createdAt: 1,
+ updatedAt: 1,
+ append_current_datetime: 1,
+ },
+ );
+
+ const docs = filterAssistantDocs({
+ documents,
+ userId: req.user.id,
+ assistantsConfig,
+ });
+ res.json(docs);
+ } catch (error) {
+ logger.error('[/assistants/documents] Error listing assistant documents', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Uploads and updates an avatar for a specific assistant.
+ * @route POST /:assistant_id/avatar
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.assistant_id - The ID of the assistant.
+ * @param {Express.Multer.File} req.file - The avatar image file.
+ * @param {object} req.body - Request body
+ * @returns {Object} 200 - success response - application/json
+ */
+const uploadAssistantAvatar = async (req, res) => {
+ try {
+ const appConfig = req.config;
+ filterFile({ req, file: req.file, image: true, isAvatar: true });
+ const { assistant_id } = req.params;
+ if (!assistant_id) {
+ return res.status(400).json({ message: 'Assistant ID is required' });
+ }
+
+ const { openai } = await getOpenAIClient({ req, res });
+ await validateAuthor({ req, openai });
+
+ const buffer = await fs.readFile(req.file.path);
+ const image = await uploadImageBuffer({
+ req,
+ context: FileContext.avatar,
+ metadata: { buffer },
+ });
+
+ let _metadata;
+
+ try {
+ const assistant = await openai.beta.assistants.retrieve(assistant_id);
+ if (assistant) {
+ _metadata = assistant.metadata;
+ }
+ } catch (error) {
+ logger.error('[/:assistant_id/avatar] Error fetching assistant', error);
+ _metadata = {};
+ }
+
+ if (_metadata.avatar && _metadata.avatar_source) {
+ const { deleteFile } = getStrategyFunctions(_metadata.avatar_source);
+ try {
+ await deleteFile(req, {
+ filepath: _metadata.avatar,
+ user: req.user.id,
+ tenantId: req.user.tenantId,
+ });
+ await deleteFileByFilter({ user: req.user.id, filepath: _metadata.avatar });
+ } catch (error) {
+ logger.error('[/:assistant_id/avatar] Error deleting old avatar', error);
+ }
+ }
+
+ const metadata = {
+ ..._metadata,
+ avatar: image.filepath,
+ avatar_source: appConfig.fileStrategy,
+ };
+
+ const promises = [];
+ promises.push(
+ updateAssistantDoc(
+ { assistant_id },
+ {
+ avatar: {
+ filepath: image.filepath,
+ source: appConfig.fileStrategy,
+ },
+ user: req.user.id,
+ },
+ ),
+ );
+ promises.push(openai.beta.assistants.update(assistant_id, { metadata }));
+
+ const resolved = await Promise.all(promises);
+ res.status(201).json(resolved[1]);
+ } catch (error) {
+ const message = 'An error occurred while updating the Assistant Avatar';
+ logger.error(message, error);
+ res.status(500).json({ message });
+ } finally {
+ try {
+ await fs.unlink(req.file.path);
+ logger.debug('[/:agent_id/avatar] Temp. image upload file deleted');
+ } catch {
+ logger.debug('[/:agent_id/avatar] Temp. image upload file already deleted');
+ }
+ }
+};
+
+module.exports = {
+ createAssistant,
+ retrieveAssistant,
+ patchAssistant,
+ deleteAssistant,
+ listAssistants,
+ getAssistantDocuments,
+ uploadAssistantAvatar,
+};
diff --git a/api/server/controllers/assistants/v2.js b/api/server/controllers/assistants/v2.js
new file mode 100644
index 0000000..fa05e12
--- /dev/null
+++ b/api/server/controllers/assistants/v2.js
@@ -0,0 +1,315 @@
+const { logger } = require('@librechat/data-schemas');
+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 { manifestToolMap, isAgentsOnlyTool } = require('~/app/clients/tools');
+const { updateAssistantDoc } = require('~/models');
+const { getOpenAIClient } = require('./helpers');
+
+/**
+ * Create an assistant.
+ * @route POST /assistants
+ * @param {AssistantCreateParams} req.body - The assistant creation parameters.
+ * @returns {Assistant} 201 - success response - application/json
+ */
+const createAssistant = async (req, res) => {
+ try {
+ /** @type {{ openai: OpenAIClient }} */
+ const { openai } = await getOpenAIClient({ req, res });
+
+ const {
+ tools = [],
+ endpoint,
+ conversation_starters,
+ append_current_datetime,
+ ...assistantData
+ } = req.body;
+ delete assistantData.conversation_starters;
+ delete assistantData.append_current_datetime;
+
+ const toolDefinitions = (await getCachedTools()) ?? {};
+
+ assistantData.tools = tools
+ .map((tool) => {
+ /** Agents-runtime-only tools (e.g. ask_user_question) cannot execute on
+ * the assistants runtime — drop them even when posted directly, since
+ * the tools-dialog scoping doesn't gate REST clients or stale payloads. */
+ if (isAgentsOnlyTool(tool)) {
+ logger.warn(
+ `[/assistants] Dropping agents-only tool from assistant payload: ${typeof tool === 'string' ? tool : tool?.function?.name}`,
+ );
+ return undefined;
+ }
+ if (typeof tool !== 'string') {
+ return tool;
+ }
+
+ const toolDef = toolDefinitions[tool];
+ if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
+ return Object.entries(toolDefinitions)
+ .filter(([key]) => key.startsWith(`${tool}_`))
+
+ .map(([_, val]) => val);
+ }
+
+ return toolDef;
+ })
+ .filter((tool) => tool)
+ .flat();
+
+ let azureModelIdentifier = null;
+ if (openai.locals?.azureOptions) {
+ azureModelIdentifier = assistantData.model;
+ assistantData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
+ }
+
+ assistantData.metadata = {
+ author: req.user.id,
+ endpoint,
+ };
+
+ const assistant = await openai.beta.assistants.create(assistantData);
+
+ const createData = { user: req.user.id };
+ if (conversation_starters) {
+ createData.conversation_starters = conversation_starters;
+ }
+ if (append_current_datetime !== undefined) {
+ createData.append_current_datetime = append_current_datetime;
+ }
+
+ const document = await updateAssistantDoc({ assistant_id: assistant.id }, createData);
+
+ if (azureModelIdentifier) {
+ assistant.model = azureModelIdentifier;
+ }
+
+ if (document.conversation_starters) {
+ assistant.conversation_starters = document.conversation_starters;
+ }
+ if (append_current_datetime !== undefined) {
+ assistant.append_current_datetime = append_current_datetime;
+ }
+
+ logger.debug('/assistants/', assistant);
+ res.status(201).json(assistant);
+ } catch (error) {
+ logger.error('[/assistants] Error creating assistant', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Modifies an assistant.
+ * @param {object} params
+ * @param {ServerRequest} params.req
+ * @param {OpenAIClient} params.openai
+ * @param {string} params.assistant_id
+ * @param {AssistantUpdateParams} params.updateData
+ * @returns {Promise} The updated assistant.
+ */
+const updateAssistant = async ({ req, openai, assistant_id, updateData }) => {
+ await validateAuthor({ req, openai });
+ const tools = [];
+ let conversation_starters = null;
+
+ if (updateData?.conversation_starters) {
+ const conversationStartersUpdate = await updateAssistantDoc(
+ { assistant_id: assistant_id },
+ { conversation_starters: updateData.conversation_starters },
+ );
+ conversation_starters = conversationStartersUpdate.conversation_starters;
+
+ delete updateData.conversation_starters;
+ }
+
+ if (updateData?.append_current_datetime !== undefined) {
+ await updateAssistantDoc(
+ { assistant_id: assistant_id },
+ { append_current_datetime: updateData.append_current_datetime },
+ );
+ delete updateData.append_current_datetime;
+ }
+
+ let hasFileSearch = false;
+ for (const tool of updateData.tools ?? []) {
+ /** Agents-runtime-only tools (e.g. ask_user_question) cannot execute on
+ * the assistants runtime — drop them even when posted directly, since
+ * the tools-dialog scoping doesn't gate REST clients or stale payloads. */
+ if (isAgentsOnlyTool(tool)) {
+ logger.warn(
+ `[/assistants] Dropping agents-only tool from assistant payload: ${typeof tool === 'string' ? tool : tool?.function?.name}`,
+ );
+ continue;
+ }
+ const toolDefinitions = (await getCachedTools()) ?? {};
+ let actualTool = typeof tool === 'string' ? toolDefinitions[tool] : tool;
+
+ if (!actualTool && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
+ actualTool = Object.entries(toolDefinitions)
+ .filter(([key]) => key.startsWith(`${tool}_`))
+
+ .map(([_, val]) => val);
+ } else if (!actualTool) {
+ continue;
+ }
+
+ if (Array.isArray(actualTool)) {
+ for (const subTool of actualTool) {
+ if (!subTool.function) {
+ tools.push(subTool);
+ continue;
+ }
+
+ const updatedTool = await validateAndUpdateTool({ req, tool: subTool, assistant_id });
+ if (updatedTool) {
+ tools.push(updatedTool);
+ }
+ }
+ continue;
+ }
+
+ if (actualTool.type === ToolCallTypes.FILE_SEARCH) {
+ hasFileSearch = true;
+ }
+
+ if (!actualTool.function) {
+ tools.push(actualTool);
+ continue;
+ }
+
+ const updatedTool = await validateAndUpdateTool({ req, tool: actualTool, assistant_id });
+ if (updatedTool) {
+ tools.push(updatedTool);
+ }
+ }
+
+ if (hasFileSearch && !updateData.tool_resources) {
+ const assistant = await openai.beta.assistants.retrieve(assistant_id);
+ updateData.tool_resources = assistant.tool_resources ?? null;
+ }
+
+ if (hasFileSearch && !updateData.tool_resources?.file_search) {
+ updateData.tool_resources = {
+ ...(updateData.tool_resources ?? {}),
+ file_search: {
+ vector_store_ids: [],
+ },
+ };
+ }
+
+ updateData.tools = tools;
+
+ if (openai.locals?.azureOptions && updateData.model) {
+ updateData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
+ }
+
+ const assistant = await openai.beta.assistants.update(assistant_id, updateData);
+
+ if (conversation_starters) {
+ assistant.conversation_starters = conversation_starters;
+ }
+
+ return assistant;
+};
+
+/**
+ * Modifies an assistant with the resource file id.
+ * @param {object} params
+ * @param {ServerRequest} params.req
+ * @param {OpenAIClient} params.openai
+ * @param {string} params.assistant_id
+ * @param {string} params.tool_resource
+ * @param {string} params.file_id
+ * @returns {Promise} The updated assistant.
+ */
+const addResourceFileId = async ({ req, openai, assistant_id, tool_resource, file_id }) => {
+ const assistant = await openai.beta.assistants.retrieve(assistant_id);
+ const { tool_resources = {} } = assistant;
+ if (tool_resources[tool_resource]) {
+ tool_resources[tool_resource].file_ids.push(file_id);
+ } else {
+ tool_resources[tool_resource] = { file_ids: [file_id] };
+ }
+
+ delete assistant.id;
+ return await updateAssistant({
+ req,
+ openai,
+ assistant_id,
+ updateData: { tools: assistant.tools, tool_resources },
+ });
+};
+
+/**
+ * Deletes a file ID from an assistant's resource.
+ * @param {object} params
+ * @param {ServerRequest} params.req
+ * @param {OpenAIClient} params.openai
+ * @param {string} params.assistant_id
+ * @param {string} [params.tool_resource]
+ * @param {string} params.file_id
+ * @param {AssistantUpdateParams} params.updateData
+ * @returns {Promise} The updated assistant.
+ */
+const deleteResourceFileId = async ({ req, openai, assistant_id, tool_resource, file_id }) => {
+ const assistant = await openai.beta.assistants.retrieve(assistant_id);
+ const { tool_resources = {} } = assistant;
+
+ if (tool_resource && tool_resources[tool_resource]) {
+ const resource = tool_resources[tool_resource];
+ const index = resource.file_ids.indexOf(file_id);
+ if (index !== -1) {
+ resource.file_ids.splice(index, 1);
+ }
+ } else {
+ for (const resourceKey in tool_resources) {
+ const resource = tool_resources[resourceKey];
+ const index = resource.file_ids.indexOf(file_id);
+ if (index !== -1) {
+ resource.file_ids.splice(index, 1);
+ break;
+ }
+ }
+ }
+
+ delete assistant.id;
+ return await updateAssistant({
+ req,
+ openai,
+ assistant_id,
+ updateData: { tools: assistant.tools, tool_resources },
+ });
+};
+
+/**
+ * Modifies an assistant.
+ * @route PATCH /assistants/:id
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.id - Assistant identifier.
+ * @param {AssistantUpdateParams} req.body - The assistant update parameters.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+const patchAssistant = async (req, res) => {
+ try {
+ const { openai } = await getOpenAIClient({ req, res });
+ const assistant_id = req.params.id;
+ const { endpoint: _e, ...updateData } = req.body;
+ updateData.tools = updateData.tools ?? [];
+ const updatedAssistant = await updateAssistant({ req, openai, assistant_id, updateData });
+ res.json(updatedAssistant);
+ } catch (error) {
+ logger.error('[/assistants/:id] Error updating assistant', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+module.exports = {
+ patchAssistant,
+ createAssistant,
+ updateAssistant,
+ addResourceFileId,
+ deleteResourceFileId,
+};
diff --git a/api/server/controllers/auth/LoginController.js b/api/server/controllers/auth/LoginController.js
new file mode 100644
index 0000000..bf606b1
--- /dev/null
+++ b/api/server/controllers/auth/LoginController.js
@@ -0,0 +1,30 @@
+const { logger } = require('@librechat/data-schemas');
+const { generate2FATempToken } = require('~/server/services/twoFactorService');
+const { setAuthTokens } = require('~/server/services/AuthService');
+
+const loginController = async (req, res) => {
+ try {
+ if (!req.user) {
+ return res.status(400).json({ message: 'Invalid credentials' });
+ }
+
+ if (req.user.twoFactorEnabled) {
+ const tempToken = generate2FATempToken(req.user._id);
+ return res.status(200).json({ twoFAPending: true, tempToken });
+ }
+
+ const { password: _p, totpSecret: _t, __v, ...user } = req.user;
+ user.id = user._id.toString();
+
+ const token = await setAuthTokens(req.user._id, res, null, req);
+
+ return res.status(200).send({ token, user });
+ } catch (err) {
+ logger.error('[loginController]', err);
+ return res.status(500).json({ message: 'Something went wrong' });
+ }
+};
+
+module.exports = {
+ loginController,
+};
diff --git a/api/server/controllers/auth/LogoutController.js b/api/server/controllers/auth/LogoutController.js
new file mode 100644
index 0000000..95e6c89
--- /dev/null
+++ b/api/server/controllers/auth/LogoutController.js
@@ -0,0 +1,144 @@
+const cookies = require('cookie');
+const { isEnabled, clearCloudFrontCookies } = require('@librechat/api');
+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';
+
+ let refreshToken;
+ let idToken;
+ if (isOpenIdUser && req.session?.openidTokens) {
+ refreshToken = req.session.openidTokens.refreshToken;
+ idToken = req.session.openidTokens.idToken;
+ delete req.session.openidTokens;
+ }
+ refreshToken = refreshToken || parsedCookies.refreshToken;
+ idToken = idToken || parsedCookies.openid_id_token;
+
+ try {
+ const logout = await logoutUser(req, refreshToken);
+ const { status, message } = logout;
+
+ res.clearCookie('refreshToken');
+ res.clearCookie('openid_access_token');
+ res.clearCookie('openid_id_token');
+ res.clearCookie('openid_user_id');
+ res.clearCookie('token_provider');
+ clearCloudFrontCookies(res, {
+ userId: req.user?.id ?? req.user?._id?.toString?.(),
+ tenantId: req.user?.tenantId,
+ });
+ const response = { message };
+ if (
+ isOpenIdUser &&
+ isEnabled(process.env.OPENID_USE_END_SESSION_ENDPOINT) &&
+ process.env.OPENID_ISSUER
+ ) {
+ let openIdConfig;
+ try {
+ openIdConfig = getOpenIdConfig();
+ } catch (err) {
+ logger.warn('[logoutController] OpenID config not available:', err.message);
+ }
+ if (openIdConfig) {
+ const endSessionEndpoint = openIdConfig.serverMetadata().end_session_endpoint;
+ if (endSessionEndpoint) {
+ const endSessionUrl = new URL(endSessionEndpoint);
+ const postLogoutRedirectUri =
+ process.env.OPENID_POST_LOGOUT_REDIRECT_URI || `${process.env.DOMAIN_CLIENT}/login`;
+ endSessionUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
+
+ /**
+ * 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 (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();
+ } else {
+ logger.warn(
+ '[logoutController] end_session_endpoint not found in OpenID issuer metadata. Please verify that the issuer is correct.',
+ );
+ }
+ }
+ }
+ return res.status(status).send(response);
+ } catch (err) {
+ logger.error('[logoutController]', err);
+ return res.status(500).json({ message: err.message });
+ }
+};
+
+module.exports = {
+ logoutController,
+};
diff --git a/api/server/controllers/auth/LogoutController.spec.js b/api/server/controllers/auth/LogoutController.spec.js
new file mode 100644
index 0000000..e6c3149
--- /dev/null
+++ b/api/server/controllers/auth/LogoutController.spec.js
@@ -0,0 +1,583 @@
+const cookies = require('cookie');
+
+const mockLogoutUser = jest.fn();
+const mockLogger = { warn: jest.fn(), error: jest.fn(), debug: jest.fn() };
+const mockIsEnabled = jest.fn();
+const mockGetOpenIdConfig = jest.fn();
+const mockClearCloudFrontCookies = jest.fn();
+
+jest.mock('cookie');
+jest.mock('@librechat/api', () => ({
+ isEnabled: (...args) => mockIsEnabled(...args),
+ clearCloudFrontCookies: (...args) => mockClearCloudFrontCookies(...args),
+}));
+jest.mock('@librechat/data-schemas', () => ({ logger: mockLogger }));
+jest.mock('~/server/services/AuthService', () => ({
+ logoutUser: (...args) => mockLogoutUser(...args),
+}));
+jest.mock('~/strategies', () => ({ getOpenIdConfig: () => mockGetOpenIdConfig() }));
+
+const { logoutController } = require('./LogoutController');
+
+function buildReq(overrides = {}) {
+ return {
+ user: { _id: 'user1', openidId: 'oid1', provider: 'openid' },
+ headers: { cookie: 'refreshToken=rt1' },
+ session: {
+ openidTokens: { refreshToken: 'srt', idToken: 'small-id-token' },
+ destroy: jest.fn(),
+ },
+ ...overrides,
+ };
+}
+
+function buildRes() {
+ const res = {
+ status: jest.fn().mockReturnThis(),
+ send: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ clearCookie: jest.fn(),
+ };
+ return res;
+}
+
+const ORIGINAL_ENV = process.env;
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ process.env = {
+ ...ORIGINAL_ENV,
+ OPENID_USE_END_SESSION_ENDPOINT: 'true',
+ OPENID_ISSUER: 'https://idp.example.com',
+ OPENID_CLIENT_ID: 'my-client-id',
+ DOMAIN_CLIENT: 'https://app.example.com',
+ };
+ cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' });
+ mockLogoutUser.mockResolvedValue({ status: 200, message: 'Logout successful' });
+ mockIsEnabled.mockReturnValue(true);
+ mockGetOpenIdConfig.mockReturnValue({
+ serverMetadata: () => ({
+ end_session_endpoint: 'https://idp.example.com/logout',
+ }),
+ });
+});
+
+afterAll(() => {
+ process.env = ORIGINAL_ENV;
+});
+
+describe('LogoutController', () => {
+ describe('id_token_hint from session', () => {
+ it('sets id_token_hint when session has idToken', async () => {
+ const req = buildReq();
+ const res = buildRes();
+
+ await logoutController(req, res);
+
+ const body = res.send.mock.calls[0][0];
+ expect(body.redirect).toContain('id_token_hint=small-id-token');
+ expect(body.redirect).not.toContain('client_id=');
+ });
+ });
+
+ describe('id_token_hint from cookie fallback', () => {
+ it('uses cookie id_token when session has no tokens', async () => {
+ cookies.parse.mockReturnValue({
+ refreshToken: 'cookie-rt',
+ openid_id_token: 'cookie-id-token',
+ });
+ const req = buildReq({ session: { 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=cookie-id-token');
+ });
+ });
+
+ describe('client_id fallback', () => {
+ it('falls back to client_id when no idToken is available', async () => {
+ cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' });
+ const req = buildReq({ session: { destroy: jest.fn() } });
+ const res = buildRes();
+
+ await logoutController(req, res);
+
+ const body = res.send.mock.calls[0][0];
+ expect(body.redirect).toContain('client_id=my-client-id');
+ expect(body.redirect).not.toContain('id_token_hint=');
+ });
+
+ it('does not produce client_id=undefined when OPENID_CLIENT_ID is unset', async () => {
+ delete process.env.OPENID_CLIENT_ID;
+ cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' });
+ const req = buildReq({ session: { destroy: jest.fn() } });
+ const res = buildRes();
+
+ await logoutController(req, res);
+
+ const body = res.send.mock.calls[0][0];
+ expect(body.redirect).not.toContain('client_id=');
+ expect(body.redirect).not.toContain('undefined');
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('Neither id_token_hint nor OPENID_CLIENT_ID'),
+ );
+ });
+ });
+
+ describe('OPENID_USE_END_SESSION_ENDPOINT disabled', () => {
+ it('does not include redirect when disabled', async () => {
+ mockIsEnabled.mockReturnValue(false);
+ const req = buildReq();
+ const res = buildRes();
+
+ await logoutController(req, res);
+
+ const body = res.send.mock.calls[0][0];
+ expect(body.redirect).toBeUndefined();
+ });
+ });
+
+ describe('OPENID_ISSUER unset', () => {
+ it('does not include redirect when OPENID_ISSUER is missing', async () => {
+ delete process.env.OPENID_ISSUER;
+ const req = buildReq();
+ const res = buildRes();
+
+ await logoutController(req, res);
+
+ const body = res.send.mock.calls[0][0];
+ expect(body.redirect).toBeUndefined();
+ });
+ });
+
+ describe('non-OpenID user', () => {
+ it('does not include redirect for non-OpenID users', async () => {
+ const req = buildReq({
+ user: { _id: 'user1', provider: 'local' },
+ });
+ const res = buildRes();
+
+ await logoutController(req, res);
+
+ const body = res.send.mock.calls[0][0];
+ expect(body.redirect).toBeUndefined();
+ });
+ });
+
+ describe('post_logout_redirect_uri', () => {
+ it('uses OPENID_POST_LOGOUT_REDIRECT_URI when set', async () => {
+ process.env.OPENID_POST_LOGOUT_REDIRECT_URI = 'https://custom.example.com/logged-out';
+ const req = buildReq();
+ const res = buildRes();
+
+ await logoutController(req, res);
+
+ const body = res.send.mock.calls[0][0];
+ const url = new URL(body.redirect);
+ expect(url.searchParams.get('post_logout_redirect_uri')).toBe(
+ 'https://custom.example.com/logged-out',
+ );
+ });
+
+ it('defaults to DOMAIN_CLIENT/login when OPENID_POST_LOGOUT_REDIRECT_URI is unset', async () => {
+ delete process.env.OPENID_POST_LOGOUT_REDIRECT_URI;
+ const req = buildReq();
+ const res = buildRes();
+
+ await logoutController(req, res);
+
+ const body = res.send.mock.calls[0][0];
+ const url = new URL(body.redirect);
+ expect(url.searchParams.get('post_logout_redirect_uri')).toBe(
+ 'https://app.example.com/login',
+ );
+ });
+ });
+
+ describe('OpenID config not available', () => {
+ it('warns and returns no redirect when getOpenIdConfig throws', async () => {
+ mockGetOpenIdConfig.mockImplementation(() => {
+ throw new Error('OpenID configuration has not been initialized');
+ });
+ const req = buildReq();
+ const res = buildRes();
+
+ await logoutController(req, res);
+
+ const body = res.send.mock.calls[0][0];
+ expect(body.redirect).toBeUndefined();
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('OpenID config not available'),
+ 'OpenID configuration has not been initialized',
+ );
+ });
+ });
+
+ describe('end_session_endpoint not in metadata', () => {
+ it('warns and returns no redirect when end_session_endpoint is missing', async () => {
+ mockGetOpenIdConfig.mockReturnValue({
+ serverMetadata: () => ({}),
+ });
+ const req = buildReq();
+ const res = buildRes();
+
+ await logoutController(req, res);
+
+ const body = res.send.mock.calls[0][0];
+ expect(body.redirect).toBeUndefined();
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('end_session_endpoint not found'),
+ );
+ });
+ });
+
+ describe('error handling', () => {
+ it('returns 500 on logoutUser error', async () => {
+ mockLogoutUser.mockRejectedValue(new Error('session error'));
+ const req = buildReq();
+ const res = buildRes();
+
+ await logoutController(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(500);
+ expect(res.json).toHaveBeenCalledWith({ message: 'session error' });
+ });
+ });
+
+ describe('cookie clearing', () => {
+ it('clears all auth cookies on successful logout', async () => {
+ const req = buildReq();
+ const res = buildRes();
+
+ await logoutController(req, res);
+
+ expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
+ expect(res.clearCookie).toHaveBeenCalledWith('openid_access_token');
+ expect(res.clearCookie).toHaveBeenCalledWith('openid_id_token');
+ expect(res.clearCookie).toHaveBeenCalledWith('openid_user_id');
+ expect(res.clearCookie).toHaveBeenCalledWith('token_provider');
+ });
+
+ it('calls clearCloudFrontCookies on successful logout', async () => {
+ const req = buildReq({ user: { _id: 'user1', tenantId: 'tenantA' } });
+ const res = buildRes();
+
+ await logoutController(req, res);
+
+ expect(mockClearCloudFrontCookies).toHaveBeenCalledWith(res, {
+ userId: 'user1',
+ tenantId: 'tenantA',
+ });
+ });
+ });
+
+ 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');
+ });
+ });
+});
diff --git a/api/server/controllers/auth/TwoFactorAuthController.js b/api/server/controllers/auth/TwoFactorAuthController.js
new file mode 100644
index 0000000..23cdd8b
--- /dev/null
+++ b/api/server/controllers/auth/TwoFactorAuthController.js
@@ -0,0 +1,61 @@
+const jwt = require('jsonwebtoken');
+const { logger } = require('@librechat/data-schemas');
+const {
+ verifyTOTP,
+ getTOTPSecret,
+ verifyBackupCode,
+} = require('~/server/services/twoFactorService');
+const { setAuthTokens } = require('~/server/services/AuthService');
+const { getUserById } = require('~/models');
+
+/**
+ * Verifies the 2FA code during login using a temporary token.
+ */
+const verify2FAWithTempToken = async (req, res) => {
+ try {
+ const { tempToken, token, backupCode } = req.body;
+ if (!tempToken) {
+ return res.status(400).json({ message: 'Missing temporary token' });
+ }
+
+ let payload;
+ try {
+ payload = jwt.verify(tempToken, process.env.JWT_SECRET);
+ } catch (err) {
+ logger.error('Failed to verify temporary token:', err);
+ return res.status(401).json({ message: 'Invalid or expired temporary token' });
+ }
+
+ const user = await getUserById(payload.userId, '+totpSecret +backupCodes');
+ if (!user || !user.twoFactorEnabled) {
+ return res.status(400).json({ message: '2FA is not enabled for this user' });
+ }
+
+ const secret = await getTOTPSecret(user.totpSecret);
+ let isVerified = false;
+ if (token) {
+ isVerified = await verifyTOTP(secret, token);
+ } else if (backupCode) {
+ isVerified = await verifyBackupCode({ user, backupCode });
+ }
+
+ if (!isVerified) {
+ return res.status(401).json({ message: 'Invalid 2FA code or backup code' });
+ }
+
+ const userData = user.toObject ? user.toObject() : { ...user };
+ delete userData.__v;
+ delete userData.password;
+ delete userData.totpSecret;
+ delete userData.backupCodes;
+ userData.id = user._id.toString();
+
+ const authToken = await setAuthTokens(user._id, res, null, req);
+ return res.status(200).json({ token: authToken, user: userData });
+ } catch (err) {
+ logger.error('[verify2FAWithTempToken]', err);
+ return res.status(500).json({ message: 'Something went wrong' });
+ }
+};
+
+module.exports = { verify2FAWithTempToken };
diff --git a/api/server/controllers/auth/oauth.js b/api/server/controllers/auth/oauth.js
new file mode 100644
index 0000000..cd6a88a
--- /dev/null
+++ b/api/server/controllers/auth/oauth.js
@@ -0,0 +1,92 @@
+const { CacheKeys } = require('librechat-data-provider');
+const { logger, DEFAULT_SESSION_EXPIRY } = require('@librechat/data-schemas');
+const {
+ isEnabled,
+ getAdminPanelUrl,
+ isAdminPanelRedirect,
+ generateAdminExchangeCode,
+} = require('@librechat/api');
+const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
+const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
+const getLogStores = require('~/cache/getLogStores');
+const { checkBan } = require('~/server/middleware');
+const { generateToken } = require('~/models');
+
+const domains = {
+ client: process.env.DOMAIN_CLIENT,
+ server: process.env.DOMAIN_SERVER,
+};
+
+function createOAuthHandler(redirectUri = domains.client) {
+ /**
+ * A handler to process OAuth authentication results.
+ * @type {Function}
+ * @param {ServerRequest} req - Express request object.
+ * @param {ServerResponse} res - Express response object.
+ * @param {NextFunction} next - Express next middleware function.
+ */
+ return async (req, res, next) => {
+ try {
+ if (res.headersSent) {
+ return;
+ }
+
+ await checkBan(req, res);
+ if (req.banned) {
+ return;
+ }
+
+ /** Check if this is an admin panel redirect (cross-origin or same-origin subpath) */
+ if (isAdminPanelRedirect(redirectUri, getAdminPanelUrl(), domains.client)) {
+ /** For admin panel, generate exchange code instead of setting cookies */
+ const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
+ const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY;
+ const token = await generateToken(req.user, sessionExpiry);
+
+ /** Get refresh token from tokenset for OpenID users */
+ const refreshToken =
+ req.user.provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) === true
+ ? req.user.tokenset?.refresh_token || req.user.federatedTokens?.refresh_token
+ : undefined;
+ const expiresAt = Date.now() + sessionExpiry;
+
+ const callbackUrl = new URL(redirectUri);
+ const exchangeCode = await generateAdminExchangeCode(
+ cache,
+ req.user,
+ token,
+ refreshToken,
+ callbackUrl.origin,
+ req.pkceChallenge,
+ expiresAt,
+ );
+ callbackUrl.searchParams.set('code', exchangeCode);
+ logger.info(`[OAuth] Admin panel redirect with exchange code for user: ${req.user.email}`);
+ return res.redirect(callbackUrl.toString());
+ }
+
+ /** Standard OAuth flow - set cookies and redirect */
+ if (
+ req.user &&
+ req.user.provider == 'openid' &&
+ isEnabled(process.env.OPENID_REUSE_TOKENS) === true
+ ) {
+ await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
+ setOpenIDAuthTokens(req.user.tokenset, req, res, {
+ userId: req.user._id.toString(),
+ tenantId: req.user.tenantId,
+ });
+ } else {
+ await setAuthTokens(req.user._id, res, null, req);
+ }
+ res.redirect(redirectUri);
+ } catch (err) {
+ logger.error('Error in setting authentication tokens:', err);
+ next(err);
+ }
+ };
+}
+
+module.exports = {
+ createOAuthHandler,
+};
diff --git a/api/server/controllers/auth/oauth.spec.js b/api/server/controllers/auth/oauth.spec.js
new file mode 100644
index 0000000..4a20442
--- /dev/null
+++ b/api/server/controllers/auth/oauth.spec.js
@@ -0,0 +1,151 @@
+const mockIsEnabled = jest.fn();
+const mockGetAdminPanelUrl = jest.fn();
+const mockIsAdminPanelRedirect = jest.fn();
+const mockGenerateAdminExchangeCode = jest.fn();
+const mockSyncUserEntraGroupMemberships = jest.fn();
+const mockSetAuthTokens = jest.fn();
+const mockSetOpenIDAuthTokens = jest.fn();
+const mockGetLogStores = jest.fn();
+const mockCheckBan = jest.fn();
+const mockGenerateToken = jest.fn();
+const mockLogger = { info: jest.fn(), error: jest.fn() };
+
+jest.mock('librechat-data-provider', () => ({
+ CacheKeys: { ADMIN_OAUTH_EXCHANGE: 'admin-oauth-exchange' },
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: mockLogger,
+ DEFAULT_SESSION_EXPIRY: 60000,
+}));
+
+jest.mock('@librechat/api', () => ({
+ isEnabled: (...args) => mockIsEnabled(...args),
+ getAdminPanelUrl: (...args) => mockGetAdminPanelUrl(...args),
+ isAdminPanelRedirect: (...args) => mockIsAdminPanelRedirect(...args),
+ generateAdminExchangeCode: (...args) => mockGenerateAdminExchangeCode(...args),
+}));
+
+jest.mock('~/server/services/PermissionService', () => ({
+ syncUserEntraGroupMemberships: (...args) => mockSyncUserEntraGroupMemberships(...args),
+}));
+
+jest.mock('~/server/services/AuthService', () => ({
+ setAuthTokens: (...args) => mockSetAuthTokens(...args),
+ setOpenIDAuthTokens: (...args) => mockSetOpenIDAuthTokens(...args),
+}));
+
+jest.mock(
+ '~/cache/getLogStores',
+ () =>
+ (...args) =>
+ mockGetLogStores(...args),
+);
+
+jest.mock('~/server/middleware', () => ({
+ checkBan: (...args) => mockCheckBan(...args),
+}));
+
+jest.mock('~/models', () => ({
+ generateToken: (...args) => mockGenerateToken(...args),
+}));
+
+const { createOAuthHandler } = require('./oauth');
+
+const ORIGINAL_ENV = process.env;
+
+function buildReq(overrides = {}) {
+ return {
+ user: {
+ _id: 'user-123',
+ email: 'admin@example.com',
+ provider: 'openid',
+ tokenset: { refresh_token: 'openid-refresh-token', access_token: 'openid-access-token' },
+ federatedTokens: { refresh_token: 'federated-refresh-token' },
+ },
+ pkceChallenge: 'pkce-challenge',
+ banned: false,
+ ...overrides,
+ };
+}
+
+function buildRes() {
+ return {
+ headersSent: false,
+ redirect: jest.fn(),
+ };
+}
+
+describe('createOAuthHandler', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ process.env = {
+ ...ORIGINAL_ENV,
+ DOMAIN_CLIENT: 'http://localhost:3080',
+ DOMAIN_SERVER: 'http://localhost:3080',
+ OPENID_REUSE_TOKENS: 'false',
+ };
+ mockIsEnabled.mockImplementation((value) => value === 'true' || value === true);
+ mockGetAdminPanelUrl.mockReturnValue('http://admin.example.com');
+ mockIsAdminPanelRedirect.mockReturnValue(true);
+ mockGetLogStores.mockReturnValue({});
+ mockCheckBan.mockResolvedValue(undefined);
+ mockGenerateToken.mockResolvedValue('jwt-token');
+ mockGenerateAdminExchangeCode.mockResolvedValue('exchange-code');
+ });
+
+ afterAll(() => {
+ process.env = ORIGINAL_ENV;
+ });
+
+ it('omits refresh token from admin exchange when OPENID_REUSE_TOKENS is disabled', async () => {
+ const handler = createOAuthHandler('http://admin.example.com/auth/openid/callback');
+ const req = buildReq();
+ const res = buildRes();
+ const next = jest.fn();
+
+ await handler(req, res, next);
+
+ expect(mockGenerateAdminExchangeCode).toHaveBeenCalledWith(
+ {},
+ req.user,
+ 'jwt-token',
+ undefined,
+ 'http://admin.example.com',
+ 'pkce-challenge',
+ expect.any(Number),
+ );
+ expect(res.redirect).toHaveBeenCalledWith(
+ 'http://admin.example.com/auth/openid/callback?code=exchange-code',
+ );
+ expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled();
+ expect(mockSetAuthTokens).not.toHaveBeenCalled();
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('includes refresh token in admin exchange when OPENID_REUSE_TOKENS is enabled', async () => {
+ process.env.OPENID_REUSE_TOKENS = 'true';
+ const handler = createOAuthHandler('http://admin.example.com/auth/openid/callback');
+ const req = buildReq();
+ const res = buildRes();
+ const next = jest.fn();
+
+ await handler(req, res, next);
+
+ expect(mockGenerateAdminExchangeCode).toHaveBeenCalledWith(
+ {},
+ req.user,
+ 'jwt-token',
+ 'openid-refresh-token',
+ 'http://admin.example.com',
+ 'pkce-challenge',
+ expect.any(Number),
+ );
+ expect(res.redirect).toHaveBeenCalledWith(
+ 'http://admin.example.com/auth/openid/callback?code=exchange-code',
+ );
+ expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled();
+ expect(mockSetAuthTokens).not.toHaveBeenCalled();
+ expect(next).not.toHaveBeenCalled();
+ });
+});
diff --git a/api/server/controllers/mcp.js b/api/server/controllers/mcp.js
new file mode 100644
index 0000000..c3d3fc4
--- /dev/null
+++ b/api/server/controllers/mcp.js
@@ -0,0 +1,504 @@
+/**
+ * MCP Tools Controller
+ * Handles MCP-specific tool endpoints, decoupled from regular LibreChat tools
+ *
+ * @import { MCPServerRegistry } from '@librechat/api'
+ * @import { MCPServerDocument } from 'librechat-data-provider'
+ */
+const { logger, SystemCapabilities } = require('@librechat/data-schemas');
+const {
+ checkAccess,
+ isUserSourced,
+ MCPErrorCodes,
+ redactServerSecrets,
+ redactAllServerSecrets,
+ isMCPDomainNotAllowedError,
+ isMCPInspectionFailedError,
+} = require('@librechat/api');
+const {
+ Constants,
+ Permissions,
+ ResourceType,
+ PermissionBits,
+ PermissionTypes,
+ MCP_USER_INPUT_FIELDS,
+ MCPServerUserInputSchema,
+} = require('librechat-data-provider');
+const {
+ resolveConfigServers,
+ resolveMcpConfigNames,
+ resolveAllMcpConfigs,
+} = require('~/server/services/MCP');
+const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
+const { getResourcePermissionsMap } = require('~/server/services/PermissionService');
+const { hasCapability } = require('~/server/middleware/roles/capabilities');
+const { getMCPManager, getMCPServersRegistry } = require('~/config');
+const db = require('~/models');
+
+/**
+ * Handles MCP-specific errors and sends appropriate HTTP responses.
+ * @param {Error} error - The error to handle
+ * @param {import('express').Response} res - Express response object
+ * @returns {import('express').Response | null} Response if handled, null if not an MCP error
+ */
+function handleMCPError(error, res) {
+ if (isMCPDomainNotAllowedError(error)) {
+ return res.status(error.statusCode).json({
+ error: error.code,
+ message: error.message,
+ });
+ }
+
+ if (isMCPInspectionFailedError(error)) {
+ return res.status(error.statusCode).json({
+ error: error.code,
+ message: error.message,
+ });
+ }
+
+ // Fallback for legacy string-based error handling (backwards compatibility)
+ if (error.message?.startsWith(MCPErrorCodes.DOMAIN_NOT_ALLOWED)) {
+ return res.status(403).json({
+ error: MCPErrorCodes.DOMAIN_NOT_ALLOWED,
+ message: error.message.replace(/^MCP_DOMAIN_NOT_ALLOWED\s*:\s*/i, ''),
+ });
+ }
+
+ if (error.message?.startsWith(MCPErrorCodes.INSPECTION_FAILED)) {
+ return res.status(400).json({
+ error: MCPErrorCodes.INSPECTION_FAILED,
+ message: error.message,
+ });
+ }
+
+ return null;
+}
+
+/**
+ * Get all MCP tools available to the user.
+ */
+const getMCPTools = async (req, res) => {
+ try {
+ const userId = req.user?.id;
+ if (!userId) {
+ logger.warn('[getMCPTools] User ID not found in request');
+ return res.status(401).json({ message: 'Unauthorized' });
+ }
+
+ const mcpConfig = await resolveAllMcpConfigs(userId, req.user);
+ const configuredServers = Object.keys(mcpConfig);
+
+ if (!configuredServers.length) {
+ return res.status(200).json({ servers: {} });
+ }
+
+ const mcpManager = getMCPManager();
+ const mcpServers = {};
+
+ const serverToolsMap = new Map();
+ const cacheResults = await Promise.all(
+ configuredServers.map(async (serverName) => {
+ try {
+ return {
+ serverName,
+ tools: await getMCPServerTools(userId, serverName, mcpConfig[serverName]),
+ };
+ } catch (error) {
+ logger.error(`[getMCPTools] Error fetching cached tools for ${serverName}:`, error);
+ return { serverName, tools: null };
+ }
+ }),
+ );
+ for (const { serverName, tools } of cacheResults) {
+ if (tools) {
+ serverToolsMap.set(serverName, tools);
+ continue;
+ }
+
+ let serverTools;
+ try {
+ serverTools = await mcpManager.getServerToolFunctions(userId, serverName);
+ } catch (error) {
+ logger.error(`[getMCPTools] Error fetching tools for server ${serverName}:`, error);
+ continue;
+ }
+ if (!serverTools) {
+ logger.debug(`[getMCPTools] No tools found for server ${serverName}`);
+ continue;
+ }
+ serverToolsMap.set(serverName, serverTools);
+
+ if (Object.keys(serverTools).length > 0) {
+ // Cache asynchronously without blocking
+ cacheMCPServerTools({
+ userId,
+ serverName,
+ serverTools,
+ serverConfig: mcpConfig[serverName],
+ }).catch((err) =>
+ logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err),
+ );
+ }
+ }
+
+ // Process each configured server
+ for (const serverName of configuredServers) {
+ try {
+ const serverTools = serverToolsMap.get(serverName);
+
+ const serverConfig = mcpConfig[serverName];
+
+ const server = {
+ name: serverName,
+ icon: serverConfig?.iconPath || '',
+ authenticated: true,
+ authConfig: [],
+ tools: [],
+ };
+
+ // Set authentication config once for the server
+ if (serverConfig?.customUserVars) {
+ const customVarKeys = Object.keys(serverConfig.customUserVars);
+ if (customVarKeys.length > 0) {
+ server.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
+ authField: key,
+ label: value.title || key,
+ description: value.description || '',
+ sensitive: value.sensitive,
+ }));
+ server.authenticated = false;
+ }
+ }
+
+ // Process tools efficiently - no need for convertMCPToolToPlugin
+ if (serverTools) {
+ for (const [toolKey, toolData] of Object.entries(serverTools)) {
+ if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
+ continue;
+ }
+
+ const toolName = toolKey.split(Constants.mcp_delimiter)[0];
+ server.tools.push({
+ name: toolName,
+ pluginKey: toolKey,
+ description: toolData.function.description || '',
+ });
+ }
+ }
+
+ // Only add server if it has tools or is configured
+ if (server.tools.length > 0 || serverConfig) {
+ mcpServers[serverName] = server;
+ }
+ } catch (error) {
+ logger.error(`[getMCPTools] Error loading tools for server ${serverName}:`, error);
+ }
+ }
+
+ res.status(200).json({ servers: mcpServers });
+ } catch (error) {
+ logger.error('[getMCPTools]', error);
+ res.status(500).json({ message: error.message });
+ }
+};
+/**
+ * Mirrors canAccessResource's capability bypass plus per-resource ACL EDIT check.
+ * `skipCapabilityWithoutDbIds` lets the list path skip the MANAGE_MCP_SERVERS probe
+ * when no DB-backed server is present; no list consumer reads the edit-gated fields
+ * the bypass would disclose. The detail route must not set it.
+ */
+async function computeCanEditByServer(req, serverConfigs, { skipCapabilityWithoutDbIds } = {}) {
+ const canEditByServer = new Map();
+ const dbIdsToCheck = [];
+ const dbIdToServerName = new Map();
+ for (const [name, config] of Object.entries(serverConfigs)) {
+ if (config.dbId) {
+ dbIdsToCheck.push(config.dbId);
+ dbIdToServerName.set(String(config.dbId), name);
+ continue;
+ }
+ canEditByServer.set(name, isUserSourced(config));
+ }
+ if (skipCapabilityWithoutDbIds === true && dbIdsToCheck.length === 0) {
+ return canEditByServer;
+ }
+ let bypass = false;
+ try {
+ bypass = await hasCapability(req.user, SystemCapabilities.MANAGE_MCP_SERVERS);
+ } catch (err) {
+ logger.warn(`[computeCanEditByServer] Capability bypass check failed: ${err.message}`);
+ }
+ if (bypass) {
+ for (const name of Object.keys(serverConfigs)) {
+ canEditByServer.set(name, true);
+ }
+ return canEditByServer;
+ }
+ if (dbIdsToCheck.length > 0) {
+ try {
+ const permsMap = await getResourcePermissionsMap({
+ userId: req.user.id,
+ role: req.user.role,
+ resourceType: ResourceType.MCPSERVER,
+ resourceIds: dbIdsToCheck,
+ });
+ for (const [dbIdStr, name] of dbIdToServerName) {
+ const bits = permsMap.get(dbIdStr) ?? 0;
+ canEditByServer.set(name, (bits & PermissionBits.EDIT) !== 0);
+ }
+ } catch (err) {
+ logger.warn(
+ `[computeCanEditByServer] ACL lookup failed, defaulting to no edit: ${err.message}`,
+ );
+ for (const name of dbIdToServerName.values()) {
+ canEditByServer.set(name, false);
+ }
+ }
+ }
+ return canEditByServer;
+}
+
+/**
+ * Get all MCP servers with permissions
+ * @route GET /api/mcp/servers
+ */
+const getMCPServersList = async (req, res) => {
+ try {
+ const userId = req.user?.id;
+ if (!userId) {
+ return res.status(401).json({ message: 'Unauthorized' });
+ }
+
+ const serverConfigs = await resolveAllMcpConfigs(userId, req.user);
+ const canEditByServer = await computeCanEditByServer(req, serverConfigs, {
+ skipCapabilityWithoutDbIds: true,
+ });
+ return res.json(redactAllServerSecrets(serverConfigs, { canEditByServer }));
+ } catch (error) {
+ logger.error('[getMCPServersList]', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Returns true when the request body's parsed config configures OBO. We block
+ * non-permission holders from creating or updating any DB-stored MCP server
+ * that mints per-user delegated tokens.
+ */
+function configHasObo(parsedConfig) {
+ return (
+ !!parsedConfig &&
+ typeof parsedConfig === 'object' &&
+ 'obo' in parsedConfig &&
+ parsedConfig.obo != null
+ );
+}
+
+/**
+ * Fields a user without `CONFIGURE_OBO` may modify on an OBO server (allowlist).
+ * Any field not on this list is locked: changes to it (add, modify, or remove)
+ * require the permission. Allowlisting is fail-closed — when upstream introduces
+ * a new MCP server config field, it lands in the locked set by default until
+ * explicitly opted in here. Anything that could redirect the OBO token flow
+ * (`url`, `proxy`, `headers`), change scopes (`obo`), or reroute auth (`oauth`,
+ * `apiKey`, `customUserVars`) MUST stay locked.
+ */
+const OBO_USER_EDITABLE_FIELDS = new Set(['title', 'description', 'iconPath']);
+
+/**
+ * Returns true when any non-allowlisted user-input field differs between the
+ * existing server config and the new payload. Treats add, remove, and modify
+ * as changes (stable JSON compare, with absence on either side counting as a
+ * change unless both sides are absent). The comparison surface is
+ * `MCP_USER_INPUT_FIELDS` (schema-derived from `MCPServerUserInputSchema`),
+ * so new fields on the schema are picked up automatically and stay locked
+ * by default until added to the allowlist above.
+ */
+function violatesOboLockdown(existingConfig, newConfig) {
+ for (const field of MCP_USER_INPUT_FIELDS) {
+ if (OBO_USER_EDITABLE_FIELDS.has(field)) continue;
+ const existing = existingConfig?.[field];
+ const next = newConfig?.[field];
+ if (existing === undefined && next === undefined) continue;
+ if (JSON.stringify(existing) !== JSON.stringify(next)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+async function callerCanConfigureObo(req) {
+ return checkAccess({
+ req,
+ user: req.user,
+ permissionType: PermissionTypes.MCP_SERVERS,
+ permissions: [Permissions.CONFIGURE_OBO],
+ getRoleByName: db.getRoleByName,
+ });
+}
+
+/**
+ * Create MCP server
+ * @route POST /api/mcp/servers
+ */
+const createMCPServerController = async (req, res) => {
+ try {
+ const userId = req.user?.id;
+ const { config } = req.body;
+
+ const validation = MCPServerUserInputSchema.safeParse(config);
+ if (!validation.success) {
+ return res.status(400).json({
+ message: 'Invalid configuration',
+ errors: validation.error.errors,
+ });
+ }
+ if (configHasObo(validation.data) && !(await callerCanConfigureObo(req))) {
+ logger.warn(
+ `[createMCPServer] User ${userId} attempted to configure OBO without ${Permissions.CONFIGURE_OBO} permission`,
+ );
+ return res
+ .status(403)
+ .json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
+ }
+ const reservedServerNames = await resolveMcpConfigNames(req);
+ const result = await getMCPServersRegistry().addServer(
+ 'temp_server_name',
+ validation.data,
+ 'DB',
+ userId,
+ reservedServerNames,
+ );
+ res.status(201).json({
+ serverName: result.serverName,
+ ...redactServerSecrets(result.config, { canEdit: true }),
+ });
+ } catch (error) {
+ logger.error('[createMCPServer]', error);
+ const mcpErrorResponse = handleMCPError(error, res);
+ if (mcpErrorResponse) {
+ return mcpErrorResponse;
+ }
+ res.status(500).json({ message: error.message });
+ }
+};
+
+/**
+ * Get MCP server by ID
+ */
+const getMCPServerById = async (req, res) => {
+ try {
+ const userId = req.user?.id;
+ const { serverName } = req.params;
+ if (!serverName) {
+ return res.status(400).json({ message: 'Server name is required' });
+ }
+ 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' });
+ }
+
+ const canEditMap = await computeCanEditByServer(req, { [serverName]: parsedConfig });
+ const canEdit = canEditMap.get(serverName) ?? false;
+ res.status(200).json(redactServerSecrets(parsedConfig, { canEdit }));
+ } catch (error) {
+ logger.error('[getMCPServerById]', error);
+ res.status(500).json({ message: error.message });
+ }
+};
+
+/**
+ * Update MCP server
+ * @route PATCH /api/mcp/servers/:serverName
+ */
+const updateMCPServerController = async (req, res) => {
+ try {
+ const userId = req.user?.id;
+ const { serverName } = req.params;
+ const { config } = req.body;
+
+ const validation = MCPServerUserInputSchema.safeParse(config);
+ if (!validation.success) {
+ return res.status(400).json({
+ message: 'Invalid configuration',
+ errors: validation.error.errors,
+ });
+ }
+
+ /**
+ * On an existing OBO server, lock down every user-input field except the
+ * cosmetic allowlist (title, description, iconPath) for callers without
+ * CONFIGURE_OBO. This closes the OBO redirect vector — without it, a user
+ * with UPDATE could change `url` (or `proxy`/`headers`/`customUserVars`)
+ * to point OBO-minted tokens at an attacker-controlled endpoint. Adds,
+ * modifies, and removes are all caught.
+ */
+ const existingConfig = await getMCPServersRegistry().getServerConfig(serverName, userId);
+ if (configHasObo(existingConfig) && !(await callerCanConfigureObo(req))) {
+ if (violatesOboLockdown(existingConfig, validation.data)) {
+ logger.warn(
+ `[updateMCPServer] User ${userId} attempted to modify a locked field on OBO server '${serverName}' without ${Permissions.CONFIGURE_OBO} permission`,
+ );
+ return res
+ .status(403)
+ .json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
+ }
+ } else if (configHasObo(validation.data) && !(await callerCanConfigureObo(req))) {
+ // Adding OBO to a non-OBO server (or first-time configuration) still
+ // requires the permission, even if existing has no OBO.
+ logger.warn(
+ `[updateMCPServer] User ${userId} attempted to add OBO to '${serverName}' without ${Permissions.CONFIGURE_OBO} permission`,
+ );
+ return res
+ .status(403)
+ .json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
+ }
+
+ const parsedConfig = await getMCPServersRegistry().updateServer(
+ serverName,
+ validation.data,
+ 'DB',
+ userId,
+ );
+
+ res.status(200).json(redactServerSecrets(parsedConfig, { canEdit: true }));
+ } catch (error) {
+ logger.error('[updateMCPServer]', error);
+ const mcpErrorResponse = handleMCPError(error, res);
+ if (mcpErrorResponse) {
+ return mcpErrorResponse;
+ }
+ res.status(500).json({ message: error.message });
+ }
+};
+
+/**
+ * Delete MCP server
+ * @route DELETE /api/mcp/servers/:serverName
+ */
+const deleteMCPServerController = async (req, res) => {
+ try {
+ const userId = req.user?.id;
+ const { serverName } = req.params;
+ await getMCPServersRegistry().removeServer(serverName, 'DB', userId);
+ res.status(200).json({ message: 'MCP server deleted successfully' });
+ } catch (error) {
+ logger.error('[deleteMCPServer]', error);
+ res.status(500).json({ message: error.message });
+ }
+};
+
+module.exports = {
+ getMCPTools,
+ getMCPServersList,
+ createMCPServerController,
+ getMCPServerById,
+ updateMCPServerController,
+ deleteMCPServerController,
+};
diff --git a/api/server/controllers/tools.js b/api/server/controllers/tools.js
new file mode 100644
index 0000000..4551adf
--- /dev/null
+++ b/api/server/controllers/tools.js
@@ -0,0 +1,259 @@
+const { nanoid } = require('nanoid');
+const { logger } = require('@librechat/data-schemas');
+const { checkAccess, loadWebSearchAuth } = require('@librechat/api');
+const {
+ Tools,
+ AuthType,
+ Permissions,
+ ToolCallTypes,
+ PermissionTypes,
+} = require('librechat-data-provider');
+const { getRoleByName, createToolCall, getToolCallsByConvo, getMessage } = require('~/models');
+const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
+const { getRetentionExpiry } = require('~/server/services/Files/retention');
+const { processCodeOutput, runPreviewFinalize } = require('~/server/services/Files/Code/process');
+const { loadAuthValues } = require('~/server/services/Tools/credentials');
+const { loadTools } = require('~/app/clients/tools/util');
+
+/**
+ * Tools that are callable directly via `POST /tools/:toolId/call`.
+ * `execute_code` is the only entry today; the tool runs server-side via
+ * the agents library / sandbox service without any per-user credential.
+ */
+const directCallableTools = new Set([Tools.execute_code]);
+
+const toolAccessPermType = {
+ [Tools.execute_code]: PermissionTypes.RUN_CODE,
+};
+
+/**
+ * Verifies web search authentication, ensuring each category has at least
+ * one fully authenticated service.
+ *
+ * @param {ServerRequest} req - The request object
+ * @param {ServerResponse} res - The response object
+ * @returns {Promise} A promise that resolves when the function has completed
+ */
+const verifyWebSearchAuth = async (req, res) => {
+ try {
+ const appConfig = req.config;
+ const userId = req.user.id;
+ /** @type {TCustomConfig['webSearch']} */
+ const webSearchConfig = appConfig?.webSearch || {};
+ const result = await loadWebSearchAuth({
+ userId,
+ loadAuthValues,
+ webSearchConfig,
+ throwError: false,
+ });
+
+ return res.status(200).json({
+ authenticated: result.authenticated,
+ authTypes: result.authTypes,
+ });
+ } catch (error) {
+ console.error('Error in verifyWebSearchAuth:', error);
+ return res.status(500).json({ message: error.message });
+ }
+};
+
+/**
+ * @param {ServerRequest} req - The request object, containing information about the HTTP request.
+ * @param {ServerResponse} res - The response object, used to send back the desired HTTP response.
+ * @returns {Promise} A promise that resolves when the function has completed.
+ */
+const verifyToolAuth = async (req, res) => {
+ try {
+ const { toolId } = req.params;
+ if (toolId === Tools.web_search) {
+ return await verifyWebSearchAuth(req, res);
+ }
+ if (!directCallableTools.has(toolId)) {
+ res.status(404).json({ message: 'Tool not found' });
+ return;
+ }
+ /**
+ * `execute_code` no longer requires a per-user credential — sandbox
+ * auth is handled server-side by the agents library. Always report
+ * system-authenticated so the client proceeds straight to the call
+ * without a key-entry dialog.
+ *
+ * Deployment contract: reachability of the sandbox service is the
+ * admin's responsibility. This endpoint does not probe the service
+ * (a per-auth-check network hop would be too expensive for what is
+ * a UI-gate query). If the sandbox is unreachable, the call path
+ * surfaces the error at execution time instead of here.
+ */
+ res.status(200).json({ authenticated: true, message: AuthType.SYSTEM_DEFINED });
+ } catch (error) {
+ res.status(500).json({ message: error.message });
+ }
+};
+
+/**
+ * @param {ServerRequest} req - The request object, containing information about the HTTP request.
+ * @param {ServerResponse} res - The response object, used to send back the desired HTTP response.
+ * @param {NextFunction} next - The next middleware function to call.
+ * @returns {Promise} A promise that resolves when the function has completed.
+ */
+const callTool = async (req, res) => {
+ try {
+ const appConfig = req.config;
+ const { toolId = '' } = req.params;
+ if (!directCallableTools.has(toolId)) {
+ logger.warn(`[${toolId}/call] User ${req.user.id} attempted call to invalid tool`);
+ res.status(404).json({ message: 'Tool not found' });
+ return;
+ }
+
+ const { partIndex, blockIndex, messageId, conversationId, ...args } = req.body;
+ if (!messageId) {
+ logger.warn(`[${toolId}/call] User ${req.user.id} attempted call without message ID`);
+ res.status(400).json({ message: 'Message ID required' });
+ return;
+ }
+
+ const message = await getMessage({ user: req.user.id, messageId });
+ if (!message) {
+ logger.debug(`[${toolId}/call] User ${req.user.id} attempted call with invalid message ID`);
+ res.status(404).json({ message: 'Message not found' });
+ return;
+ }
+ logger.debug(`[${toolId}/call] User: ${req.user.id}`);
+ let hasAccess = true;
+ if (toolAccessPermType[toolId]) {
+ hasAccess = await checkAccess({
+ user: req.user,
+ permissionType: toolAccessPermType[toolId],
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ }
+ if (!hasAccess) {
+ logger.warn(
+ `[${toolAccessPermType[toolId]}] Forbidden: Insufficient permissions for User ${req.user.id}: ${Permissions.USE}`,
+ );
+ return res.status(403).json({ message: 'Forbidden: Insufficient permissions' });
+ }
+ const { loadedTools } = await loadTools({
+ user: req.user.id,
+ tools: [toolId],
+ functions: true,
+ options: {
+ req,
+ returnMetadata: true,
+ processFileURL,
+ uploadImageBuffer,
+ },
+ webSearch: appConfig.webSearch,
+ fileStrategy: appConfig.fileStrategy,
+ imageOutputType: appConfig.imageOutputType,
+ });
+
+ const tool = loadedTools[0];
+ const toolCallId = `${req.user.id}_${nanoid()}`;
+ const result = await tool.invoke({
+ args,
+ name: toolId,
+ id: toolCallId,
+ type: ToolCallTypes.TOOL_CALL,
+ });
+
+ const { content, artifact } = result;
+ const toolCallData = {
+ toolId,
+ messageId,
+ partIndex,
+ blockIndex,
+ conversationId,
+ result: content,
+ user: req.user.id,
+ ...(await getRetentionExpiry(req)),
+ };
+
+ if (!artifact || !artifact.files || toolId !== Tools.execute_code) {
+ createToolCall(toolCallData).catch((error) => {
+ logger.error(`Error creating tool call: ${error.message}`);
+ });
+ return res.status(200).json({
+ result: content,
+ });
+ }
+
+ const artifactPromises = [];
+ for (const file of artifact.files) {
+ /* Files flagged `inherited` by codeapi are unchanged passthroughs of
+ * inputs the caller already owns (skill files, prior downloaded inputs,
+ * inherited .dirkeep markers). Re-downloading them is wasted work and
+ * 403s when the file is scoped to a different entity (e.g. skill
+ * entity_id) than the user's session key. They remain available for
+ * subsequent tool calls via primeInvokedSkills / session inheritance. */
+ if (file.inherited) {
+ continue;
+ }
+ const { id, name } = file;
+ artifactPromises.push(
+ (async () => {
+ const result = await processCodeOutput({
+ req,
+ id,
+ name,
+ messageId,
+ toolCallId,
+ conversationId,
+ session_id: artifact.session_id,
+ });
+ const fileMetadata = result?.file ?? null;
+ const finalize = result?.finalize;
+ if (!fileMetadata) {
+ return null;
+ }
+ /* This endpoint is non-streaming and its contract is "give
+ * me the artifacts" — return the persisted record immediately
+ * (with `status: 'pending'` for office buckets) and run the
+ * preview render in the background. The client polls
+ * `/api/files/:file_id/preview` for the resolved record.
+ * No `onResolved` — there's no live stream to write to here. */
+ runPreviewFinalize({
+ finalize,
+ fileId: fileMetadata.file_id,
+ previewRevision: result?.previewRevision,
+ });
+ return fileMetadata;
+ })().catch((error) => {
+ logger.error('Error processing code output:', error);
+ return null;
+ }),
+ );
+ }
+ const attachments = await Promise.all(artifactPromises);
+ toolCallData.attachments = attachments;
+ createToolCall(toolCallData).catch((error) => {
+ logger.error(`Error creating tool call: ${error.message}`);
+ });
+ res.status(200).json({
+ result: content,
+ attachments,
+ });
+ } catch (error) {
+ logger.error('Error calling tool', error);
+ res.status(500).json({ message: 'Error calling tool' });
+ }
+};
+
+const getToolCalls = async (req, res) => {
+ try {
+ const { conversationId } = req.query;
+ const toolCalls = await getToolCallsByConvo(conversationId, req.user.id);
+ res.status(200).json(toolCalls);
+ } catch (error) {
+ logger.error('Error getting tool calls', error);
+ res.status(500).json({ message: 'Error getting tool calls' });
+ }
+};
+
+module.exports = {
+ callTool,
+ getToolCalls,
+ verifyToolAuth,
+};
diff --git a/api/server/experimental.js b/api/server/experimental.js
new file mode 100644
index 0000000..e670dd0
--- /dev/null
+++ b/api/server/experimental.js
@@ -0,0 +1,586 @@
+require('dotenv').config();
+const fs = require('fs');
+const path = require('path');
+require('module-alias')({ base: path.resolve(__dirname, '..') });
+const cluster = require('cluster');
+const Redis = require('ioredis');
+const cors = require('cors');
+const axios = require('axios');
+const express = require('express');
+const passport = require('passport');
+const compression = require('compression');
+const cookieParser = require('cookie-parser');
+const { logger, runAsSystem, tenantStorage } = require('@librechat/data-schemas');
+const mongoSanitize = require('express-mongo-sanitize');
+const {
+ isEnabled,
+ apiNotFound,
+ ErrorController,
+ GenerationJobManager,
+ QUERY_DEVTOOLS_HEADER,
+ performStartupChecks,
+ handleJsonParseError,
+ deleteAgentCheckpoint,
+ initializeFileStorage,
+ loadToolApprovalHooks,
+ maybeInjectQueryDevtoolsBootstrap,
+ preAuthTenantMiddleware,
+} = require('@librechat/api');
+const { connectDb, indexSync } = require('~/db');
+const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
+const { capabilityContextMiddleware } = require('./middleware/roles/capabilities');
+const createValidateImageRequest = require('./middleware/validateImageRequest');
+const { startExpiredFileSweep } = require('./services/Files/process');
+const { initializeGitHubSkillSync } = require('./services/Skills/sync');
+const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
+const { updateInterfacePermissions: updateInterfacePerms } = require('@librechat/api');
+const {
+ getRoleByName,
+ updateAccessPermissions,
+ seedDatabase,
+ sweepOrphanedPreviews,
+} = require('~/models');
+const { checkMigrations } = require('./services/start/migration');
+const initializeMCPs = require('./services/initializeMCPs');
+const configureSocialLogins = require('./socialLogins');
+const createSpaFallback = require('./utils/fallback');
+const { getAppConfig } = require('./services/Config');
+const staticCache = require('./utils/staticCache');
+const optionalJwtAuth = require('./middleware/optionalJwtAuth');
+const noIndex = require('./middleware/noIndex');
+const routes = require('./routes');
+
+const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
+
+/** Allow PORT=0 to be used for automatic free port assignment */
+const port = isNaN(Number(PORT)) ? 3080 : Number(PORT);
+const host = HOST || 'localhost';
+const trusted_proxy = Number(TRUST_PROXY) || 1;
+
+/** Number of worker processes to spawn (simulating multiple pods) */
+const workers = Number(process.env.CLUSTER_WORKERS) || 4;
+
+/** Helper to wrap log messages for better visibility */
+const wrapLogMessage = (msg) => {
+ return `\n${'='.repeat(50)}\n${msg}\n${'='.repeat(50)}`;
+};
+
+/**
+ * Flushes the Redis cache on startup
+ * This ensures a clean state for testing multi-pod MCP connection issues
+ */
+const flushRedisCache = async () => {
+ /** Skip cache flush if Redis is not enabled */
+ if (!isEnabled(process.env.USE_REDIS)) {
+ logger.info('Redis is not enabled, skipping cache flush');
+ return;
+ }
+
+ const redisConfig = {
+ host: process.env.REDIS_HOST || 'localhost',
+ port: process.env.REDIS_PORT || 6379,
+ };
+
+ if (process.env.REDIS_PASSWORD) {
+ redisConfig.password = process.env.REDIS_PASSWORD;
+ }
+
+ /** Handle Redis Cluster configuration */
+ if (isEnabled(process.env.USE_REDIS_CLUSTER) || process.env.REDIS_URI?.includes(',')) {
+ logger.info('Detected Redis Cluster configuration');
+ const uris = process.env.REDIS_URI?.split(',').map((uri) => {
+ const url = new URL(uri.trim());
+ return {
+ host: url.hostname,
+ port: parseInt(url.port || '6379', 10),
+ };
+ });
+ const redis = new Redis.Cluster(uris, {
+ redisOptions: {
+ password: process.env.REDIS_PASSWORD,
+ },
+ });
+
+ try {
+ logger.info('Attempting to connect to Redis Cluster...');
+ await redis.ping();
+ logger.info('Connected to Redis Cluster. Executing flushall...');
+ const result = await Promise.race([
+ redis.flushall(),
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Flush timeout')), 10000)),
+ ]);
+ logger.info('Redis Cluster cache flushed successfully', { result });
+ } catch (err) {
+ logger.error('Error while flushing Redis Cluster cache:', err);
+ throw err;
+ } finally {
+ redis.disconnect();
+ }
+ return;
+ }
+
+ /** Handle single Redis instance */
+ const redis = new Redis(redisConfig);
+
+ try {
+ logger.info('Attempting to connect to Redis...');
+ await redis.ping();
+ logger.info('Connected to Redis. Executing flushall...');
+ const result = await Promise.race([
+ redis.flushall(),
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Flush timeout')), 5000)),
+ ]);
+ logger.info('Redis cache flushed successfully', { result });
+ } catch (err) {
+ logger.error('Error while flushing Redis cache:', err);
+ throw err;
+ } finally {
+ redis.disconnect();
+ }
+};
+
+/**
+ * Master process
+ * Manages worker processes and handles graceful shutdowns
+ */
+if (cluster.isMaster) {
+ logger.info(wrapLogMessage(`Master ${process.pid} is starting...`));
+ logger.info(`Spawning ${workers} workers to simulate multi-pod environment`);
+
+ let activeWorkers = 0;
+ const listeningWorkers = new Set();
+ let retentionSweepWorkerId = null;
+ const startTime = Date.now();
+
+ const assignRetentionSweepWorker = () => {
+ if (retentionSweepWorkerId && cluster.workers[retentionSweepWorkerId]) {
+ return;
+ }
+
+ const connectedWorkers = Object.values(cluster.workers).filter(
+ (worker) => worker && worker.isConnected(),
+ );
+ const availableWorkers = connectedWorkers.filter((worker) => listeningWorkers.has(worker.id));
+ const workerPool = availableWorkers.length > 0 ? availableWorkers : connectedWorkers;
+ const retentionSweepWorker = workerPool[workerPool.length - 1];
+ if (!retentionSweepWorker) {
+ return;
+ }
+
+ retentionSweepWorkerId = retentionSweepWorker.id;
+ logger.info(
+ wrapLogMessage(`Worker ${retentionSweepWorker.process.pid} assigned to file-retention sweep`),
+ );
+ retentionSweepWorker.send({ type: 'file-retention-sweep-worker' });
+ };
+
+ /** Flush Redis cache before starting workers */
+ flushRedisCache()
+ .then(() => {
+ logger.info('Cache flushed, forking workers...');
+ for (let i = 0; i < workers; i++) {
+ cluster.fork();
+ }
+ })
+ .catch((err) => {
+ logger.error('Unable to flush Redis cache, not forking workers:', err);
+ process.exit(1);
+ });
+
+ /** Track worker lifecycle */
+ cluster.on('online', (worker) => {
+ activeWorkers++;
+ const uptime = ((Date.now() - startTime) / 1000).toFixed(2);
+ logger.info(
+ `Worker ${worker.process.pid} is online (${activeWorkers}/${workers}) after ${uptime}s`,
+ );
+
+ /** Assign one worker for process-wide background jobs */
+ if (activeWorkers === workers) {
+ logger.info(wrapLogMessage(`All ${workers} workers are online`));
+ }
+ });
+
+ cluster.on('listening', (worker) => {
+ listeningWorkers.add(worker.id);
+ if (
+ listeningWorkers.size === workers ||
+ (!retentionSweepWorkerId && activeWorkers >= workers)
+ ) {
+ assignRetentionSweepWorker();
+ }
+ });
+
+ cluster.on('exit', (worker, code, signal) => {
+ activeWorkers--;
+ listeningWorkers.delete(worker.id);
+ if (worker.id === retentionSweepWorkerId) {
+ retentionSweepWorkerId = null;
+ assignRetentionSweepWorker();
+ }
+ logger.error(
+ `Worker ${worker.process.pid} died (${activeWorkers}/${workers}). Code: ${code}, Signal: ${signal}`,
+ );
+ logger.info('Starting a new worker to replace it...');
+ cluster.fork();
+ });
+
+ /** Graceful shutdown on SIGTERM/SIGINT */
+ const shutdown = () => {
+ logger.info('Master received shutdown signal, terminating workers...');
+ for (const id in cluster.workers) {
+ cluster.workers[id].kill();
+ }
+ setTimeout(() => {
+ logger.info('Forcing shutdown after timeout');
+ process.exit(0);
+ }, 10000);
+ };
+
+ process.on('SIGTERM', shutdown);
+ process.on('SIGINT', shutdown);
+} else {
+ /**
+ * Worker process
+ * Each worker runs a full Express server instance
+ */
+ const app = express();
+ /**
+ * The master may assign the sweep worker before or after this worker has
+ * loaded app config. These flags join the IPC assignment with config
+ * availability and ensure the background sweep starts only once.
+ */
+ let shouldStartExpiredFileSweep = false;
+ let expiredFileSweepOptions = null;
+ let expiredFileSweepStarted = false;
+
+ const startExpiredFileSweepOnce = () => {
+ if (!shouldStartExpiredFileSweep || expiredFileSweepStarted || !expiredFileSweepOptions) {
+ return;
+ }
+
+ expiredFileSweepStarted = true;
+ startExpiredFileSweep(expiredFileSweepOptions);
+ };
+
+ /** Handle inter-process messages from master */
+ process.on('message', (msg) => {
+ if (msg.type === 'file-retention-sweep-worker') {
+ shouldStartExpiredFileSweep = true;
+ logger.info(wrapLogMessage(`Worker ${process.pid} is assigned file-retention sweep`));
+ startExpiredFileSweepOnce();
+ }
+ });
+
+ const startServer = async () => {
+ logger.info(`Worker ${process.pid} initializing...`);
+
+ if (typeof Bun !== 'undefined') {
+ axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
+ }
+
+ /** Connect to MongoDB */
+ await connectDb();
+ logger.info(`Worker ${process.pid}: Connected to MongoDB`);
+
+ /** Background index sync (non-blocking) */
+ indexSync().catch((err) => {
+ logger.error(`[Worker ${process.pid}][indexSync] Background sync failed:`, err);
+ });
+
+ app.disable('x-powered-by');
+ app.set('trust proxy', trusted_proxy);
+
+ /** Seed database (idempotent) */
+ await seedDatabase();
+
+ /* Mirrors `server/index.js`; `runAsSystem` for tenant-isolated File. */
+ runAsSystem(sweepOrphanedPreviews).catch((err) => {
+ logger.error('[sweepOrphanedPreviews] Background sweep failed:', err);
+ });
+
+ /** Initialize app configuration */
+ const appConfig = await getAppConfig();
+ initializeFileStorage(appConfig);
+ initializeGitHubSkillSync(appConfig);
+ // Register configured tool-approval policy hooks (mirrors the standard startup path).
+ // Honors the `enabled` kill switch; hooks are base-config-only, registered process-wide.
+ // Read from the BASE config specifically — `appConfig` above (getAppConfig() with no
+ // principal) still merges DB `__base__` overrides, which must not drive which hook
+ // modules load in every worker (matches api/server/index.js's baseOnly usage).
+ const baseAppConfig = await getAppConfig({ baseOnly: true });
+ const toolApproval = baseAppConfig?.endpoints?.agents?.toolApproval;
+ await loadToolApprovalHooks(toolApproval?.enabled ? toolApproval.hooks : undefined, {
+ basePath: path.resolve(__dirname, '../..'),
+ });
+ // Prune the paused run's durable checkpoint when its approval EXPIRES (a stale submit —
+ // this startup never runs the periodic sweeper) instead of leaving it until the Mongo
+ // TTL. Mirrors api/server/index.js's configureGenerationStreams wiring; safe here even
+ // though this startup runs the manager on constructor defaults (the setter never resets
+ // services). streamId === conversationId === the LangGraph thread_id.
+ GenerationJobManager.setApprovalExpiredHandler(async (conversationId, job) => {
+ // Resolve config in the PAUSED JOB's tenant/user scope (mirrors index.js): enter the
+ // tenant ALS context — getAppConfig args alone only key the cache.
+ await tenantStorage.run({ tenantId: job?.tenantId, userId: job?.userId }, async () => {
+ const currentConfig = await getAppConfig({
+ userId: job?.userId,
+ tenantId: job?.tenantId,
+ });
+ await deleteAgentCheckpoint(conversationId, currentConfig?.endpoints?.agents?.checkpointer);
+ });
+ });
+ expiredFileSweepOptions = { appConfig, loadAppConfig: getAppConfig };
+ startExpiredFileSweepOnce();
+ await performStartupChecks(appConfig);
+ await updateInterfacePerms({ appConfig, getRoleByName, updateAccessPermissions });
+
+ /** Load index.html for SPA serving */
+ const indexPath = path.join(appConfig.paths.dist, 'index.html');
+ let indexHTML = fs.readFileSync(indexPath, 'utf8');
+
+ /** Support serving in subdirectory if DOMAIN_CLIENT is set */
+ if (process.env.DOMAIN_CLIENT) {
+ const clientUrl = new URL(process.env.DOMAIN_CLIENT);
+ const baseHref = clientUrl.pathname.endsWith('/')
+ ? clientUrl.pathname
+ : `${clientUrl.pathname}/`;
+ if (baseHref !== '/') {
+ logger.info(`Setting base href to ${baseHref}`);
+ indexHTML = indexHTML.replace(/base href="\/"/, `base href="${baseHref}"`);
+ }
+ }
+
+ const sendIndexHtml = (req, res) => {
+ res.set({
+ 'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate',
+ Pragma: process.env.INDEX_PRAGMA || 'no-cache',
+ Expires: process.env.INDEX_EXPIRES || '0',
+ });
+ res.vary(QUERY_DEVTOOLS_HEADER);
+
+ const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US';
+ const saneLang = lang.replace(/"/g, '"');
+ let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`);
+ updatedIndexHtml = maybeInjectQueryDevtoolsBootstrap(updatedIndexHtml, req);
+
+ res.type('html');
+ res.send(updatedIndexHtml);
+ };
+
+ /** Health check endpoint */
+ app.get('/health', (_req, res) => res.status(200).send('OK'));
+
+ /** Middleware */
+ app.use(noIndex);
+ app.use(express.json({ limit: '3mb' }));
+ app.use(express.urlencoded({ extended: true, limit: '3mb' }));
+
+ app.use(handleJsonParseError);
+
+ /**
+ * Express 5 Compatibility: Make req.query writable for mongoSanitize
+ * In Express 5, req.query is read-only by default, but express-mongo-sanitize needs to modify it
+ */
+ app.use((req, _res, next) => {
+ Object.defineProperty(req, 'query', {
+ ...Object.getOwnPropertyDescriptor(req, 'query'),
+ value: req.query,
+ writable: true,
+ });
+ next();
+ });
+
+ app.use(mongoSanitize());
+ app.use(cors());
+ app.use(cookieParser());
+
+ if (!isEnabled(DISABLE_COMPRESSION)) {
+ app.use(compression());
+ } else {
+ logger.warn('Response compression has been disabled via DISABLE_COMPRESSION.');
+ }
+
+ app.get('/index.html', sendIndexHtml);
+ app.use(staticCache(appConfig.paths.dist));
+ app.use(staticCache(appConfig.paths.fonts));
+ app.use(staticCache(appConfig.paths.assets));
+
+ if (!ALLOW_SOCIAL_LOGIN) {
+ logger.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.');
+ }
+
+ /** OAUTH */
+ app.use(passport.initialize());
+ passport.use(jwtLogin());
+ passport.use(passportLogin());
+
+ /** LDAP Auth */
+ if (process.env.LDAP_URL && process.env.LDAP_USER_SEARCH_BASE) {
+ passport.use(ldapLogin);
+ }
+
+ if (isEnabled(ALLOW_SOCIAL_LOGIN)) {
+ await configureSocialLogins(app);
+ }
+
+ app.use(capabilityContextMiddleware);
+
+ /** Routes */
+ app.use('/oauth', routes.oauth);
+ app.use('/api/auth', routes.auth);
+ app.use('/api/admin', routes.adminAuth);
+ app.use('/api/admin/skills', routes.adminSkills);
+ app.use('/api/actions', routes.actions);
+ app.use('/api/keys', routes.keys);
+ app.use('/api/api-keys', routes.apiKeys);
+ app.use('/api/user', routes.user);
+ app.use('/api/search', routes.search);
+ app.use('/api/messages', routes.messages);
+ app.use('/api/convos', routes.convos);
+ app.use('/api/presets', routes.presets);
+ app.use('/api/projects', routes.projects);
+ app.use('/api/prompts', routes.prompts);
+ app.use('/api/skills', routes.skills);
+ app.use('/api/categories', routes.categories);
+ app.use('/api/endpoints', routes.endpoints);
+ app.use('/api/balance', routes.balance);
+ app.use('/api/models', routes.models);
+ 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/roles', routes.roles);
+ app.use('/api/agents', routes.agents);
+ app.use('/api/banner', routes.banner);
+ app.use('/api/memories', routes.memories);
+ app.use('/api/permissions', routes.accessPermissions);
+ app.use('/api/tags', routes.tags);
+ app.use('/api/mcp', routes.mcp);
+
+ /** 404 for unmatched API routes */
+ app.use('/api', apiNotFound);
+
+ /** SPA fallback - serve index.html for all unmatched routes */
+ app.use(createSpaFallback(sendIndexHtml));
+
+ /** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */
+ app.use(ErrorController);
+
+ /** Start listening on shared port (cluster will distribute connections) */
+ app.listen(port, host, async (err) => {
+ if (err) {
+ logger.error(`Worker ${process.pid} failed to start server:`, err);
+ process.exit(1);
+ }
+
+ logger.info(
+ `Worker ${process.pid} started: Server listening at http://${
+ host == '0.0.0.0' ? 'localhost' : host
+ }:${port}`,
+ );
+
+ /**
+ * The listen callback is async, so any rejection from these awaits
+ * would otherwise be detached from `startServer().catch(...)`. Without
+ * explicit handling, the global `unhandledRejection` handler would
+ * swallow init failures and leave the worker listening but only
+ * partially initialized.
+ */
+ try {
+ /** Initialize MCP servers and OAuth reconnection for this worker */
+ await initializeMCPs();
+ await initializeOAuthReconnectManager();
+ await checkMigrations();
+ } catch (initErr) {
+ logger.error(`Worker ${process.pid} post-listen initialization failed:`, initErr);
+ process.exit(1);
+ }
+ });
+ };
+
+ startServer().catch((err) => {
+ logger.error(`Failed to start worker ${process.pid}:`, err);
+ process.exit(1);
+ });
+
+ /** Export app for testing purposes (only available in worker processes) */
+ module.exports = app;
+}
+
+/**
+ * Uncaught exception handler
+ * Filters out known non-critical errors
+ */
+let messageCount = 0;
+process.on('uncaughtException', (err) => {
+ if (!err.message.includes('fetch failed')) {
+ logger.error('There was an uncaught error:', err);
+ }
+
+ if (err.message && err.message?.toLowerCase()?.includes('abort')) {
+ logger.warn('There was an uncatchable abort error.');
+ return;
+ }
+
+ if (err.message.includes('GoogleGenerativeAI')) {
+ logger.warn(
+ '\n\n`GoogleGenerativeAI` errors cannot be caught due to an upstream issue, see: https://github.com/google-gemini/generative-ai-js/issues/303',
+ );
+ return;
+ }
+
+ if (err.message.includes('fetch failed')) {
+ if (messageCount === 0) {
+ logger.warn('Meilisearch error, search will be disabled');
+ messageCount++;
+ }
+ return;
+ }
+
+ if (err.message.includes('OpenAIError') || err.message.includes('ChatCompletionMessage')) {
+ logger.error(
+ '\n\nAn Uncaught `OpenAIError` error may be due to your reverse-proxy setup or stream configuration, or a bug in the `openai` node package.',
+ );
+ return;
+ }
+
+ if (err.stack && err.stack.includes('@librechat/agents')) {
+ logger.error(
+ '\n\nAn error occurred in the agents system. The error has been logged and the app will continue running.',
+ {
+ message: err.message,
+ stack: err.stack,
+ },
+ );
+ return;
+ }
+
+ process.exit(1);
+});
+
+/**
+ * Unhandled promise rejection handler.
+ *
+ * Node 15+ terminates the process by default when a promise rejection is
+ * unhandled. MCP OAuth reconnect storms and streamable-HTTP transport resets
+ * can produce transient fire-and-forget rejections (ECONNRESET, token refresh
+ * races) that are recoverable — the server should log and keep serving other
+ * requests rather than silently crash under load.
+ *
+ * Non-Error reasons are forwarded as-is so structured payloads (e.g.
+ * `{ code: "ECONNRESET", errno: -104 }`) survive instead of being collapsed to
+ * "[object Object]" by `String()`.
+ */
+process.on('unhandledRejection', (reason) => {
+ if (reason instanceof Error) {
+ logger.error('Unhandled promise rejection. The app will continue running.', {
+ name: reason.name,
+ message: reason.message,
+ stack: reason.stack,
+ cause: reason.cause,
+ });
+ return;
+ }
+ logger.error('Unhandled promise rejection. The app will continue running.', { reason });
+});
diff --git a/api/server/index.js b/api/server/index.js
new file mode 100644
index 0000000..f88b2fd
--- /dev/null
+++ b/api/server/index.js
@@ -0,0 +1,462 @@
+const telemetry = require('./telemetry');
+const fs = require('fs');
+const path = require('path');
+require('module-alias')({ base: path.resolve(__dirname, '..') });
+const cors = require('cors');
+const axios = require('axios');
+const express = require('express');
+const passport = require('passport');
+const compression = require('compression');
+const cookieParser = require('cookie-parser');
+const mongoSanitize = require('express-mongo-sanitize');
+const { logger, runAsSystem, tenantStorage } = require('@librechat/data-schemas');
+const {
+ isEnabled,
+ apiNotFound,
+ createMetrics,
+ ErrorController,
+ memoryDiagnostics,
+ performStartupChecks,
+ handleJsonParseError,
+ GenerationJobManager,
+ QUERY_DEVTOOLS_HEADER,
+ createStreamServices,
+ deleteAgentCheckpoint,
+ initializeFileStorage,
+ initializeDeploymentSkills,
+ loadToolApprovalHooks,
+ maybeInjectQueryDevtoolsBootstrap,
+ preAuthTenantMiddleware,
+ setupGracefulShutdown,
+ updateInterfacePermissions,
+} = require('@librechat/api');
+const { connectDb, indexSync } = require('~/db');
+const {
+ updateAccessPermissions,
+ sweepOrphanedPreviews,
+ getRoleByName,
+ seedDatabase,
+} = require('~/models');
+const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
+const { capabilityContextMiddleware } = require('./middleware/roles/capabilities');
+const createValidateImageRequest = require('./middleware/validateImageRequest');
+const { startExpiredFileSweep } = require('./services/Files/process');
+const { initializeGitHubSkillSync } = require('./services/Skills/sync');
+const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
+const { checkMigrations } = require('./services/start/migration');
+const optionalJwtAuth = require('./middleware/optionalJwtAuth');
+const initializeMCPs = require('./services/initializeMCPs');
+const configureSocialLogins = require('./socialLogins');
+const createSpaFallback = require('./utils/fallback');
+const { getAppConfig } = require('./services/Config');
+const staticCache = require('./utils/staticCache');
+const noIndex = require('./middleware/noIndex');
+const routes = require('./routes');
+
+const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
+
+// Allow PORT=0 to be used for automatic free port assignment
+const port = isNaN(Number(PORT)) ? 3080 : Number(PORT);
+const host = HOST || 'localhost';
+const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default */
+
+const app = express();
+let serverReady = false;
+
+const SERVER_NOT_READY_CODE = 'SERVER_NOT_READY';
+const CHAT_START_RETRY_AFTER_SECONDS = '1';
+
+const rejectChatStartsUntilReady = (req, res, next) => {
+ if (serverReady || req.method !== 'POST' || req.path === '/abort') {
+ return next();
+ }
+
+ res.set('Retry-After', CHAT_START_RETRY_AFTER_SECONDS);
+ return res.status(503).json({
+ code: SERVER_NOT_READY_CODE,
+ error: 'Server is still starting. Please retry shortly.',
+ });
+};
+
+const configureGenerationStreams = () => {
+ const streamServices = createStreamServices();
+ GenerationJobManager.configure({
+ ...streamServices,
+ cleanupOnComplete: !isEnabled(process.env.STREAM_KEEP_COMPLETED_JOBS),
+ });
+ GenerationJobManager.initialize();
+ // Prune the paused run's durable checkpoint when its approval EXPIRES (periodic sweeper
+ // or a stale submit) instead of leaving it until the Mongo TTL. streamId === conversationId
+ // === the LangGraph thread_id. Config is resolved lazily per expiry so the prune always
+ // targets the currently configured checkpoint collections.
+ GenerationJobManager.setApprovalExpiredHandler(async (conversationId, job) => {
+ // Resolve config in the PAUSED JOB's tenant/user scope — the expiry runs outside any
+ // request context. Passing ids to getAppConfig only keys the cache; the Config query
+ // itself is ALS-scoped by the tenant-isolation plugin, so ENTER the tenant context.
+ await tenantStorage.run({ tenantId: job?.tenantId, userId: job?.userId }, async () => {
+ const appConfig = await getAppConfig({ userId: job?.userId, tenantId: job?.tenantId });
+ await deleteAgentCheckpoint(conversationId, appConfig?.endpoints?.agents?.checkpointer);
+ });
+ });
+};
+
+const startServer = async () => {
+ const { metricsMiddleware, metricsRouter } = createMetrics();
+ if (!process.env.METRICS_SECRET) {
+ logger.warn('[metrics] METRICS_SECRET is not set - /metrics will return 401 for all requests');
+ }
+
+ if (typeof Bun !== 'undefined') {
+ axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
+ }
+ await connectDb();
+
+ logger.info('Connected to MongoDB');
+ indexSync().catch((err) => {
+ logger.error('[indexSync] Background sync failed:', err);
+ });
+
+ app.disable('x-powered-by');
+ app.set('trust proxy', trusted_proxy);
+
+ 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);
+ /* Recover stuck `status: 'pending'` records from a crash mid-render.
+ * `runAsSystem` is required — `File` is tenant-isolated and strict
+ * mode rejects unscoped queries. Lazy sweep in the preview endpoint
+ * covers anything younger than the boot cutoff. */
+ runAsSystem(sweepOrphanedPreviews).catch((err) => {
+ logger.error('[sweepOrphanedPreviews] Background sweep failed:', err);
+ });
+ const appConfig = await getAppConfig({ baseOnly: true });
+ initializeFileStorage(appConfig);
+ await initializeDeploymentSkills({ projectRoot: path.resolve(__dirname, '../..') });
+ initializeGitHubSkillSync(appConfig);
+ startExpiredFileSweep({ appConfig, loadAppConfig: getAppConfig });
+ // Register any programmatic tool-approval policy hooks declared in
+ // `endpoints.agents.toolApproval.hooks`. Honor the `enabled` kill switch: when tool
+ // approval is off we pass no hooks, so a disabled endpoint imports/runs nothing (and any
+ // previously loaded batch is unregistered). Hooks are read from the BASE config only —
+ // they register once, process-wide; per-user/tenant differences belong inside the hook
+ // (via its context), not in per-override module lists.
+ const toolApproval = appConfig?.endpoints?.agents?.toolApproval;
+ await loadToolApprovalHooks(toolApproval?.enabled ? toolApproval.hooks : undefined, {
+ basePath: path.resolve(__dirname, '../..'),
+ });
+ 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');
+
+ // In order to provide support to serving the application in a sub-directory
+ // We need to update the base href if the DOMAIN_CLIENT is specified and not the root path
+ if (process.env.DOMAIN_CLIENT) {
+ const clientUrl = new URL(process.env.DOMAIN_CLIENT);
+ const baseHref = clientUrl.pathname.endsWith('/')
+ ? clientUrl.pathname
+ : `${clientUrl.pathname}/`;
+ if (baseHref !== '/') {
+ logger.info(`Setting base href to ${baseHref}`);
+ indexHTML = indexHTML.replace(/base href="\/"/, `base href="${baseHref}"`);
+ }
+ }
+
+ const sendIndexHtml = (req, res) => {
+ res.set({
+ 'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate',
+ Pragma: process.env.INDEX_PRAGMA || 'no-cache',
+ Expires: process.env.INDEX_EXPIRES || '0',
+ });
+ res.vary(QUERY_DEVTOOLS_HEADER);
+
+ const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US';
+ const saneLang = lang.replace(/"/g, '"');
+ let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`);
+ updatedIndexHtml = maybeInjectQueryDevtoolsBootstrap(updatedIndexHtml, req);
+
+ res.type('html');
+ res.send(updatedIndexHtml);
+ };
+
+ app.get('/health', (_req, res) => res.status(200).send('OK'));
+ app.get('/livez', (_req, res) => res.status(200).send('OK'));
+ app.get('/readyz', (_req, res) => {
+ if (!serverReady) {
+ return res.status(503).send('NOT_READY');
+ }
+ return res.status(200).send('OK');
+ });
+
+ /* Middleware */
+ app.use(metricsMiddleware);
+ app.use(noIndex);
+ app.use(express.json({ limit: '3mb' }));
+ app.use(express.urlencoded({ extended: true, limit: '3mb' }));
+ app.use(handleJsonParseError);
+
+ /**
+ * Express 5 Compatibility: Make req.query writable for mongoSanitize
+ * In Express 5, req.query is read-only by default, but express-mongo-sanitize needs to modify it
+ */
+ app.use((req, _res, next) => {
+ Object.defineProperty(req, 'query', {
+ ...Object.getOwnPropertyDescriptor(req, 'query'),
+ value: req.query,
+ writable: true,
+ });
+ next();
+ });
+
+ app.use(mongoSanitize());
+ app.use(cors());
+ app.use(cookieParser());
+
+ if (!isEnabled(DISABLE_COMPRESSION)) {
+ app.use(compression());
+ } else {
+ console.warn('Response compression has been disabled via DISABLE_COMPRESSION.');
+ }
+
+ app.get('/index.html', sendIndexHtml);
+ app.use(staticCache(appConfig.paths.dist));
+ app.use(staticCache(appConfig.paths.fonts));
+ app.use(staticCache(appConfig.paths.assets));
+
+ if (telemetry.enabled) {
+ app.use(telemetry.telemetryMiddleware);
+ }
+
+ if (!ALLOW_SOCIAL_LOGIN) {
+ console.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.');
+ }
+
+ /* OAUTH */
+ app.use(passport.initialize());
+ passport.use(jwtLogin());
+ passport.use(passportLogin());
+
+ /* LDAP Auth */
+ if (process.env.LDAP_URL && process.env.LDAP_USER_SEARCH_BASE) {
+ passport.use(ldapLogin);
+ }
+
+ if (isEnabled(ALLOW_SOCIAL_LOGIN)) {
+ await configureSocialLogins(app);
+ }
+
+ /* 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', 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/skills', routes.adminSkills);
+ app.use('/api/admin/users', routes.adminUsers);
+ app.use('/api/admin/audit-log', routes.adminAuditLog);
+ app.use('/api/actions', routes.actions);
+ app.use('/api/keys', routes.keys);
+ app.use('/api/api-keys', routes.apiKeys);
+ app.use('/api/user', routes.user);
+ app.use('/api/search', routes.search);
+ app.use('/api/messages', routes.messages);
+ app.use('/api/convos', routes.convos);
+ app.use('/api/presets', routes.presets);
+ app.use('/api/projects', routes.projects);
+ app.use('/api/prompts', routes.prompts);
+ app.use('/api/skills', routes.skills);
+ app.use('/api/categories', routes.categories);
+ app.use('/api/endpoints', routes.endpoints);
+ app.use('/api/balance', routes.balance);
+ app.use('/api/models', routes.models);
+ 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', preAuthTenantMiddleware, routes.share);
+ app.use('/api/roles', routes.roles);
+ app.use('/api/agents/chat', rejectChatStartsUntilReady);
+ app.use('/api/agents', routes.agents);
+ app.use('/api/banner', routes.banner);
+ app.use('/api/memories', routes.memories);
+ app.use('/api/permissions', routes.accessPermissions);
+
+ app.use('/api/tags', routes.tags);
+ app.use('/api/mcp', routes.mcp);
+ app.use('/api/rum', routes.rum);
+
+ app.use('/metrics', metricsRouter);
+
+ /** 404 for unmatched API routes */
+ app.use('/api', apiNotFound);
+
+ /** SPA fallback - serve index.html for all unmatched routes */
+ app.use(createSpaFallback(sendIndexHtml));
+
+ /** Record trace errors before the final error controller. */
+ if (telemetry.enabled) {
+ app.use(telemetry.telemetryErrorMiddleware);
+ }
+ /** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */
+ app.use(ErrorController);
+
+ configureGenerationStreams();
+
+ const server = app.listen(port, host, async (err) => {
+ if (err) {
+ logger.error('Failed to start server:', err);
+ process.exit(1);
+ }
+
+ if (host === '0.0.0.0') {
+ logger.info(
+ `Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`,
+ );
+ } else {
+ logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
+ }
+
+ /**
+ * The listen callback is async, so any rejection from these awaits would
+ * otherwise be detached from `startServer().catch(...)` (which only
+ * catches errors that happen before `app.listen`). Without explicit
+ * handling, the global `unhandledRejection` handler would swallow init
+ * failures and leave the server listening but only partially
+ * initialized — passing liveness checks while serving broken requests.
+ */
+ try {
+ await runAsSystem(async () => {
+ await initializeMCPs();
+ await initializeOAuthReconnectManager();
+ });
+ await checkMigrations();
+
+ const inspectFlags = process.execArgv.some((arg) => arg.startsWith('--inspect'));
+ if (inspectFlags || isEnabled(process.env.MEM_DIAG)) {
+ memoryDiagnostics.start();
+ }
+ serverReady = true;
+ logger.info('Server readiness checks passing.');
+ } catch (initErr) {
+ serverReady = false;
+ logger.error('Post-listen initialization failed:', initErr);
+ process.exit(1);
+ }
+ });
+
+ setupGracefulShutdown(server);
+};
+
+/**
+ * Boot rejections (e.g. `connectDb`, `getAppConfig`, `performStartupChecks`)
+ * must remain fail-fast: a half-initialized process with no listening HTTP
+ * server should die immediately so the orchestrator restarts it, instead of
+ * being kept alive by the `unhandledRejection` handler below until the
+ * liveness probe eventually times out. Mirrors the pattern in
+ * `experimental.js`.
+ */
+startServer().catch((err) => {
+ logger.error('Failed to start server:', err);
+ process.exit(1);
+});
+
+let messageCount = 0;
+process.on('uncaughtException', (err) => {
+ if (!err.message.includes('fetch failed')) {
+ logger.error('There was an uncaught error:', err);
+ }
+
+ if (err.message && err.message?.toLowerCase()?.includes('abort')) {
+ logger.warn('There was an uncatchable abort error.');
+ return;
+ }
+
+ if (err.message.includes('GoogleGenerativeAI')) {
+ logger.warn(
+ '\n\n`GoogleGenerativeAI` errors cannot be caught due to an upstream issue, see: https://github.com/google-gemini/generative-ai-js/issues/303',
+ );
+ return;
+ }
+
+ if (err.message.includes('fetch failed')) {
+ if (messageCount === 0) {
+ logger.warn('Meilisearch error, search will be disabled');
+ messageCount++;
+ }
+
+ return;
+ }
+
+ if (err.message.includes('OpenAIError') || err.message.includes('ChatCompletionMessage')) {
+ logger.error(
+ '\n\nAn Uncaught `OpenAIError` error may be due to your reverse-proxy setup or stream configuration, or a bug in the `openai` node package.',
+ );
+ return;
+ }
+
+ if (err.stack && err.stack.includes('@librechat/agents')) {
+ logger.error(
+ '\n\nAn error occurred in the agents system. The error has been logged and the app will continue running.',
+ {
+ message: err.message,
+ stack: err.stack,
+ },
+ );
+ return;
+ }
+
+ if (isEnabled(process.env.CONTINUE_ON_UNCAUGHT_EXCEPTION)) {
+ logger.error('Unhandled error encountered. The app will continue running.', {
+ name: err?.name,
+ message: err?.message,
+ stack: err?.stack,
+ });
+ return;
+ }
+
+ process.exit(1);
+});
+
+/**
+ * Unhandled promise rejection handler.
+ *
+ * Node 15+ terminates the process by default when a promise rejection is
+ * unhandled. MCP OAuth reconnect storms and streamable-HTTP transport resets
+ * can produce transient fire-and-forget rejections (ECONNRESET, token refresh
+ * races) that are recoverable — the server should log and keep serving other
+ * requests rather than silently crash under load.
+ *
+ * Non-Error reasons are forwarded as-is so structured payloads (e.g.
+ * `{ code: "ECONNRESET", errno: -104 }`) survive instead of being collapsed to
+ * "[object Object]" by `String()`.
+ */
+process.on('unhandledRejection', (reason) => {
+ if (reason instanceof Error) {
+ logger.error('Unhandled promise rejection. The app will continue running.', {
+ name: reason.name,
+ message: reason.message,
+ stack: reason.stack,
+ cause: reason.cause,
+ });
+ return;
+ }
+ logger.error('Unhandled promise rejection. The app will continue running.', { reason });
+});
+
+/** Export app for easier testing purposes */
+module.exports = app;
diff --git a/api/server/index.metrics.spec.js b/api/server/index.metrics.spec.js
new file mode 100644
index 0000000..c907aca
--- /dev/null
+++ b/api/server/index.metrics.spec.js
@@ -0,0 +1,166 @@
+const fs = require('fs');
+const request = require('supertest');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const mongoose = require('mongoose');
+
+jest.mock('~/server/services/Config', () => ({
+ loadCustomConfig: jest.fn(() => Promise.resolve({})),
+ getAppConfig: jest.fn().mockResolvedValue({
+ paths: {
+ uploads: '/tmp',
+ dist: '/tmp/dist',
+ fonts: '/tmp/fonts',
+ assets: '/tmp/assets',
+ },
+ fileStrategy: 'local',
+ imageOutputType: 'PNG',
+ }),
+ setCachedTools: jest.fn(),
+}));
+
+jest.mock('~/app/clients/tools', () => ({
+ createOpenAIImageTools: jest.fn(() => []),
+ createYouTubeTools: jest.fn(() => []),
+ manifestToolMap: {},
+ toolkits: [],
+}));
+
+jest.mock('~/config', () => ({
+ createMCPServersRegistry: jest.fn(),
+ createMCPManager: jest.fn().mockResolvedValue({
+ getAppToolFunctions: jest.fn().mockResolvedValue({}),
+ }),
+}));
+
+describe('Server metrics route', () => {
+ jest.setTimeout(30_000);
+
+ let mongoServer;
+ let app;
+
+ const originalReadFileSync = fs.readFileSync;
+
+ beforeAll(() => {
+ fs.readFileSync = function (filepath, options) {
+ if (filepath.includes('index.html')) {
+ return 'LibreChat
';
+ }
+ return originalReadFileSync(filepath, options);
+ };
+ });
+
+ afterAll(() => {
+ fs.readFileSync = originalReadFileSync;
+ });
+
+ beforeAll(async () => {
+ const fs = require('fs');
+ const path = require('path');
+
+ const dirs = ['/tmp/dist', '/tmp/fonts', '/tmp/assets'];
+ dirs.forEach((dir) => {
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+ });
+
+ fs.writeFileSync(
+ path.join('/tmp/dist', 'index.html'),
+ 'LibreChat
',
+ );
+
+ mongoServer = await MongoMemoryServer.create();
+ process.env.MONGO_URI = mongoServer.getUri();
+ process.env.PORT = '0';
+ process.env.METRICS_SECRET = 'test-secret';
+ app = require('~/server');
+
+ await healthCheckPoll(app);
+ });
+
+ afterEach(() => {
+ process.env.METRICS_SECRET = 'test-secret';
+ });
+
+ afterAll(async () => {
+ delete process.env.METRICS_SECRET;
+ await mongoServer.stop();
+ await mongoose.disconnect();
+ });
+
+ it('returns 401 at /metrics when METRICS_SECRET is unset', async () => {
+ const response = await request(app).get('/metrics');
+ expect(response.status).toBe(401);
+ });
+
+ it('returns 401 at /metrics when no token provided', async () => {
+ process.env.METRICS_SECRET = 'test-secret';
+
+ const response = await request(app).get('/metrics');
+
+ expect(response.status).toBe(401);
+ });
+
+ it('returns 401 at /metrics when wrong token provided', async () => {
+ process.env.METRICS_SECRET = 'test-secret';
+
+ const response = await request(app).get('/metrics').set('Authorization', 'Bearer wrong-token');
+
+ expect(response.status).toBe(401);
+ });
+
+ it('returns 401 at /metrics when the bearer scheme is omitted', async () => {
+ process.env.METRICS_SECRET = 'test-secret';
+
+ const response = await request(app).get('/metrics').set('Authorization', 'test-secret');
+
+ expect(response.status).toBe(401);
+ });
+
+ it('returns 401 at /metrics for non-bearer auth schemes', async () => {
+ process.env.METRICS_SECRET = 'test-secret';
+
+ const response = await request(app).get('/metrics').set('Authorization', 'Basic test-secret');
+
+ expect(response.status).toBe(401);
+ });
+
+ it('exposes Prometheus metrics at /metrics with correct bearer token', async () => {
+ process.env.METRICS_SECRET = 'test-secret';
+
+ const response = await request(app).get('/metrics').set('Authorization', 'Bearer test-secret');
+
+ expect(response.status).toBe(200);
+ expect(response.headers['content-type']).toMatch(/text\/plain/);
+ expect(response.text).toMatch(/^# HELP /m);
+ expect(response.text).toMatch(/^# TYPE /m);
+ });
+
+ it('accepts lowercase bearer scheme at /metrics', async () => {
+ process.env.METRICS_SECRET = 'test-secret';
+
+ const response = await request(app).get('/metrics').set('Authorization', 'bearer test-secret');
+
+ expect(response.status).toBe(200);
+ });
+});
+
+async function healthCheckPoll(app, retries = 0) {
+ const maxRetries = Math.floor(10000 / 30);
+ try {
+ const response = await request(app).get('/health');
+ if (response.status === 200) {
+ return;
+ }
+ } catch {
+ // Ignore connection errors during polling.
+ }
+
+ if (retries < maxRetries) {
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ await healthCheckPoll(app, retries + 1);
+ return;
+ }
+
+ throw new Error('App did not become healthy within 10 seconds.');
+}
diff --git a/api/server/index.spec.js b/api/server/index.spec.js
new file mode 100644
index 0000000..3e0fc07
--- /dev/null
+++ b/api/server/index.spec.js
@@ -0,0 +1,284 @@
+const fs = require('fs');
+const path = require('path');
+const request = require('supertest');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const mongoose = require('mongoose');
+
+jest.mock('~/server/services/Config', () => ({
+ loadCustomConfig: jest.fn(() => Promise.resolve({})),
+ getAppConfig: jest.fn().mockResolvedValue({
+ paths: {
+ uploads: '/tmp',
+ dist: '/tmp/dist',
+ fonts: '/tmp/fonts',
+ assets: '/tmp/assets',
+ },
+ fileStrategy: 'local',
+ imageOutputType: 'PNG',
+ }),
+ setCachedTools: jest.fn(),
+}));
+
+jest.mock('~/app/clients/tools', () => ({
+ createOpenAIImageTools: jest.fn(() => []),
+ createYouTubeTools: jest.fn(() => []),
+ manifestToolMap: {},
+ toolkits: [],
+}));
+
+jest.mock('~/config', () => ({
+ createMCPServersRegistry: jest.fn(),
+ createMCPManager: jest.fn().mockResolvedValue({
+ getAppToolFunctions: jest.fn().mockResolvedValue({}),
+ }),
+}));
+
+jest.mock(
+ '@librechat/api/telemetry',
+ () => ({
+ initializeTelemetry: jest.fn(() => ({
+ enabled: false,
+ status: 'disabled',
+ shutdown: jest.fn(),
+ })),
+ telemetryMiddleware: jest.fn((_req, _res, next) => next()),
+ telemetryErrorMiddleware: jest.fn((err, _req, _res, next) => next(err)),
+ }),
+ { virtual: true },
+);
+
+describe('Telemetry wiring', () => {
+ const source = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8');
+
+ it('loads telemetry before other server imports', () => {
+ const firstStatement = source
+ .split('\n')
+ .map((line) => line.trim())
+ .find(Boolean);
+
+ expect(firstStatement).toBe("const telemetry = require('./telemetry');");
+ });
+
+ it('mounts telemetry middleware after static assets and before routes', () => {
+ const telemetryMiddlewareIndex = source.indexOf('app.use(telemetry.telemetryMiddleware);');
+ const staticAssetsIndex = source.indexOf('app.use(staticCache(appConfig.paths.assets));');
+ const apiRoutesIndex = source.indexOf("app.use('/api/auth'");
+
+ expect(telemetryMiddlewareIndex).toBeGreaterThan(-1);
+ expect(staticAssetsIndex).toBeGreaterThan(-1);
+ expect(apiRoutesIndex).toBeGreaterThan(-1);
+ expect(staticAssetsIndex).toBeLessThan(telemetryMiddlewareIndex);
+ expect(telemetryMiddlewareIndex).toBeLessThan(apiRoutesIndex);
+ });
+
+ it('mounts telemetry error middleware before ErrorController', () => {
+ const telemetryErrorMiddlewareIndex = source.indexOf(
+ 'app.use(telemetry.telemetryErrorMiddleware);',
+ );
+ const errorControllerIndex = source.indexOf('app.use(ErrorController);');
+
+ expect(telemetryErrorMiddlewareIndex).toBeGreaterThan(-1);
+ expect(errorControllerIndex).toBeGreaterThan(-1);
+ expect(telemetryErrorMiddlewareIndex).toBeLessThan(errorControllerIndex);
+ });
+});
+
+describe('Startup readiness wiring', () => {
+ const source = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8');
+
+ it('configures generation streams before the server accepts requests', () => {
+ const streamConfigIndex = source.indexOf('configureGenerationStreams();');
+ const listenIndex = source.indexOf('const server = app.listen');
+ const postListenMcpIndex = source.indexOf('await initializeMCPs();');
+
+ expect(streamConfigIndex).toBeGreaterThan(-1);
+ expect(listenIndex).toBeGreaterThan(-1);
+ expect(postListenMcpIndex).toBeGreaterThan(-1);
+ expect(streamConfigIndex).toBeLessThan(listenIndex);
+ expect(streamConfigIndex).toBeLessThan(postListenMcpIndex);
+ });
+
+ it('mounts the chat-start readiness gate before agent routes', () => {
+ const readinessGateIndex = source.indexOf(
+ "app.use('/api/agents/chat', rejectChatStartsUntilReady);",
+ );
+ const agentsRouteIndex = source.indexOf("app.use('/api/agents', routes.agents);");
+
+ expect(readinessGateIndex).toBeGreaterThan(-1);
+ expect(agentsRouteIndex).toBeGreaterThan(-1);
+ expect(readinessGateIndex).toBeLessThan(agentsRouteIndex);
+ });
+});
+
+describe('Server Configuration', () => {
+ // Increase the default timeout to allow for Mongo cleanup
+ jest.setTimeout(30_000);
+
+ let mongoServer;
+ let app;
+
+ /** Mocked fs.readFileSync for index.html */
+ const originalReadFileSync = fs.readFileSync;
+ beforeAll(() => {
+ fs.readFileSync = function (filepath, options) {
+ if (filepath.includes('index.html')) {
+ return 'LibreChat
';
+ }
+ return originalReadFileSync(filepath, options);
+ };
+ });
+
+ afterAll(() => {
+ // Restore original fs.readFileSync
+ fs.readFileSync = originalReadFileSync;
+ });
+
+ beforeAll(async () => {
+ // Create the required directories and files for the test
+ const fs = require('fs');
+ const path = require('path');
+
+ const dirs = ['/tmp/dist', '/tmp/fonts', '/tmp/assets'];
+ dirs.forEach((dir) => {
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+ });
+
+ fs.writeFileSync(
+ path.join('/tmp/dist', 'index.html'),
+ 'LibreChat
',
+ );
+
+ mongoServer = await MongoMemoryServer.create();
+ process.env.MONGO_URI = mongoServer.getUri();
+ process.env.PORT = '0'; // Use a random available port
+ app = require('~/server');
+
+ // Wait for the app to be healthy
+ await healthCheckPoll(app);
+ });
+
+ afterAll(async () => {
+ await mongoServer.stop();
+ await mongoose.disconnect();
+ });
+
+ it('should return OK for /health', async () => {
+ const response = await request(app).get('/health');
+ expect(response.status).toBe(200);
+ expect(response.text).toBe('OK');
+ });
+
+ it('should not cache index page', async () => {
+ const response = await request(app).get('/');
+ expect(response.status).toBe(200);
+ expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
+ expect(response.headers['pragma']).toBe('no-cache');
+ expect(response.headers['expires']).toBe('0');
+ });
+
+ it('should return 404 JSON for undefined API routes', async () => {
+ const response = await request(app).get('/api/nonexistent');
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({ message: 'Endpoint not found' });
+ });
+
+ it('should return 404 JSON for nested undefined API routes', async () => {
+ const response = await request(app).get('/api/nonexistent/nested/path');
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({ message: 'Endpoint not found' });
+ });
+
+ it('should return 404 JSON for non-GET methods on undefined API routes', async () => {
+ const post = await request(app).post('/api/nonexistent');
+ expect(post.status).toBe(404);
+ expect(post.body).toEqual({ message: 'Endpoint not found' });
+
+ const del = await request(app).delete('/api/nonexistent');
+ expect(del.status).toBe(404);
+ expect(del.body).toEqual({ message: 'Endpoint not found' });
+ });
+
+ it('should return 404 JSON for the /api root path', async () => {
+ const response = await request(app).get('/api');
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({ message: 'Endpoint not found' });
+ });
+
+ it('should serve SPA HTML for non-API unmatched routes', async () => {
+ const response = await request(app).get('/this/does/not/exist');
+ expect(response.status).toBe(200);
+ expect(response.headers['content-type']).toMatch(/html/);
+ });
+
+ it('should gate React Query Devtools config in SPA HTML by debug header', async () => {
+ const defaultResponse = await request(app).get('/this/does/not/exist');
+ const debugResponse = await request(app)
+ .get('/this/does/not/exist')
+ .set('x-librechat-enable-query-devtools', '1');
+ const directIndexResponse = await request(app)
+ .get('/index.html')
+ .set('x-librechat-enable-query-devtools', '1');
+
+ expect(defaultResponse.status).toBe(200);
+ expect(defaultResponse.headers.vary).toContain('x-librechat-enable-query-devtools');
+ expect(defaultResponse.text).not.toContain('enableQueryDevtools');
+
+ expect(debugResponse.status).toBe(200);
+ expect(debugResponse.headers.vary).toContain('x-librechat-enable-query-devtools');
+ expect(debugResponse.text).toContain('window.__LIBRECHAT_CONFIG__');
+ expect(debugResponse.text).toContain('data-librechat-query-devtools="true"');
+ expect(debugResponse.text).toContain('"enableQueryDevtools":true');
+
+ expect(directIndexResponse.status).toBe(200);
+ expect(directIndexResponse.headers.vary).toContain('x-librechat-enable-query-devtools');
+ expect(directIndexResponse.text).toContain('window.__LIBRECHAT_CONFIG__');
+ expect(directIndexResponse.text).toContain('data-librechat-query-devtools="true"');
+ expect(directIndexResponse.text).toContain('"enableQueryDevtools":true');
+ });
+
+ it('should return 500 for unknown errors via ErrorController', async () => {
+ // Testing the error handling here on top of unit tests to ensure the middleware is correctly integrated
+
+ // Mock MongoDB operations to fail
+ const originalFindOne = mongoose.models.User.findOne;
+ const mockError = new Error('MongoDB operation failed');
+ mongoose.models.User.findOne = jest.fn().mockImplementation(() => {
+ throw mockError;
+ });
+
+ try {
+ const response = await request(app).post('/api/auth/login').send({
+ email: 'test@example.com',
+ password: 'password123',
+ });
+
+ expect(response.status).toBe(500);
+ expect(response.text).toBe('An unknown error occurred.');
+ } finally {
+ // Restore original function
+ mongoose.models.User.findOne = originalFindOne;
+ }
+ });
+});
+
+// Polls the /health endpoint every 30ms for up to 10 seconds to wait for the server to start completely
+async function healthCheckPoll(app, retries = 0) {
+ const maxRetries = Math.floor(10000 / 30); // 10 seconds / 30ms
+ try {
+ const response = await request(app).get('/health');
+ if (response.status === 200) {
+ return; // App is healthy
+ }
+ } catch {
+ // Ignore connection errors during polling
+ }
+
+ if (retries < maxRetries) {
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ await healthCheckPoll(app, retries + 1);
+ } else {
+ throw new Error('App did not become healthy within 10 seconds.');
+ }
+}
diff --git a/api/server/middleware/__tests__/requireJwtAuth.spec.js b/api/server/middleware/__tests__/requireJwtAuth.spec.js
new file mode 100644
index 0000000..b70f371
--- /dev/null
+++ b/api/server/middleware/__tests__/requireJwtAuth.spec.js
@@ -0,0 +1,987 @@
+/**
+ * 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 jwt = require('jsonwebtoken');
+
+// ── Mocks ──────────────────────────────────────────────────────────────
+
+let mockPassportError = null;
+let mockRegisteredStrategies = new Set(['jwt']);
+
+jest.mock('passport', () => ({
+ _strategy: jest.fn((strategy) => (mockRegisteredStrategies.has(strategy) ? {} : undefined)),
+ authenticate: jest.fn((strategy, _options, callback) => {
+ return (req, _res, _done) => {
+ if (mockPassportError) {
+ return callback(mockPassportError);
+ }
+ const strategyResult = req._mockStrategies?.[strategy];
+ if (strategyResult) {
+ return callback(
+ strategyResult.err ?? null,
+ strategyResult.user ?? false,
+ strategyResult.info,
+ strategyResult.status,
+ );
+ }
+ return callback(null, req._mockUser ?? false, { message: 'Unauthorized' }, 401);
+ };
+ }),
+}));
+
+jest.mock('@librechat/data-schemas', () => {
+ const { AsyncLocalStorage } = require('async_hooks');
+ const tenantStorage = new AsyncLocalStorage();
+ return {
+ getTenantId: () => tenantStorage.getStore()?.tenantId,
+ getUserId: () => tenantStorage.getStore()?.userId,
+ getRequestId: () => tenantStorage.getStore()?.requestId,
+ logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
+ tenantStorage,
+ };
+});
+
+// Mock @librechat/api — the real tenantContextMiddleware is TS and cannot be
+// required directly from CJS tests. This thin wrapper mirrors the real logic
+// (read request context, 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');
+ const normalizeAuthLogValue = (value) => {
+ if (value == null) {
+ return undefined;
+ }
+ if (Array.isArray(value)) {
+ for (const entry of value) {
+ const normalized = normalizeAuthLogValue(entry);
+ if (normalized) {
+ return normalized;
+ }
+ }
+ return undefined;
+ }
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ return trimmed || undefined;
+ }
+ if (typeof value === 'number' || typeof value === 'boolean') {
+ return String(value);
+ }
+ return undefined;
+ };
+ const normalizeAuthLogContextValue = (value) => {
+ if (value == null) {
+ return undefined;
+ }
+ if (Array.isArray(value)) {
+ const values = value
+ .map((entry) => normalizeAuthLogValue(entry))
+ .filter((entry) => entry !== undefined);
+ return values.length > 0 ? values : undefined;
+ }
+ if (typeof value === 'string') {
+ return normalizeAuthLogValue(value);
+ }
+ if (typeof value === 'number') {
+ return Number.isFinite(value) ? value : undefined;
+ }
+ if (typeof value === 'boolean') {
+ return value;
+ }
+ return undefined;
+ };
+ const getAuthFailureField = (source, field) => {
+ if (!source) {
+ return undefined;
+ }
+ if (typeof source === 'string') {
+ return field === 'message' ? source : undefined;
+ }
+ if (typeof source === 'object') {
+ try {
+ return source[field];
+ } catch {
+ return undefined;
+ }
+ }
+ return undefined;
+ };
+ const getAuthFailureReason = (err, info, fallback = 'Unauthorized') =>
+ normalizeAuthLogValue(getAuthFailureField(info, 'message')) ??
+ normalizeAuthLogValue(getAuthFailureField(err, 'message')) ??
+ fallback;
+ const getAuthFailureErrorName = (err, info) =>
+ normalizeAuthLogValue(getAuthFailureField(info, 'name')) ??
+ normalizeAuthLogValue(getAuthFailureField(err, 'name'));
+ const getSafeTokenProvider = (tokenProvider) => {
+ const normalized = normalizeAuthLogValue(tokenProvider);
+ if (!normalized) {
+ return undefined;
+ }
+ return normalized === 'openid' || normalized === 'librechat' ? normalized : 'other';
+ };
+ const normalizeRoutePath = (path) => {
+ if (typeof path === 'string') {
+ return normalizeAuthLogValue(path);
+ }
+ if (Array.isArray(path)) {
+ for (const entry of path) {
+ const normalized = normalizeRoutePath(entry);
+ if (normalized) {
+ return normalized;
+ }
+ }
+ }
+ return undefined;
+ };
+ const joinRoutePath = (baseUrl, routePath) => {
+ const normalizedRoute = routePath === '/' ? '' : routePath;
+ if (!baseUrl) {
+ return normalizedRoute || '/';
+ }
+ if (!normalizedRoute) {
+ return baseUrl;
+ }
+ return `${baseUrl.replace(/\/$/, '')}/${normalizedRoute.replace(/^\//, '')}`;
+ };
+ const bucketConcretePath = (path) => {
+ const queryless = path?.split('?')[0];
+ if (!queryless) {
+ return undefined;
+ }
+ const segments = queryless.split('/').filter(Boolean);
+ if (segments.length === 0) {
+ return '/';
+ }
+ if (segments[0] === 'api' && segments[1]) {
+ return `/${segments.slice(0, 2).join('/')}`;
+ }
+ return `/${segments[0]}`;
+ };
+ const getRequestPath = (req) => {
+ const baseUrl = normalizeAuthLogValue(req.baseUrl);
+ const routePath = normalizeRoutePath(req.route?.path);
+ if (routePath) {
+ return joinRoutePath(baseUrl, routePath);
+ }
+ if (baseUrl) {
+ return baseUrl;
+ }
+ const path =
+ normalizeAuthLogValue(req.path) ?? normalizeAuthLogValue(req.originalUrl ?? req.url);
+ return bucketConcretePath(path);
+ };
+ const compactAuthLogContext = (log) =>
+ Object.fromEntries(
+ Object.entries(log)
+ .map(([key, value]) => [key, normalizeAuthLogContextValue(value)])
+ .filter(([, value]) => value !== undefined),
+ );
+ const buildSafeAuthLogContext = (req, authState, extra = {}) =>
+ compactAuthLogContext({
+ ...extra,
+ request_id:
+ normalizeAuthLogValue(req.requestId) ??
+ normalizeAuthLogValue(req.id) ??
+ normalizeAuthLogValue(req.headers?.['x-request-id']) ??
+ normalizeAuthLogValue(req.headers?.['x-correlation-id']),
+ method: normalizeAuthLogValue(req.method),
+ path: getRequestPath(req),
+ token_provider: getSafeTokenProvider(authState.tokenProvider),
+ openid_reuse_enabled: authState.openidReuseEnabled,
+ openid_jwt_available: authState.openidJwtAvailable,
+ has_openid_reuse_user_id: authState.hasOpenIdReuseUserId,
+ });
+ const formatAuthLogMessage = (message, context) => `${message} ${JSON.stringify(context)}`;
+ const normalizeContextValue = (value) => {
+ const trimmed = value?.trim?.();
+ return trimmed || undefined;
+ };
+ const getUserId = (user) =>
+ normalizeContextValue(user?.id?.toString?.()) ?? normalizeContextValue(user?._id?.toString?.());
+ const getRequestId = (req) =>
+ normalizeContextValue(req.requestId) ??
+ normalizeContextValue(req.id) ??
+ normalizeContextValue(req.headers?.['x-request-id']) ??
+ normalizeContextValue(req.headers?.['x-correlation-id']);
+ return {
+ isEnabled: jest.fn(() => false),
+ recordRumProxyRequest: jest.fn(),
+ getAuthFailureReason,
+ getAuthFailureErrorName,
+ buildSafeAuthLogContext,
+ formatAuthLogMessage,
+ maybeRefreshCloudFrontAuthCookiesMiddleware: jest.fn((req, res, next) => next()),
+ tenantContextMiddleware: (req, res, next) => {
+ const context = {
+ tenantId: normalizeContextValue(req.user?.tenantId),
+ userId: getUserId(req.user),
+ requestId: getRequestId(req),
+ };
+ if (!context.tenantId && !context.userId && !context.requestId) {
+ return next();
+ }
+ return tenantStorage.run(context, async () => next());
+ },
+ };
+});
+
+// ── Helpers ─────────────────────────────────────────────────────────────
+
+const requireJwtAuth = require('../requireJwtAuth');
+const { requireRumProxyAuth } = requireJwtAuth;
+const { getTenantId, getUserId, logger } = require('@librechat/data-schemas');
+const {
+ isEnabled,
+ maybeRefreshCloudFrontAuthCookiesMiddleware,
+ recordRumProxyRequest,
+} = require('@librechat/api');
+const passport = require('passport');
+
+const jwtSecret = 'test-refresh-secret';
+
+function mockReq(user, extra = {}) {
+ return { headers: {}, _mockUser: user, ...extra };
+}
+
+function signedOpenIdUserCookie(userId = 'user-openid') {
+ return jwt.sign({ id: userId }, jwtSecret);
+}
+
+function mockRes() {
+ return {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ end: 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', () => {
+ const originalJwtSecret = process.env.JWT_REFRESH_SECRET;
+
+ beforeEach(() => {
+ process.env.JWT_REFRESH_SECRET = jwtSecret;
+ });
+
+ afterEach(() => {
+ mockPassportError = null;
+ mockRegisteredStrategies = new Set(['jwt']);
+ isEnabled.mockReturnValue(false);
+ maybeRefreshCloudFrontAuthCookiesMiddleware.mockClear();
+ logger.debug.mockClear();
+ logger.info.mockClear();
+ logger.warn.mockClear();
+ logger.error.mockClear();
+ recordRumProxyRequest.mockClear();
+ passport.authenticate.mockClear();
+ passport._strategy.mockClear();
+ if (originalJwtSecret === undefined) {
+ delete process.env.JWT_REFRESH_SECRET;
+ } else {
+ process.env.JWT_REFRESH_SECRET = originalJwtSecret;
+ }
+ });
+
+ 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('refreshes CloudFront auth cookies after passport auth succeeds', () => {
+ const req = mockReq({ tenantId: 'tenant-abc', role: 'user' });
+ const res = mockRes();
+ const next = jest.fn();
+
+ requireJwtAuth(req, res, next);
+
+ expect(maybeRefreshCloudFrontAuthCookiesMiddleware).toHaveBeenCalledWith(
+ req,
+ res,
+ expect.any(Function),
+ );
+ expect(next).toHaveBeenCalled();
+ });
+
+ it('refreshes CloudFront auth cookies inside the request context', () => {
+ let observedContext;
+ maybeRefreshCloudFrontAuthCookiesMiddleware.mockImplementationOnce(
+ (_req, _res, middlewareNext) => {
+ observedContext = {
+ tenantId: getTenantId(),
+ userId: getUserId(),
+ };
+ middlewareNext();
+ },
+ );
+ const req = mockReq({ id: 'user-123', tenantId: 'tenant-abc', role: 'user' });
+ const res = mockRes();
+ const next = jest.fn();
+
+ requireJwtAuth(req, res, next);
+
+ expect(observedContext).toEqual({ tenantId: 'tenant-abc', userId: 'user-123' });
+ expect(next).toHaveBeenCalled();
+ });
+
+ it('ALS tenant context is NOT set when user has no tenantId', async () => {
+ const tenantId = await runAuth({ role: 'user' });
+ expect(tenantId).toBeUndefined();
+ });
+
+ it('returns 401 when no strategy authenticates a user', async () => {
+ const req = mockReq(undefined);
+ const res = mockRes();
+ const next = jest.fn();
+
+ requireJwtAuth(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(getTenantId()).toBeUndefined();
+ expect(logger.debug).toHaveBeenCalledWith(
+ expect.stringContaining('[requireJwtAuth] Authentication failed after all strategies'),
+ expect.objectContaining({
+ primary_strategy: 'jwt',
+ fallback_attempted: false,
+ fallback_succeeded: false,
+ attempted_strategies: ['jwt'],
+ final_strategy: 'jwt',
+ reason: 'Unauthorized',
+ status: 401,
+ }),
+ );
+ expect(logger.warn).not.toHaveBeenCalled();
+ });
+
+ it('logs OpenID JWT expiry when JWT fallback succeeds', () => {
+ isEnabled.mockReturnValue(true);
+ mockRegisteredStrategies.add('openidJwt');
+ const req = mockReq(undefined, {
+ requestId: 'req-expired-success',
+ method: 'GET',
+ path: '/api/messages',
+ headers: {
+ cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-jwt')}`,
+ },
+ _mockStrategies: {
+ openidJwt: {
+ user: false,
+ info: { message: 'jwt expired', name: 'TokenExpiredError' },
+ status: 401,
+ },
+ jwt: { user: { id: 'user-jwt', tenantId: 'tenant-jwt', role: 'user' } },
+ },
+ });
+ const res = mockRes();
+ const next = jest.fn();
+
+ requireJwtAuth(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.authStrategy).toBe('jwt');
+ expect(res.status).not.toHaveBeenCalled();
+ expect(logger.debug).toHaveBeenCalledWith(
+ expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
+ expect.objectContaining({
+ request_id: 'req-expired-success',
+ method: 'GET',
+ path: '/api/messages',
+ token_provider: 'openid',
+ openid_reuse_enabled: true,
+ openid_jwt_available: true,
+ has_openid_reuse_user_id: true,
+ primary_strategy: 'openidJwt',
+ fallback_strategy: 'jwt',
+ fallback_attempted: true,
+ reason: 'jwt expired',
+ error_name: 'TokenExpiredError',
+ status: 401,
+ }),
+ );
+ expect(logger.debug).toHaveBeenCalledWith(
+ expect.stringContaining('[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure'),
+ expect.objectContaining({
+ request_id: 'req-expired-success',
+ auth_strategy: 'jwt',
+ primary_strategy: 'openidJwt',
+ fallback_strategy: 'jwt',
+ fallback_attempted: true,
+ fallback_succeeded: true,
+ primary_failure_reason: 'jwt expired',
+ reason: 'jwt expired',
+ error_name: 'TokenExpiredError',
+ }),
+ );
+ expect(logger.debug.mock.calls[0][0]).toContain('"reason":"jwt expired"');
+ expect(logger.debug.mock.calls[0][0]).toContain('"fallback_attempted":true');
+ expect(logger.debug.mock.calls[1][0]).toContain('"fallback_succeeded":true');
+ expect(logger.warn).not.toHaveBeenCalled();
+ });
+
+ it('does not let malformed Passport info break JWT fallback logging', () => {
+ isEnabled.mockReturnValue(true);
+ mockRegisteredStrategies.add('openidJwt');
+ const info = {};
+ Object.defineProperties(info, {
+ message: {
+ get() {
+ throw new TypeError('message getter failed');
+ },
+ },
+ name: {
+ get() {
+ throw new TypeError('name getter failed');
+ },
+ },
+ });
+ const req = mockReq(undefined, {
+ requestId: 'req-malformed-info',
+ method: 'GET',
+ path: '/api/messages',
+ headers: {
+ cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-jwt')}`,
+ },
+ _mockStrategies: {
+ openidJwt: {
+ user: false,
+ info,
+ status: 401,
+ },
+ jwt: { user: { id: 'user-jwt', tenantId: 'tenant-jwt', role: 'user' } },
+ },
+ });
+ const res = mockRes();
+ const next = jest.fn();
+
+ expect(() => requireJwtAuth(req, res, next)).not.toThrow();
+
+ expect(next).toHaveBeenCalled();
+ expect(req.authStrategy).toBe('jwt');
+ expect(res.status).not.toHaveBeenCalled();
+ expect(logger.debug).toHaveBeenCalledWith(
+ expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
+ expect.objectContaining({
+ request_id: 'req-malformed-info',
+ fallback_attempted: true,
+ reason: 'Unauthorized',
+ status: 401,
+ }),
+ );
+ expect(logger.debug).toHaveBeenCalledWith(
+ expect.stringContaining('[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure'),
+ expect.objectContaining({
+ request_id: 'req-malformed-info',
+ fallback_succeeded: true,
+ primary_failure_reason: 'Unauthorized',
+ }),
+ );
+ });
+
+ it('logs OpenID JWT expiry when JWT fallback fails', () => {
+ isEnabled.mockReturnValue(true);
+ mockRegisteredStrategies.add('openidJwt');
+ const req = mockReq(undefined, {
+ id: 'req-expired-fail',
+ method: 'POST',
+ originalUrl: '/api/ask?access_token=hidden',
+ headers: {
+ cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-jwt')}`,
+ },
+ _mockStrategies: {
+ openidJwt: {
+ user: false,
+ info: { message: 'jwt expired', name: 'TokenExpiredError' },
+ status: 401,
+ },
+ jwt: {
+ user: false,
+ info: { message: 'invalid signature', name: 'JsonWebTokenError' },
+ status: 401,
+ },
+ },
+ });
+ const res = mockRes();
+ const next = jest.fn();
+
+ requireJwtAuth(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(logger.debug).toHaveBeenCalledWith(
+ expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
+ expect.objectContaining({
+ request_id: 'req-expired-fail',
+ method: 'POST',
+ path: '/api/ask',
+ fallback_attempted: true,
+ reason: 'jwt expired',
+ error_name: 'TokenExpiredError',
+ status: 401,
+ }),
+ );
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('[requireJwtAuth] Authentication failed after all strategies'),
+ expect.objectContaining({
+ request_id: 'req-expired-fail',
+ method: 'POST',
+ path: '/api/ask',
+ token_provider: 'openid',
+ attempted_strategies: ['openidJwt', 'jwt'],
+ final_strategy: 'jwt',
+ primary_strategy: 'openidJwt',
+ fallback_strategy: 'jwt',
+ fallback_attempted: true,
+ fallback_succeeded: false,
+ reason: 'invalid signature',
+ error_name: 'JsonWebTokenError',
+ status: 401,
+ }),
+ );
+ expect(logger.warn.mock.calls[0][0]).toContain('"reason":"invalid signature"');
+ expect(logger.warn.mock.calls[0][0]).toContain('"path":"/api/ask"');
+ });
+
+ it('does not fall back to OpenID JWT for bearer-only reuse requests', () => {
+ isEnabled.mockReturnValue(true);
+ mockRegisteredStrategies.add('openidJwt');
+ const req = mockReq(undefined, {
+ _mockStrategies: {
+ jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
+ openidJwt: { user: { tenantId: 'tenant-openid', role: 'user' } },
+ },
+ });
+ const res = mockRes();
+ const next = jest.fn();
+
+ requireJwtAuth(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(req.authStrategy).toBeUndefined();
+ expect(passport.authenticate).toHaveBeenCalledTimes(1);
+ expect(passport.authenticate).toHaveBeenCalledWith(
+ 'jwt',
+ { session: false },
+ expect.any(Function),
+ );
+ });
+
+ it('uses OpenID JWT before LibreChat JWT when the OpenID cookie is present', async () => {
+ isEnabled.mockReturnValue(true);
+ mockRegisteredStrategies.add('openidJwt');
+ const req = mockReq(undefined, {
+ headers: { cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie()}` },
+ _mockStrategies: {
+ openidJwt: { user: { id: 'user-openid', tenantId: 'tenant-openid', role: 'user' } },
+ jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
+ },
+ });
+ const res = mockRes();
+ const tenantId = await new Promise((resolve) => {
+ requireJwtAuth(req, res, () => {
+ resolve(getTenantId());
+ });
+ });
+
+ expect(tenantId).toBe('tenant-openid');
+ expect(req.authStrategy).toBe('openidJwt');
+ expect(res.status).not.toHaveBeenCalled();
+ expect(passport.authenticate).toHaveBeenCalledWith(
+ 'openidJwt',
+ { session: false },
+ expect.any(Function),
+ );
+ expect(maybeRefreshCloudFrontAuthCookiesMiddleware).toHaveBeenCalledWith(
+ req,
+ res,
+ expect.any(Function),
+ );
+ });
+
+ it('logs OpenID user-id mismatch when JWT fallback succeeds', () => {
+ isEnabled.mockReturnValue(true);
+ mockRegisteredStrategies.add('openidJwt');
+ const req = mockReq(undefined, {
+ requestId: 'req-mismatch-success',
+ method: 'GET',
+ path: '/api/auth/me',
+ headers: {
+ cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-a')}`,
+ },
+ _mockStrategies: {
+ openidJwt: { user: { id: 'user-b', tenantId: 'tenant-openid', role: 'user' } },
+ jwt: { user: { id: 'user-a', tenantId: 'tenant-jwt', role: 'user' } },
+ },
+ });
+ const res = mockRes();
+ const next = jest.fn();
+
+ requireJwtAuth(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.authStrategy).toBe('jwt');
+ expect(logger.debug).toHaveBeenCalledWith(
+ expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
+ expect.objectContaining({
+ request_id: 'req-mismatch-success',
+ primary_strategy: 'openidJwt',
+ fallback_strategy: 'jwt',
+ fallback_attempted: true,
+ reason: 'openid user-id mismatch',
+ status: 401,
+ }),
+ );
+ expect(logger.debug).toHaveBeenCalledWith(
+ expect.stringContaining('[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure'),
+ expect.objectContaining({
+ request_id: 'req-mismatch-success',
+ auth_strategy: 'jwt',
+ fallback_attempted: true,
+ fallback_succeeded: true,
+ primary_failure_reason: 'openid user-id mismatch',
+ reason: 'openid user-id mismatch',
+ }),
+ );
+ expect(logger.warn).not.toHaveBeenCalled();
+ });
+
+ it('logs OpenID user-id mismatch when JWT fallback fails', () => {
+ isEnabled.mockReturnValue(true);
+ mockRegisteredStrategies.add('openidJwt');
+ const req = mockReq(undefined, {
+ requestId: 'req-mismatch-fail',
+ method: 'GET',
+ path: '/api/auth/me',
+ headers: {
+ cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-a')}`,
+ },
+ _mockStrategies: {
+ openidJwt: { user: { id: 'user-b', tenantId: 'tenant-openid', role: 'user' } },
+ jwt: { user: false, info: { message: 'Unauthorized' }, status: 401 },
+ },
+ });
+ const res = mockRes();
+ const next = jest.fn();
+
+ requireJwtAuth(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(logger.debug).toHaveBeenCalledWith(
+ expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
+ expect.objectContaining({
+ request_id: 'req-mismatch-fail',
+ fallback_attempted: true,
+ reason: 'openid user-id mismatch',
+ status: 401,
+ }),
+ );
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('[requireJwtAuth] Authentication failed after all strategies'),
+ expect.objectContaining({
+ request_id: 'req-mismatch-fail',
+ attempted_strategies: ['openidJwt', 'jwt'],
+ final_strategy: 'jwt',
+ fallback_attempted: true,
+ fallback_succeeded: false,
+ reason: 'Unauthorized',
+ status: 401,
+ }),
+ );
+ });
+
+ it('does not authenticate OpenID JWT when the reuse cookie belongs to another user', () => {
+ isEnabled.mockReturnValue(true);
+ mockRegisteredStrategies.add('openidJwt');
+ const req = mockReq(undefined, {
+ headers: {
+ cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-a')}`,
+ },
+ _mockStrategies: {
+ openidJwt: { user: { id: 'user-b', tenantId: 'tenant-openid', role: 'user' } },
+ jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
+ },
+ });
+ const res = mockRes();
+ const next = jest.fn();
+
+ requireJwtAuth(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(req.authStrategy).toBeUndefined();
+ expect(passport.authenticate).toHaveBeenCalledTimes(2);
+ expect(passport.authenticate).toHaveBeenNthCalledWith(
+ 1,
+ 'openidJwt',
+ { session: false },
+ expect.any(Function),
+ );
+ expect(passport.authenticate).toHaveBeenNthCalledWith(
+ 2,
+ 'jwt',
+ { session: false },
+ expect.any(Function),
+ );
+ expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled();
+ });
+
+ it('does not use OpenID JWT when the signed OpenID reuse cookie is missing', () => {
+ isEnabled.mockReturnValue(true);
+ mockRegisteredStrategies.add('openidJwt');
+ const req = mockReq(undefined, {
+ headers: { cookie: 'token_provider=openid' },
+ _mockStrategies: {
+ jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
+ openidJwt: { user: { tenantId: 'tenant-openid', role: 'user' } },
+ },
+ });
+ const res = mockRes();
+ const next = jest.fn();
+
+ requireJwtAuth(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(req.authStrategy).toBeUndefined();
+ expect(passport.authenticate).toHaveBeenCalledTimes(1);
+ expect(passport.authenticate).toHaveBeenCalledWith(
+ 'jwt',
+ { session: false },
+ expect.any(Function),
+ );
+ expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled();
+ });
+
+ it('does not use OpenID JWT when the OpenID reuse cookie is invalid', () => {
+ isEnabled.mockReturnValue(true);
+ mockRegisteredStrategies.add('openidJwt');
+ const req = mockReq(undefined, {
+ headers: { cookie: 'token_provider=openid; openid_user_id=invalid-jwt' },
+ _mockStrategies: {
+ jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
+ openidJwt: { user: { tenantId: 'tenant-openid', role: 'user' } },
+ },
+ });
+ const res = mockRes();
+ const next = jest.fn();
+
+ requireJwtAuth(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(req.authStrategy).toBeUndefined();
+ expect(passport.authenticate).toHaveBeenCalledTimes(1);
+ expect(passport.authenticate).toHaveBeenCalledWith(
+ 'jwt',
+ { session: false },
+ expect.any(Function),
+ );
+ expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled();
+ });
+
+ it('skips OpenID JWT fallback when the strategy was not registered', async () => {
+ isEnabled.mockReturnValue(true);
+ const req = mockReq(undefined, {
+ _mockStrategies: {
+ jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
+ openidJwt: { user: { tenantId: 'tenant-openid', role: 'user' } },
+ },
+ });
+ const res = mockRes();
+ const next = jest.fn();
+
+ requireJwtAuth(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(req.authStrategy).toBeUndefined();
+ expect(passport.authenticate).toHaveBeenCalledTimes(1);
+ expect(passport.authenticate).toHaveBeenCalledWith(
+ 'jwt',
+ { session: false },
+ expect.any(Function),
+ );
+ });
+
+ 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();
+ });
+});
+
+describe('requireRumProxyAuth', () => {
+ const originalJwtSecret = process.env.JWT_REFRESH_SECRET;
+
+ beforeEach(() => {
+ process.env.JWT_REFRESH_SECRET = jwtSecret;
+ });
+
+ afterEach(() => {
+ mockPassportError = null;
+ mockRegisteredStrategies = new Set(['jwt']);
+ isEnabled.mockReturnValue(false);
+ maybeRefreshCloudFrontAuthCookiesMiddleware.mockClear();
+ logger.debug.mockClear();
+ logger.info.mockClear();
+ logger.warn.mockClear();
+ logger.error.mockClear();
+ recordRumProxyRequest.mockClear();
+ passport.authenticate.mockClear();
+ passport._strategy.mockClear();
+ if (originalJwtSecret === undefined) {
+ delete process.env.JWT_REFRESH_SECRET;
+ } else {
+ process.env.JWT_REFRESH_SECRET = originalJwtSecret;
+ }
+ });
+
+ it('authenticates telemetry with the LibreChat JWT strategy without tenant or cookie refresh middleware', () => {
+ const req = mockReq({ id: 'user-jwt', tenantId: 'tenant-jwt', role: 'user' });
+ const res = mockRes();
+ const next = jest.fn();
+
+ requireRumProxyAuth(req, res, next);
+
+ expect(passport.authenticate).toHaveBeenCalledWith(
+ 'jwt',
+ { session: false },
+ expect.any(Function),
+ );
+ expect(req.authStrategy).toBe('jwt');
+ expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled();
+ // Success is recorded by the proxy.
+ expect(recordRumProxyRequest).not.toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ expect(next).toHaveBeenCalled();
+ });
+
+ it('authenticates telemetry with OpenID JWT reuse when the reuse cookie is present', () => {
+ isEnabled.mockReturnValue(true);
+ mockRegisteredStrategies.add('openidJwt');
+ const req = mockReq(undefined, {
+ headers: { cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie()}` },
+ _mockStrategies: {
+ openidJwt: { user: { id: 'user-openid', tenantId: 'tenant-openid', role: 'user' } },
+ jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
+ },
+ });
+ const res = mockRes();
+ const next = jest.fn();
+
+ requireRumProxyAuth(req, res, next);
+
+ expect(passport.authenticate).toHaveBeenCalledWith(
+ 'openidJwt',
+ { session: false },
+ expect.any(Function),
+ );
+ expect(req.authStrategy).toBe('openidJwt');
+ expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled();
+ expect(recordRumProxyRequest).not.toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ expect(next).toHaveBeenCalled();
+ });
+
+ it('falls back to LibreChat JWT when OpenID JWT telemetry auth fails', () => {
+ isEnabled.mockReturnValue(true);
+ mockRegisteredStrategies.add('openidJwt');
+ const req = mockReq(undefined, {
+ headers: { cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie()}` },
+ _mockStrategies: {
+ openidJwt: {
+ user: false,
+ info: { message: 'jwt expired', name: 'TokenExpiredError' },
+ status: 401,
+ },
+ jwt: { user: { id: 'user-openid', tenantId: 'tenant-jwt', role: 'user' } },
+ },
+ });
+ const res = mockRes();
+ const next = jest.fn();
+
+ requireRumProxyAuth(req, res, next);
+
+ expect(passport.authenticate).toHaveBeenCalledTimes(2);
+ expect(req.authStrategy).toBe('jwt');
+ expect(recordRumProxyRequest).not.toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ expect(next).toHaveBeenCalled();
+ });
+
+ it('drops invalid telemetry auth with 204 instead of returning an app auth error', () => {
+ const req = mockReq(undefined, {
+ path: '/v1/traces',
+ _mockStrategies: {
+ jwt: {
+ user: false,
+ info: { message: 'invalid signature', name: 'JsonWebTokenError' },
+ status: 401,
+ },
+ },
+ });
+ const res = mockRes();
+ const next = jest.fn();
+
+ requireRumProxyAuth(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled();
+ expect(recordRumProxyRequest).toHaveBeenCalledWith('traces', 'auth_drop');
+ expect(res.status).toHaveBeenCalledWith(204);
+ expect(res.end).toHaveBeenCalled();
+ });
+
+ it('records passport errors separately from ordinary telemetry auth drops', () => {
+ mockPassportError = new Error('passport unavailable');
+ const req = mockReq(undefined, { path: '/v1/logs' });
+ const res = mockRes();
+ const next = jest.fn();
+
+ requireRumProxyAuth(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(logger.warn).not.toHaveBeenCalled();
+ expect(recordRumProxyRequest).toHaveBeenCalledWith('logs', 'auth_error');
+ expect(res.status).toHaveBeenCalledWith(204);
+ expect(res.end).toHaveBeenCalled();
+ });
+});
diff --git a/api/server/middleware/__tests__/validateMessageReq.spec.js b/api/server/middleware/__tests__/validateMessageReq.spec.js
new file mode 100644
index 0000000..2570c9a
--- /dev/null
+++ b/api/server/middleware/__tests__/validateMessageReq.spec.js
@@ -0,0 +1,240 @@
+jest.mock('~/models', () => ({
+ getConvo: jest.fn(),
+}));
+
+jest.mock('@librechat/api', () => ({
+ createMessageRequestMiddleware:
+ jest.requireActual('@librechat/api').createMessageRequestMiddleware,
+ GenerationJobManager: {
+ getJob: jest.fn(),
+ },
+ isPendingActionStale: jest.fn(() => false),
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: {
+ warn: jest.fn(),
+ },
+}));
+
+const validateMessageReq = require('../validateMessageReq');
+const { getConvo } = require('~/models');
+const { GenerationJobManager } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+
+function createResponse() {
+ const res = {
+ json: jest.fn(),
+ send: jest.fn(),
+ status: jest.fn(),
+ };
+ res.status.mockReturnValue(res);
+ return res;
+}
+
+describe('validateMessageReq', () => {
+ const userId = 'user-123';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should reject requests when URL and body conversationId values differ', async () => {
+ const req = {
+ params: { conversationId: 'convo-owned' },
+ body: { conversationId: 'convo-victim' },
+ user: { id: userId },
+ };
+ const res = createResponse();
+ const next = jest.fn();
+
+ await validateMessageReq(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({ error: 'Conversation ID mismatch' });
+ expect(getConvo).not.toHaveBeenCalled();
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should reject requests when URL and nested message conversationId values differ', async () => {
+ const req = {
+ params: { conversationId: 'convo-owned' },
+ body: { message: { conversationId: 'convo-victim' } },
+ user: { id: userId },
+ };
+ const res = createResponse();
+ const next = jest.fn();
+
+ await validateMessageReq(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({ error: 'Conversation ID mismatch' });
+ expect(getConvo).not.toHaveBeenCalled();
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should validate ownership against the URL conversationId when values match', async () => {
+ const req = {
+ params: { conversationId: 'convo-owned' },
+ body: { conversationId: 'convo-owned' },
+ user: { id: userId },
+ };
+ const res = createResponse();
+ const next = jest.fn();
+ getConvo.mockResolvedValue({ conversationId: 'convo-owned', user: userId });
+
+ await validateMessageReq(req, res, next);
+
+ expect(getConvo).toHaveBeenCalledWith(userId, 'convo-owned');
+ expect(next).toHaveBeenCalledTimes(1);
+ });
+
+ it('should allow message reads for an owned active generation job before the conversation is saved', async () => {
+ const req = {
+ method: 'GET',
+ params: { conversationId: 'active-convo' },
+ body: {},
+ user: { id: userId, tenantId: 'tenant-a' },
+ };
+ const res = createResponse();
+ const next = jest.fn();
+ getConvo.mockResolvedValue(null);
+ GenerationJobManager.getJob.mockResolvedValue({
+ status: 'running',
+ metadata: { userId, tenantId: 'tenant-a' },
+ });
+
+ await validateMessageReq(req, res, next);
+
+ expect(GenerationJobManager.getJob).toHaveBeenCalledWith('active-convo');
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ it('should allow message reads for an owned active generation job without tenant metadata', async () => {
+ const req = {
+ method: 'GET',
+ params: { conversationId: 'active-convo' },
+ body: {},
+ user: { id: userId },
+ };
+ const res = createResponse();
+ const next = jest.fn();
+ getConvo.mockResolvedValue(null);
+ GenerationJobManager.getJob.mockResolvedValue({
+ status: 'running',
+ metadata: { userId },
+ });
+
+ await validateMessageReq(req, res, next);
+
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ it('should reject active job message reads owned by another user', async () => {
+ const req = {
+ method: 'GET',
+ params: { conversationId: 'active-convo' },
+ body: {},
+ user: { id: userId },
+ };
+ const res = createResponse();
+ const next = jest.fn();
+ getConvo.mockResolvedValue(null);
+ GenerationJobManager.getJob.mockResolvedValue({
+ status: 'running',
+ metadata: { userId: 'another-user' },
+ });
+
+ await validateMessageReq(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({ error: 'Conversation not found' });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should reject active job message reads from another tenant', async () => {
+ const req = {
+ method: 'GET',
+ params: { conversationId: 'active-convo' },
+ body: {},
+ user: { id: userId, tenantId: 'tenant-a' },
+ };
+ const res = createResponse();
+ const next = jest.fn();
+ getConvo.mockResolvedValue(null);
+ GenerationJobManager.getJob.mockResolvedValue({
+ status: 'running',
+ metadata: { userId, tenantId: 'tenant-b' },
+ });
+
+ await validateMessageReq(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({ error: 'Conversation not found' });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should reject message-by-id reads before the conversation is saved', async () => {
+ const req = {
+ method: 'GET',
+ params: { conversationId: 'active-convo', messageId: 'message-id' },
+ body: {},
+ user: { id: userId },
+ };
+ const res = createResponse();
+ const next = jest.fn();
+ getConvo.mockResolvedValue(null);
+
+ await validateMessageReq(req, res, next);
+
+ expect(GenerationJobManager.getJob).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({ error: 'Conversation not found' });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should return not found when active job lookup fails', async () => {
+ const req = {
+ method: 'GET',
+ params: { conversationId: 'active-convo' },
+ body: {},
+ user: { id: userId },
+ };
+ const res = createResponse();
+ const next = jest.fn();
+ const error = new Error('job store unavailable');
+ getConvo.mockResolvedValue(null);
+ GenerationJobManager.getJob.mockRejectedValue(error);
+
+ await validateMessageReq(req, res, next);
+
+ expect(GenerationJobManager.getJob).toHaveBeenCalledWith('active-convo');
+ expect(logger.warn).toHaveBeenCalledWith(
+ '[validateMessageReq] Active job lookup failed for active-convo:',
+ error,
+ );
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({ error: 'Conversation not found' });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should not allow unsaved conversation writes through active job ownership', async () => {
+ const req = {
+ method: 'POST',
+ params: { conversationId: 'active-convo' },
+ body: {},
+ user: { id: userId },
+ };
+ const res = createResponse();
+ const next = jest.fn();
+ getConvo.mockResolvedValue(null);
+
+ await validateMessageReq(req, res, next);
+
+ expect(GenerationJobManager.getJob).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(next).not.toHaveBeenCalled();
+ });
+});
diff --git a/api/server/middleware/__tests__/validateModel.spec.js b/api/server/middleware/__tests__/validateModel.spec.js
new file mode 100644
index 0000000..634baee
--- /dev/null
+++ b/api/server/middleware/__tests__/validateModel.spec.js
@@ -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 = '';
+ 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' });
+ });
+ });
+});
diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js
new file mode 100644
index 0000000..feed002
--- /dev/null
+++ b/api/server/middleware/abortMiddleware.js
@@ -0,0 +1,322 @@
+const { logger } = require('@librechat/data-schemas');
+const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider');
+const {
+ isEnabled,
+ sendEvent,
+ countTokens,
+ GenerationJobManager,
+ recordCollectedUsage,
+ sanitizeMessageForTransmit,
+ buildAbortedResponseMetadata,
+} = require('@librechat/api');
+const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
+const clearPendingReq = require('~/cache/clearPendingReq');
+const { sendError } = require('~/server/middleware/error');
+const { abortRun } = require('./abortRun');
+const db = require('~/models');
+
+/**
+ * @param {Error | unknown} error
+ * @returns {boolean}
+ */
+const isAbortError = (error) => {
+ const visited = new Set();
+ let current = error;
+
+ while (current && typeof current === 'object' && !visited.has(current)) {
+ visited.add(current);
+
+ const errorName = current.name;
+ const errorCode = current.code;
+ const errorMessage = typeof current.message === 'string' ? current.message : '';
+
+ if (
+ errorName === 'AbortError' ||
+ errorCode === 'ABORT_ERR' ||
+ errorMessage.includes('AbortError') ||
+ /(?:operation|request|stream) was aborted/i.test(errorMessage)
+ ) {
+ return true;
+ }
+
+ current = current.cause;
+ }
+
+ return false;
+};
+
+/**
+ * Spend tokens for all models from collected usage.
+ * This handles both sequential and parallel agent execution.
+ *
+ * IMPORTANT: After spending, this function clears the collectedUsage array
+ * to prevent double-spending. The array is shared with AgentClient.collectedUsage,
+ * so clearing it here prevents the finally block from also spending tokens.
+ *
+ * @param {Object} params
+ * @param {string} params.userId - User ID
+ * @param {string} params.conversationId - Conversation ID
+ * @param {Array} params.collectedUsage - Usage metadata from all models
+ * @param {string} [params.fallbackModel] - Fallback model name if not in usage
+ * @param {string} [params.messageId] - The response message ID for transaction correlation
+ */
+async function spendCollectedUsage({
+ userId,
+ conversationId,
+ collectedUsage,
+ fallbackModel,
+ messageId,
+}) {
+ if (!collectedUsage || collectedUsage.length === 0) {
+ return;
+ }
+
+ await recordCollectedUsage(
+ {
+ spendTokens: db.spendTokens,
+ spendStructuredTokens: db.spendStructuredTokens,
+ pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
+ bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
+ },
+ {
+ user: userId,
+ conversationId,
+ collectedUsage,
+ context: 'abort',
+ messageId,
+ model: fallbackModel,
+ },
+ );
+
+ // Clear the array to prevent double-spending from the AgentClient finally block.
+ // The collectedUsage array is shared by reference with AgentClient.collectedUsage,
+ // so clearing it here ensures recordCollectedUsage() sees an empty array and returns early.
+ collectedUsage.length = 0;
+}
+
+/**
+ * Abort an active message generation.
+ * Uses GenerationJobManager for all agent requests.
+ * Since streamId === conversationId, we can directly abort by conversationId.
+ */
+async function abortMessage(req, res) {
+ const { abortKey, endpoint } = req.body;
+
+ if (isAssistantsEndpoint(endpoint)) {
+ return await abortRun(req, res);
+ }
+
+ const conversationId = abortKey?.split(':')?.[0] ?? req.user.id;
+ const userId = req.user.id;
+
+ // Use GenerationJobManager to abort the job (streamId === conversationId)
+ const abortResult = await GenerationJobManager.abortJob(conversationId);
+
+ if (!abortResult.success) {
+ if (!res.headersSent) {
+ return res.status(204).send({ message: 'Request not found' });
+ }
+ return;
+ }
+
+ const { jobData, content, text, collectedUsage } = abortResult;
+
+ const completionTokens = await countTokens(text);
+ const promptTokens = jobData?.promptTokens ?? 0;
+
+ const responseMessage = {
+ messageId: jobData?.responseMessageId,
+ parentMessageId: jobData?.userMessage?.messageId,
+ conversationId: jobData?.conversationId,
+ content,
+ text,
+ sender: jobData?.sender ?? 'AI',
+ finish_reason: 'incomplete',
+ endpoint: jobData?.endpoint,
+ iconURL: jobData?.iconURL,
+ model: jobData?.model,
+ unfinished: false,
+ error: false,
+ isCreatedByUser: false,
+ tokenCount: completionTokens,
+ };
+
+ /** Persist the usage/cost rollup + context breakdown for the stopped response
+ * so its branch/total cost and granular rows survive a reload, matching the
+ * normal completion path. */
+ const abortMetadata = buildAbortedResponseMetadata(jobData);
+ if (abortMetadata) {
+ responseMessage.metadata = abortMetadata;
+ }
+
+ // Spend tokens for ALL models from collectedUsage (handles parallel agents/addedConvo)
+ if (collectedUsage && collectedUsage.length > 0) {
+ await spendCollectedUsage({
+ userId,
+ conversationId: jobData?.conversationId,
+ collectedUsage,
+ fallbackModel: jobData?.model,
+ messageId: jobData?.responseMessageId,
+ });
+ } else {
+ // Fallback: no collected usage, use text-based token counting for primary model only
+ await db.spendTokens(
+ { ...responseMessage, context: 'incomplete', user: userId },
+ { promptTokens, completionTokens },
+ );
+ }
+
+ 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 db.getConvo(userId, conversationId);
+
+ const finalEvent = {
+ title: conversation && !conversation.title ? null : conversation?.title || 'New Chat',
+ final: true,
+ conversation,
+ requestMessage: jobData?.userMessage
+ ? sanitizeMessageForTransmit({
+ messageId: jobData.userMessage.messageId,
+ parentMessageId: jobData.userMessage.parentMessageId,
+ conversationId: jobData.userMessage.conversationId,
+ text: jobData.userMessage.text,
+ quotes: jobData.userMessage.quotes,
+ isCreatedByUser: true,
+ })
+ : null,
+ responseMessage,
+ };
+
+ logger.debug(
+ `[abortMessage] ID: ${userId} | ${req.user.email} | Aborted request: ${conversationId}`,
+ );
+
+ if (res.headersSent) {
+ return sendEvent(res, finalEvent);
+ }
+
+ res.setHeader('Content-Type', 'application/json');
+ res.send(JSON.stringify(finalEvent));
+}
+
+const handleAbort = function () {
+ return async function (req, res) {
+ try {
+ if (isEnabled(process.env.LIMIT_CONCURRENT_MESSAGES)) {
+ await clearPendingReq({ userId: req.user.id });
+ }
+ return await abortMessage(req, res);
+ } catch (err) {
+ logger.error('[abortMessage] handleAbort error', err);
+ }
+ };
+};
+
+/**
+ * Handle abort errors during generation.
+ * @param {ServerResponse} res
+ * @param {ServerRequest} req
+ * @param {Error | unknown} error
+ * @param {Partial & { partialText?: string }} data
+ * @returns {Promise}
+ */
+const handleAbortError = async (res, req, error, data) => {
+ const { sender, conversationId, messageId, parentMessageId, userMessageId, partialText } = data;
+
+ if (error?.message?.includes('base64')) {
+ logger.error('[handleAbortError] Error in base64 encoding', {
+ ...error,
+ stack: smartTruncateText(error?.stack, 1000),
+ message: truncateText(error.message, 350),
+ });
+ } else if (isAbortError(error)) {
+ logger.debug('[handleAbortError] AI response aborted by user', {
+ conversationId,
+ code: error?.code,
+ name: error?.name,
+ message: truncateText(error?.message ?? 'AbortError', 350),
+ });
+ } else {
+ logger.error('[handleAbortError] AI response error; aborting request:', error);
+ }
+
+ if (error?.stack && error.stack.includes('google')) {
+ logger.warn(
+ `AI Response error for conversation ${conversationId} likely caused by Google censor/filter`,
+ );
+ }
+
+ let errorText = error?.message?.includes('"type"')
+ ? error.message
+ : 'An error occurred while processing your request. Please contact the Admin.';
+
+ if (error?.type === ErrorTypes.INVALID_REQUEST) {
+ errorText = `{"type":"${ErrorTypes.INVALID_REQUEST}"}`;
+ }
+
+ if (error?.message?.includes("does not support 'system'")) {
+ errorText = `{"type":"${ErrorTypes.NO_SYSTEM_MESSAGES}"}`;
+ }
+
+ /**
+ * @param {string} partialText
+ * @returns {Promise}
+ */
+ const respondWithError = async (partialText) => {
+ const endpointOption = req.body?.endpointOption;
+ let options = {
+ sender,
+ messageId,
+ conversationId,
+ parentMessageId,
+ text: errorText,
+ user: req.user.id,
+ spec: endpointOption?.spec,
+ iconURL: endpointOption?.iconURL,
+ modelLabel: endpointOption?.modelLabel,
+ shouldSaveMessage: userMessageId != null,
+ model: endpointOption?.modelOptions?.model || req.body?.model,
+ };
+
+ if (req.body?.agent_id) {
+ options.agent_id = req.body.agent_id;
+ }
+
+ if (partialText) {
+ options = {
+ ...options,
+ error: false,
+ unfinished: true,
+ text: partialText,
+ };
+ }
+
+ await sendError(req, res, options);
+ };
+
+ if (partialText && partialText.length > 5) {
+ try {
+ return await abortMessage(req, res);
+ } catch (err) {
+ logger.error('[handleAbortError] error while trying to abort message', err);
+ return respondWithError(partialText);
+ }
+ } else {
+ return respondWithError();
+ }
+};
+
+module.exports = {
+ handleAbort,
+ handleAbortError,
+ spendCollectedUsage,
+};
diff --git a/api/server/middleware/abortMiddleware.spec.js b/api/server/middleware/abortMiddleware.spec.js
new file mode 100644
index 0000000..06e4340
--- /dev/null
+++ b/api/server/middleware/abortMiddleware.spec.js
@@ -0,0 +1,312 @@
+/**
+ * Tests for abortMiddleware - spendCollectedUsage function
+ *
+ * This tests the token spending logic for abort scenarios,
+ * particularly for parallel agents (addedConvo) where multiple
+ * models need their tokens spent.
+ *
+ * spendCollectedUsage delegates to recordCollectedUsage from @librechat/api,
+ * passing pricing + bulkWriteOps deps, with context: 'abort'.
+ * After spending, it clears the collectedUsage array to prevent double-spending
+ * from the AgentClient finally block (which shares the same array reference).
+ */
+
+const mockSpendTokens = jest.fn().mockResolvedValue();
+const mockSpendStructuredTokens = jest.fn().mockResolvedValue();
+const mockRecordCollectedUsage = jest
+ .fn()
+ .mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
+
+const mockGetMultiplier = jest.fn().mockReturnValue(1);
+const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: {
+ debug: jest.fn(),
+ error: jest.fn(),
+ warn: jest.fn(),
+ info: jest.fn(),
+ },
+}));
+
+jest.mock('@librechat/api', () => ({
+ countTokens: jest.fn().mockResolvedValue(100),
+ isEnabled: jest.fn().mockReturnValue(false),
+ sendEvent: jest.fn(),
+ GenerationJobManager: {
+ abortJob: jest.fn(),
+ },
+ recordCollectedUsage: mockRecordCollectedUsage,
+ sanitizeMessageForTransmit: jest.fn((msg) => msg),
+}));
+
+jest.mock('librechat-data-provider', () => ({
+ isAssistantsEndpoint: jest.fn().mockReturnValue(false),
+ ErrorTypes: { INVALID_REQUEST: 'INVALID_REQUEST', NO_SYSTEM_MESSAGES: 'NO_SYSTEM_MESSAGES' },
+}));
+
+jest.mock('~/app/clients/prompts', () => ({
+ truncateText: jest.fn((text) => text),
+ smartTruncateText: jest.fn((text) => text),
+}));
+
+jest.mock('~/cache/clearPendingReq', () => jest.fn().mockResolvedValue());
+
+jest.mock('~/server/middleware/error', () => ({
+ sendError: jest.fn(),
+}));
+
+const mockUpdateBalance = jest.fn().mockResolvedValue({});
+const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
+jest.mock('~/models', () => ({
+ saveMessage: jest.fn().mockResolvedValue(),
+ 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', () => ({
+ abortRun: jest.fn(),
+}));
+
+const { logger } = require('@librechat/data-schemas');
+const { sendError } = require('~/server/middleware/error');
+const { handleAbortError, spendCollectedUsage } = require('./abortMiddleware');
+
+const buildAbortRequest = () => ({
+ body: {
+ model: 'gpt-4',
+ },
+ user: {
+ id: 'user-123',
+ },
+});
+
+describe('abortMiddleware - spendCollectedUsage', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('spendCollectedUsage delegation', () => {
+ it('should return early if collectedUsage is empty', async () => {
+ await spendCollectedUsage({
+ userId: 'user-123',
+ conversationId: 'convo-123',
+ collectedUsage: [],
+ fallbackModel: 'gpt-4',
+ });
+
+ expect(mockRecordCollectedUsage).not.toHaveBeenCalled();
+ });
+
+ it('should return early if collectedUsage is null', async () => {
+ await spendCollectedUsage({
+ userId: 'user-123',
+ conversationId: 'convo-123',
+ collectedUsage: null,
+ fallbackModel: 'gpt-4',
+ });
+
+ expect(mockRecordCollectedUsage).not.toHaveBeenCalled();
+ });
+
+ it('should call recordCollectedUsage with abort context and full deps', async () => {
+ const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }];
+
+ await spendCollectedUsage({
+ userId: 'user-123',
+ conversationId: 'convo-123',
+ collectedUsage,
+ fallbackModel: 'gpt-4',
+ messageId: 'msg-123',
+ });
+
+ expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
+ expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
+ {
+ spendTokens: expect.any(Function),
+ spendStructuredTokens: expect.any(Function),
+ pricing: {
+ getMultiplier: mockGetMultiplier,
+ getCacheMultiplier: mockGetCacheMultiplier,
+ },
+ bulkWriteOps: {
+ insertMany: mockBulkInsertTransactions,
+ updateBalance: mockUpdateBalance,
+ },
+ },
+ {
+ user: 'user-123',
+ conversationId: 'convo-123',
+ collectedUsage,
+ context: 'abort',
+ messageId: 'msg-123',
+ model: 'gpt-4',
+ },
+ );
+ });
+
+ it('should pass context abort for multiple models (parallel agents)', async () => {
+ const collectedUsage = [
+ { input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
+ { input_tokens: 80, output_tokens: 40, model: 'claude-3' },
+ { input_tokens: 120, output_tokens: 60, model: 'gemini-pro' },
+ ];
+
+ await spendCollectedUsage({
+ userId: 'user-123',
+ conversationId: 'convo-123',
+ collectedUsage,
+ fallbackModel: 'gpt-4',
+ });
+
+ expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
+ expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.objectContaining({
+ context: 'abort',
+ collectedUsage,
+ }),
+ );
+ });
+
+ it('should handle real-world parallel agent abort scenario', async () => {
+ const collectedUsage = [
+ { input_tokens: 31596, output_tokens: 151, model: 'gemini-3-flash-preview' },
+ { input_tokens: 28000, output_tokens: 120, model: 'gpt-5.2' },
+ ];
+
+ await spendCollectedUsage({
+ userId: 'user-123',
+ conversationId: 'convo-123',
+ collectedUsage,
+ fallbackModel: 'gemini-3-flash-preview',
+ });
+
+ expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
+ expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.objectContaining({
+ user: 'user-123',
+ conversationId: 'convo-123',
+ context: 'abort',
+ model: 'gemini-3-flash-preview',
+ }),
+ );
+ });
+
+ /**
+ * Race condition prevention: after abort middleware spends tokens,
+ * the collectedUsage array is cleared so AgentClient.recordCollectedUsage()
+ * (which shares the same array reference) sees an empty array and returns early.
+ */
+ it('should clear collectedUsage array after spending to prevent double-spending', async () => {
+ const collectedUsage = [
+ { input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
+ { input_tokens: 80, output_tokens: 40, model: 'claude-3' },
+ ];
+
+ expect(collectedUsage.length).toBe(2);
+
+ await spendCollectedUsage({
+ userId: 'user-123',
+ conversationId: 'convo-123',
+ collectedUsage,
+ fallbackModel: 'gpt-4',
+ });
+
+ expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
+ expect(collectedUsage.length).toBe(0);
+ });
+
+ it('should await recordCollectedUsage before clearing array', async () => {
+ let resolved = false;
+ mockRecordCollectedUsage.mockImplementation(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ resolved = true;
+ return { input_tokens: 100, output_tokens: 50 };
+ });
+
+ const collectedUsage = [
+ { input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
+ { input_tokens: 80, output_tokens: 40, model: 'claude-3' },
+ ];
+
+ await spendCollectedUsage({
+ userId: 'user-123',
+ conversationId: 'convo-123',
+ collectedUsage,
+ fallbackModel: 'gpt-4',
+ });
+
+ expect(resolved).toBe(true);
+ expect(collectedUsage.length).toBe(0);
+ });
+ });
+});
+
+describe('abortMiddleware - handleAbortError', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it.each([
+ [
+ 'native DOMException AbortError',
+ new DOMException('The operation was aborted', 'AbortError'),
+ 'AbortError',
+ ],
+ [
+ 'wrapped AbortError message',
+ new Error('SSE stream disconnected: AbortError: The operation was aborted'),
+ 'Error',
+ ],
+ [
+ 'cause-nested AbortError',
+ new Error('Request failed', {
+ cause: new DOMException('The operation was aborted', 'AbortError'),
+ }),
+ 'Error',
+ ],
+ ])('logs a %s as a debug event instead of an error', async (_label, error, name) => {
+ await handleAbortError({}, buildAbortRequest(), error, {
+ sender: 'AI',
+ conversationId: 'convo-123',
+ messageId: 'message-123',
+ parentMessageId: 'parent-123',
+ userMessageId: 'user-message-123',
+ });
+
+ expect(logger.error).not.toHaveBeenCalled();
+ expect(logger.debug).toHaveBeenCalledWith('[handleAbortError] AI response aborted by user', {
+ conversationId: 'convo-123',
+ code: error.code,
+ name,
+ message: error.message,
+ });
+ expect(sendError).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps unexpected generation errors classified as errors', async () => {
+ const error = new Error('Provider failed');
+
+ await handleAbortError({}, buildAbortRequest(), error, {
+ sender: 'AI',
+ conversationId: 'convo-123',
+ messageId: 'message-123',
+ parentMessageId: 'parent-123',
+ userMessageId: 'user-message-123',
+ });
+
+ expect(logger.error).toHaveBeenCalledWith(
+ '[handleAbortError] AI response error; aborting request:',
+ error,
+ );
+ expect(logger.debug).not.toHaveBeenCalled();
+ expect(sendError).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/api/server/middleware/abortRun.js b/api/server/middleware/abortRun.js
new file mode 100644
index 0000000..318693f
--- /dev/null
+++ b/api/server/middleware/abortRun.js
@@ -0,0 +1,104 @@
+const { sendEvent } = require('@librechat/api');
+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, getConvo } = require('~/models');
+const getLogStores = require('~/cache/getLogStores');
+
+const three_minutes = 1000 * 60 * 3;
+
+async function abortRun(req, res) {
+ res.setHeader('Content-Type', 'application/json');
+ const { abortKey, endpoint } = req.body;
+ const [conversationId, latestMessageId] = abortKey.split(':');
+ const conversation = await getConvo(req.user.id, conversationId);
+
+ if (conversation?.model) {
+ req.body = req.body || {}; // Express 5: ensure req.body exists
+ req.body.model = conversation.model;
+ }
+
+ if (!isUUID.safeParse(conversationId).success) {
+ logger.error('[abortRun] Invalid conversationId', { conversationId });
+ return res.status(400).send({ message: 'Invalid conversationId' });
+ }
+
+ const cacheKey = `${req.user.id}:${conversationId}`;
+ const cache = getLogStores(CacheKeys.ABORT_KEYS);
+ const runValues = await cache.get(cacheKey);
+ if (!runValues) {
+ logger.warn('[abortRun] Run not found in cache', { cacheKey });
+ return res.status(204).send({ message: 'Run not found' });
+ }
+ const [thread_id, run_id] = runValues.split(':');
+
+ if (!run_id) {
+ logger.warn("[abortRun] Couldn't find run for cancel request", { thread_id });
+ return res.status(204).send({ message: 'Run not found' });
+ } else if (run_id === 'cancelled') {
+ logger.warn('[abortRun] Run already cancelled', { thread_id });
+ return res.status(204).send({ message: 'Run already cancelled' });
+ }
+
+ let runMessages = [];
+ /** @type {{ openai: OpenAI }} */
+ const { openai } = await initializeClient({ req, res });
+
+ try {
+ await cache.set(cacheKey, 'cancelled', three_minutes);
+ const cancelledRun = await openai.beta.threads.runs.cancel(run_id, { thread_id });
+ logger.debug('[abortRun] Cancelled run:', cancelledRun);
+ } catch (error) {
+ logger.error('[abortRun] Error cancelling run', error);
+ if (
+ error?.message?.includes(RunStatus.CANCELLED) ||
+ error?.message?.includes(RunStatus.CANCELLING)
+ ) {
+ return res.end();
+ }
+ }
+
+ try {
+ const run = await openai.beta.threads.runs.retrieve(run_id, { thread_id });
+ await recordUsage({
+ ...run.usage,
+ model: run.model,
+ user: req.user.id,
+ conversationId,
+ });
+ } catch (error) {
+ logger.error('[abortRun] Error fetching or processing run', error);
+ }
+
+ /* TODO: a reconciling strategy between the existing intermediate message would be more optimal than deleting it */
+ await deleteMessages({
+ user: req.user.id,
+ unfinished: true,
+ conversationId,
+ });
+ runMessages = await checkMessageGaps({
+ openai,
+ run_id,
+ endpoint,
+ thread_id,
+ conversationId,
+ latestMessageId,
+ });
+
+ const finalEvent = {
+ final: true,
+ conversation,
+ runMessages,
+ };
+
+ if (res.headersSent && finalEvent) {
+ return sendEvent(res, finalEvent);
+ }
+
+ res.json(finalEvent);
+}
+
+module.exports = {
+ abortRun,
+};
diff --git a/api/server/middleware/accessResources/canAccessAgentFromBody.js b/api/server/middleware/accessResources/canAccessAgentFromBody.js
new file mode 100644
index 0000000..5ade76b
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessAgentFromBody.js
@@ -0,0 +1,193 @@
+const { logger } = require('@librechat/data-schemas');
+const {
+ Constants,
+ Permissions,
+ ResourceType,
+ SystemRoles,
+ PermissionTypes,
+ isAgentsEndpoint,
+ isEphemeralAgentId,
+} = require('librechat-data-provider');
+const { checkPermission } = require('~/server/services/PermissionService');
+const { canAccessResource } = require('./canAccessResource');
+const db = require('~/models');
+
+const { getRoleByName, getAgent } = db;
+
+/**
+ * Resolves custom agent ID (e.g., "agent_abc123") to a MongoDB document.
+ * @param {string} agentCustomId - Custom agent ID from request body
+ * @returns {Promise} Agent document with _id field, or null if ephemeral/not found
+ */
+const resolveAgentIdFromBody = async (agentCustomId) => {
+ if (isEphemeralAgentId(agentCustomId)) {
+ return null;
+ }
+ return getAgent({ id: agentCustomId });
+};
+
+/**
+ * Creates a `canAccessResource` middleware for the given agent ID
+ * and chains to the provided continuation on success.
+ *
+ * @param {string} agentId - The agent's custom string ID (e.g., "agent_abc123")
+ * @param {number} requiredPermission - Permission bit(s) required
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res - Written on deny; continuation called on allow
+ * @param {Function} continuation - Called when the permission check passes
+ * @returns {Promise}
+ */
+const checkAgentResourceAccess = (agentId, requiredPermission, req, res, continuation) => {
+ const middleware = canAccessResource({
+ resourceType: ResourceType.AGENT,
+ requiredPermission,
+ resourceIdParam: 'agent_id',
+ idResolver: () => resolveAgentIdFromBody(agentId),
+ });
+
+ const tempReq = {
+ ...req,
+ params: { ...req.params, agent_id: agentId },
+ };
+
+ return middleware(tempReq, res, continuation);
+};
+
+/**
+ * Middleware factory that validates MULTI_CONVO:USE role permission and, when
+ * addedConvo.agent_id is a non-ephemeral agent, the same resource-level permission
+ * required for the primary agent (`requiredPermission`). Caches the resolved agent
+ * document on `req.resolvedAddedAgent` to avoid a duplicate DB fetch in `loadAddedAgent`.
+ *
+ * @param {number} requiredPermission - Permission bit(s) to check on the added agent resource
+ * @returns {(req: import('express').Request, res: import('express').Response, next: Function) => Promise}
+ */
+const checkAddedConvoAccess = (requiredPermission) => async (req, res, next) => {
+ const addedConvo = req.body?.addedConvo;
+ if (!addedConvo || typeof addedConvo !== 'object' || Array.isArray(addedConvo)) {
+ return next();
+ }
+
+ try {
+ if (!req.user?.role) {
+ return res.status(403).json({
+ error: 'Forbidden',
+ message: 'Insufficient permissions for multi-conversation',
+ });
+ }
+
+ if (req.user.role !== SystemRoles.ADMIN) {
+ const role = await getRoleByName(req.user.role);
+ const hasMultiConvo = role?.permissions?.[PermissionTypes.MULTI_CONVO]?.[Permissions.USE];
+ if (!hasMultiConvo) {
+ return res.status(403).json({
+ error: 'Forbidden',
+ message: 'Multi-conversation feature is not enabled',
+ });
+ }
+ }
+
+ const addedAgentId = addedConvo.agent_id;
+ if (!addedAgentId || typeof addedAgentId !== 'string' || isEphemeralAgentId(addedAgentId)) {
+ return next();
+ }
+
+ if (req.user.role === SystemRoles.ADMIN) {
+ return next();
+ }
+
+ const agent = await resolveAgentIdFromBody(addedAgentId);
+ if (!agent) {
+ return res.status(404).json({
+ error: 'Not Found',
+ message: `${ResourceType.AGENT} not found`,
+ });
+ }
+
+ const hasPermission = await checkPermission({
+ userId: req.user.id,
+ role: req.user.role,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ requiredPermission,
+ });
+
+ if (!hasPermission) {
+ return res.status(403).json({
+ error: 'Forbidden',
+ message: `Insufficient permissions to access this ${ResourceType.AGENT}`,
+ });
+ }
+
+ req.resolvedAddedAgent = agent;
+ return next();
+ } catch (error) {
+ logger.error('Failed to validate addedConvo access permissions', error);
+ return res.status(500).json({
+ error: 'Internal Server Error',
+ message: 'Failed to validate addedConvo access permissions',
+ });
+ }
+};
+
+/**
+ * Middleware factory that checks agent access permissions from request body.
+ * Validates both the primary agent_id and, when present, addedConvo.agent_id
+ * (which also requires MULTI_CONVO:USE role permission).
+ *
+ * @param {Object} options - Configuration options
+ * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
+ * @returns {Function} Express middleware function
+ *
+ * @example
+ * router.post('/chat',
+ * canAccessAgentFromBody({ requiredPermission: PermissionBits.VIEW }),
+ * buildEndpointOption,
+ * chatController
+ * );
+ */
+const canAccessAgentFromBody = (options) => {
+ const { requiredPermission } = options;
+
+ if (!requiredPermission || typeof requiredPermission !== 'number') {
+ throw new Error('canAccessAgentFromBody: requiredPermission is required and must be a number');
+ }
+
+ const addedConvoMiddleware = checkAddedConvoAccess(requiredPermission);
+
+ return async (req, res, next) => {
+ try {
+ const { endpoint, agent_id } = req.body;
+ let agentId = agent_id;
+
+ if (!isAgentsEndpoint(endpoint)) {
+ agentId = Constants.EPHEMERAL_AGENT_ID;
+ }
+
+ if (!agentId) {
+ return res.status(400).json({
+ error: 'Bad Request',
+ message: 'agent_id is required in request body',
+ });
+ }
+
+ const afterPrimaryCheck = () => addedConvoMiddleware(req, res, next);
+
+ if (isEphemeralAgentId(agentId)) {
+ return afterPrimaryCheck();
+ }
+
+ return checkAgentResourceAccess(agentId, requiredPermission, req, res, afterPrimaryCheck);
+ } catch (error) {
+ logger.error('Failed to validate agent access permissions', error);
+ return res.status(500).json({
+ error: 'Internal Server Error',
+ message: 'Failed to validate agent access permissions',
+ });
+ }
+ };
+};
+
+module.exports = {
+ canAccessAgentFromBody,
+};
diff --git a/api/server/middleware/accessResources/canAccessAgentFromBody.spec.js b/api/server/middleware/accessResources/canAccessAgentFromBody.spec.js
new file mode 100644
index 0000000..9e5e0b0
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessAgentFromBody.spec.js
@@ -0,0 +1,509 @@
+const mongoose = require('mongoose');
+const {
+ ResourceType,
+ SystemRoles,
+ PrincipalType,
+ PrincipalModel,
+} = require('librechat-data-provider');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
+const { User, Role, AclEntry } = require('~/db/models');
+const { createAgent } = require('~/models');
+
+describe('canAccessAgentFromBody middleware', () => {
+ let mongoServer;
+ let req, res, next;
+ let testUser, otherUser;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ await mongoose.connect(mongoServer.getUri());
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await mongoose.connection.dropDatabase();
+
+ await Role.create({
+ name: 'test-role',
+ permissions: {
+ AGENTS: { USE: true, CREATE: true, SHARE: true },
+ MULTI_CONVO: { USE: true },
+ },
+ });
+
+ await Role.create({
+ name: 'no-multi-convo',
+ permissions: {
+ AGENTS: { USE: true, CREATE: true, SHARE: true },
+ MULTI_CONVO: { USE: false },
+ },
+ });
+
+ await Role.create({
+ name: SystemRoles.ADMIN,
+ permissions: {
+ AGENTS: { USE: true, CREATE: true, SHARE: true },
+ MULTI_CONVO: { USE: true },
+ },
+ });
+
+ testUser = await User.create({
+ email: 'test@example.com',
+ name: 'Test User',
+ username: 'testuser',
+ role: 'test-role',
+ });
+
+ otherUser = await User.create({
+ email: 'other@example.com',
+ name: 'Other User',
+ username: 'otheruser',
+ role: 'test-role',
+ });
+
+ req = {
+ user: { id: testUser._id, role: testUser.role },
+ params: {},
+ body: {
+ endpoint: 'agents',
+ agent_id: 'ephemeral_primary',
+ },
+ };
+ res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+ next = jest.fn();
+
+ jest.clearAllMocks();
+ });
+
+ describe('middleware factory', () => {
+ test('throws if requiredPermission is missing', () => {
+ expect(() => canAccessAgentFromBody({})).toThrow(
+ 'canAccessAgentFromBody: requiredPermission is required and must be a number',
+ );
+ });
+
+ test('throws if requiredPermission is not a number', () => {
+ expect(() => canAccessAgentFromBody({ requiredPermission: '1' })).toThrow(
+ 'canAccessAgentFromBody: requiredPermission is required and must be a number',
+ );
+ });
+
+ test('returns a middleware function', () => {
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ expect(typeof middleware).toBe('function');
+ expect(middleware.length).toBe(3);
+ });
+ });
+
+ describe('primary agent checks', () => {
+ test('returns 400 when agent_id is missing on agents endpoint', async () => {
+ req.body.agent_id = undefined;
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(400);
+ });
+
+ test('proceeds for ephemeral primary agent without addedConvo', async () => {
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('proceeds for non-agents endpoint (ephemeral fallback)', async () => {
+ req.body.endpoint = 'openAI';
+ req.body.agent_id = undefined;
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ });
+ });
+
+ describe('addedConvo — absent or invalid shape', () => {
+ test('calls next when addedConvo is absent', async () => {
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('calls next when addedConvo is a string', async () => {
+ req.body.addedConvo = 'not-an-object';
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('calls next when addedConvo is an array', async () => {
+ req.body.addedConvo = [{ agent_id: 'agent_something' }];
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ });
+ });
+
+ describe('addedConvo — MULTI_CONVO permission gate', () => {
+ test('returns 403 when user lacks MULTI_CONVO:USE', async () => {
+ req.user.role = 'no-multi-convo';
+ req.body.addedConvo = { agent_id: 'agent_x', endpoint: 'agents', model: 'gpt-4' };
+
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'Multi-conversation feature is not enabled' }),
+ );
+ });
+
+ test('returns 403 when user.role is missing', async () => {
+ req.user = { id: testUser._id };
+ req.body.addedConvo = { agent_id: 'agent_x', endpoint: 'agents', model: 'gpt-4' };
+
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ test('ADMIN bypasses MULTI_CONVO check', async () => {
+ req.user.role = SystemRoles.ADMIN;
+ req.body.addedConvo = { agent_id: 'ephemeral_x', endpoint: 'agents', model: 'gpt-4' };
+
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('addedConvo — agent_id shape validation', () => {
+ test('calls next when agent_id is ephemeral', async () => {
+ req.body.addedConvo = { agent_id: 'ephemeral_xyz', endpoint: 'agents', model: 'gpt-4' };
+
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('calls next when agent_id is absent', async () => {
+ req.body.addedConvo = { endpoint: 'agents', model: 'gpt-4' };
+
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('calls next when agent_id is not a string (object injection)', async () => {
+ req.body.addedConvo = { agent_id: { $gt: '' }, endpoint: 'agents', model: 'gpt-4' };
+
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ });
+ });
+
+ describe('addedConvo — agent resource ACL (IDOR prevention)', () => {
+ let addedAgent;
+
+ beforeEach(async () => {
+ addedAgent = await createAgent({
+ id: `agent_added_${Date.now()}`,
+ name: 'Private Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ });
+
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: otherUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: addedAgent._id,
+ permBits: 15,
+ grantedBy: otherUser._id,
+ });
+ });
+
+ test('returns 403 when requester has no ACL for the added agent', async () => {
+ req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
+
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Insufficient permissions to access this agent',
+ }),
+ );
+ });
+
+ test('returns 404 when added agent does not exist', async () => {
+ req.body.addedConvo = {
+ agent_id: 'agent_nonexistent_999',
+ endpoint: 'agents',
+ model: 'gpt-4',
+ };
+
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(404);
+ });
+
+ test('proceeds when requester has ACL for the added agent', async () => {
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: addedAgent._id,
+ permBits: 1,
+ grantedBy: otherUser._id,
+ });
+
+ req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
+
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('denies when ACL permission bits are insufficient', async () => {
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: addedAgent._id,
+ permBits: 1,
+ grantedBy: otherUser._id,
+ });
+
+ req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
+
+ const middleware = canAccessAgentFromBody({ requiredPermission: 2 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ test('caches resolved agent on req.resolvedAddedAgent', async () => {
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: addedAgent._id,
+ permBits: 1,
+ grantedBy: otherUser._id,
+ });
+
+ req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
+
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.resolvedAddedAgent).toBeDefined();
+ expect(req.resolvedAddedAgent._id.toString()).toBe(addedAgent._id.toString());
+ });
+
+ test('ADMIN bypasses agent resource ACL for addedConvo', async () => {
+ req.user.role = SystemRoles.ADMIN;
+ req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
+
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ expect(req.resolvedAddedAgent).toBeUndefined();
+ });
+ });
+
+ describe('end-to-end: primary real agent + addedConvo real agent', () => {
+ let primaryAgent, addedAgent;
+
+ beforeEach(async () => {
+ primaryAgent = await createAgent({
+ id: `agent_primary_${Date.now()}`,
+ name: 'Primary Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ });
+
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: primaryAgent._id,
+ permBits: 15,
+ grantedBy: testUser._id,
+ });
+
+ addedAgent = await createAgent({
+ id: `agent_added_${Date.now()}`,
+ name: 'Added Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ });
+
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: otherUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: addedAgent._id,
+ permBits: 15,
+ grantedBy: otherUser._id,
+ });
+
+ req.body.agent_id = primaryAgent.id;
+ });
+
+ test('both checks pass when user has ACL for both agents', async () => {
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: addedAgent._id,
+ permBits: 1,
+ grantedBy: otherUser._id,
+ });
+
+ req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
+
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ expect(req.resolvedAddedAgent).toBeDefined();
+ });
+
+ test('primary passes but addedConvo denied → 403', async () => {
+ req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
+
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ test('primary denied → 403 without reaching addedConvo check', async () => {
+ const foreignAgent = await createAgent({
+ id: `agent_foreign_${Date.now()}`,
+ name: 'Foreign Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ });
+
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: otherUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: foreignAgent._id,
+ permBits: 15,
+ grantedBy: otherUser._id,
+ });
+
+ req.body.agent_id = foreignAgent.id;
+ req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
+
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+ });
+
+ describe('ephemeral primary + real addedConvo agent', () => {
+ let addedAgent;
+
+ beforeEach(async () => {
+ addedAgent = await createAgent({
+ id: `agent_added_${Date.now()}`,
+ name: 'Added Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ });
+
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: otherUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: addedAgent._id,
+ permBits: 15,
+ grantedBy: otherUser._id,
+ });
+ });
+
+ test('runs full addedConvo ACL check even when primary is ephemeral', async () => {
+ req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
+
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ test('proceeds when user has ACL for added agent (ephemeral primary)', async () => {
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: addedAgent._id,
+ permBits: 1,
+ grantedBy: otherUser._id,
+ });
+
+ req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
+
+ const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/api/server/middleware/accessResources/canAccessAgentResource.js b/api/server/middleware/accessResources/canAccessAgentResource.js
new file mode 100644
index 0000000..4c00ab4
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessAgentResource.js
@@ -0,0 +1,59 @@
+const { ResourceType } = require('librechat-data-provider');
+const { canAccessResource } = require('./canAccessResource');
+const { getAgent } = require('~/models');
+
+/**
+ * Agent ID resolver function
+ * Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId
+ *
+ * @param {string} agentCustomId - Custom agent ID from route parameter
+ * @returns {Promise} Agent document with _id field, or null if not found
+ */
+const resolveAgentId = async (agentCustomId) => {
+ return await getAgent({ id: agentCustomId });
+};
+
+/**
+ * Agent-specific middleware factory that creates middleware to check agent access permissions.
+ * This middleware extends the generic canAccessResource to handle agent custom ID resolution.
+ *
+ * @param {Object} options - Configuration options
+ * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
+ * @param {string} [options.resourceIdParam='id'] - The name of the route parameter containing the agent custom ID
+ * @returns {Function} Express middleware function
+ *
+ * @example
+ * // Basic usage for viewing agents
+ * router.get('/agents/:id',
+ * canAccessAgentResource({ requiredPermission: 1 }),
+ * getAgent
+ * );
+ *
+ * @example
+ * // Custom resource ID parameter and edit permission
+ * router.patch('/agents/:agent_id',
+ * canAccessAgentResource({
+ * requiredPermission: 2,
+ * resourceIdParam: 'agent_id'
+ * }),
+ * updateAgent
+ * );
+ */
+const canAccessAgentResource = (options) => {
+ const { requiredPermission, resourceIdParam = 'id' } = options;
+
+ if (!requiredPermission || typeof requiredPermission !== 'number') {
+ throw new Error('canAccessAgentResource: requiredPermission is required and must be a number');
+ }
+
+ return canAccessResource({
+ resourceType: ResourceType.AGENT,
+ requiredPermission,
+ resourceIdParam,
+ idResolver: resolveAgentId,
+ });
+};
+
+module.exports = {
+ canAccessAgentResource,
+};
diff --git a/api/server/middleware/accessResources/canAccessAgentResource.spec.js b/api/server/middleware/accessResources/canAccessAgentResource.spec.js
new file mode 100644
index 0000000..786636e
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessAgentResource.spec.js
@@ -0,0 +1,385 @@
+const mongoose = require('mongoose');
+const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { canAccessAgentResource } = require('./canAccessAgentResource');
+const { User, Role, AclEntry } = require('~/db/models');
+const { createAgent } = require('~/models');
+
+describe('canAccessAgentResource middleware', () => {
+ let mongoServer;
+ let req, res, next;
+ let testUser;
+
+ 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 mongoose.connection.dropDatabase();
+ await Role.create({
+ name: 'test-role',
+ permissions: {
+ AGENTS: {
+ USE: true,
+ CREATE: true,
+ SHARE: true,
+ },
+ },
+ });
+
+ // Create a test user
+ testUser = await User.create({
+ email: 'test@example.com',
+ name: 'Test User',
+ username: 'testuser',
+ role: 'test-role',
+ });
+
+ req = {
+ user: { id: testUser._id, role: testUser.role },
+ params: {},
+ };
+ res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+ next = jest.fn();
+
+ jest.clearAllMocks();
+ });
+
+ describe('middleware factory', () => {
+ test('should throw error if requiredPermission is not provided', () => {
+ expect(() => canAccessAgentResource({})).toThrow(
+ 'canAccessAgentResource: requiredPermission is required and must be a number',
+ );
+ });
+
+ test('should throw error if requiredPermission is not a number', () => {
+ expect(() => canAccessAgentResource({ requiredPermission: '1' })).toThrow(
+ 'canAccessAgentResource: requiredPermission is required and must be a number',
+ );
+ });
+
+ test('should create middleware with default resourceIdParam', () => {
+ const middleware = canAccessAgentResource({ requiredPermission: 1 });
+ expect(typeof middleware).toBe('function');
+ expect(middleware.length).toBe(3); // Express middleware signature
+ });
+
+ test('should create middleware with custom resourceIdParam', () => {
+ const middleware = canAccessAgentResource({
+ requiredPermission: 2,
+ resourceIdParam: 'agent_id',
+ });
+ expect(typeof middleware).toBe('function');
+ expect(middleware.length).toBe(3);
+ });
+ });
+
+ describe('permission checking with real agents', () => {
+ test('should allow access when user is the agent author', async () => {
+ // Create an agent owned by the test user
+ const agent = await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ });
+
+ // Create ACL entry for the author (owner permissions)
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 15, // All permissions (1+2+4+8)
+ grantedBy: testUser._id,
+ });
+
+ req.params.id = agent.id;
+
+ const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should deny access when user is not the author and has no ACL entry', async () => {
+ // Create an agent owned by a different user
+ const otherUser = await User.create({
+ email: 'other@example.com',
+ name: 'Other User',
+ username: 'otheruser',
+ role: 'test-role',
+ });
+
+ const agent = await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Other User Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ });
+
+ // Create ACL entry for the other user (owner)
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: otherUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 15, // All permissions
+ grantedBy: otherUser._id,
+ });
+
+ req.params.id = agent.id;
+
+ const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to access this agent',
+ });
+ });
+
+ test('should allow access when user has ACL entry with sufficient permissions', async () => {
+ // Create an agent owned by a different user
+ const otherUser = await User.create({
+ email: 'other2@example.com',
+ name: 'Other User 2',
+ username: 'otheruser2',
+ role: 'test-role',
+ });
+
+ const agent = await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Shared Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ });
+
+ // Create ACL entry granting view permission to test user
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 1, // VIEW permission
+ grantedBy: otherUser._id,
+ });
+
+ req.params.id = agent.id;
+
+ const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should deny access when ACL permissions are insufficient', async () => {
+ // Create an agent owned by a different user
+ const otherUser = await User.create({
+ email: 'other3@example.com',
+ name: 'Other User 3',
+ username: 'otheruser3',
+ role: 'test-role',
+ });
+
+ const agent = await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Limited Access Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ });
+
+ // Create ACL entry granting only view permission
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 1, // VIEW permission only
+ grantedBy: otherUser._id,
+ });
+
+ req.params.id = agent.id;
+
+ const middleware = canAccessAgentResource({ requiredPermission: 2 }); // EDIT permission required
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to access this agent',
+ });
+ });
+
+ test('should handle non-existent agent', async () => {
+ req.params.id = 'agent_nonexistent';
+
+ const middleware = canAccessAgentResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Not Found',
+ message: 'agent not found',
+ });
+ });
+
+ test('should use custom resourceIdParam', async () => {
+ const agent = await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Custom Param Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ });
+
+ // Create ACL entry for the author
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 15, // All permissions
+ grantedBy: testUser._id,
+ });
+
+ req.params.agent_id = agent.id; // Using custom param name
+
+ const middleware = canAccessAgentResource({
+ requiredPermission: 1,
+ resourceIdParam: 'agent_id',
+ });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('permission levels', () => {
+ let agent;
+
+ beforeEach(async () => {
+ agent = await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Permission Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ });
+
+ // Create ACL entry with all permissions for the owner
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 15, // All permissions (1+2+4+8)
+ grantedBy: testUser._id,
+ });
+
+ req.params.id = agent.id;
+ });
+
+ test('should support view permission (1)', async () => {
+ const middleware = canAccessAgentResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should support edit permission (2)', async () => {
+ const middleware = canAccessAgentResource({ requiredPermission: 2 });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should support delete permission (4)', async () => {
+ const middleware = canAccessAgentResource({ requiredPermission: 4 });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should support share permission (8)', async () => {
+ const middleware = canAccessAgentResource({ requiredPermission: 8 });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should support combined permissions', async () => {
+ const viewAndEdit = 1 | 2; // 3
+ const middleware = canAccessAgentResource({ requiredPermission: viewAndEdit });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+ });
+
+ describe('integration with agent operations', () => {
+ test('should work with agent CRUD operations', async () => {
+ const agentId = `agent_${Date.now()}`;
+
+ // Create agent
+ const agent = await createAgent({
+ id: agentId,
+ name: 'Integration Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ description: 'Testing integration',
+ });
+
+ // Create ACL entry for the author
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 15, // All permissions
+ grantedBy: testUser._id,
+ });
+
+ req.params.id = agentId;
+
+ // Test view access
+ const viewMiddleware = canAccessAgentResource({ requiredPermission: 1 });
+ await viewMiddleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ jest.clearAllMocks();
+
+ // Update the agent
+ const { updateAgent } = require('~/models');
+ await updateAgent({ id: agentId }, { description: 'Updated description' });
+
+ // Test edit access
+ const editMiddleware = canAccessAgentResource({ requiredPermission: 2 });
+ await editMiddleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/api/server/middleware/accessResources/canAccessMCPServerResource.js b/api/server/middleware/accessResources/canAccessMCPServerResource.js
new file mode 100644
index 0000000..85d479c
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessMCPServerResource.js
@@ -0,0 +1,61 @@
+const { ResourceType } = require('librechat-data-provider');
+const { canAccessResource } = require('./canAccessResource');
+const { findMCPServerByServerName } = require('~/models');
+
+/**
+ * MCP Server name resolver function
+ * Resolves MCP server name (e.g., "my-mcp-server") to MongoDB ObjectId
+ *
+ * @param {string} serverName - Server name from route parameter
+ * @returns {Promise} MCP server document with _id field, or null if not found
+ */
+const resolveMCPServerName = async (serverName) => {
+ return await findMCPServerByServerName(serverName);
+};
+
+/**
+ * MCP Server-specific middleware factory that creates middleware to check MCP server access permissions.
+ * This middleware extends the generic canAccessResource to handle MCP server custom ID resolution.
+ *
+ * @param {Object} options - Configuration options
+ * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
+ * @param {string} [options.resourceIdParam='serverName'] - The name of the route parameter containing the MCP server custom ID
+ * @returns {Function} Express middleware function
+ *
+ * @example
+ * // Basic usage for viewing MCP servers
+ * router.get('/servers/:serverName',
+ * canAccessMCPServerResource({ requiredPermission: 1 }),
+ * getMCPServer
+ * );
+ *
+ * @example
+ * // Custom resource ID parameter and edit permission
+ * router.patch('/servers/:id',
+ * canAccessMCPServerResource({
+ * requiredPermission: 2,
+ * resourceIdParam: 'id'
+ * }),
+ * updateMCPServer
+ * );
+ */
+const canAccessMCPServerResource = (options) => {
+ const { requiredPermission, resourceIdParam = 'serverName' } = options;
+
+ if (!requiredPermission || typeof requiredPermission !== 'number') {
+ throw new Error(
+ 'canAccessMCPServerResource: requiredPermission is required and must be a number',
+ );
+ }
+
+ return canAccessResource({
+ resourceType: ResourceType.MCPSERVER,
+ requiredPermission,
+ resourceIdParam,
+ idResolver: resolveMCPServerName,
+ });
+};
+
+module.exports = {
+ canAccessMCPServerResource,
+};
diff --git a/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js b/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js
new file mode 100644
index 0000000..6f7e4ab
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js
@@ -0,0 +1,636 @@
+const mongoose = require('mongoose');
+const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
+const { SystemCapabilities } = require('@librechat/data-schemas');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { canAccessMCPServerResource } = require('./canAccessMCPServerResource');
+const { User, Role, AclEntry, SystemGrant } = require('~/db/models');
+const { createMCPServer } = require('~/models');
+
+describe('canAccessMCPServerResource middleware', () => {
+ let mongoServer;
+ let req, res, next;
+ let testUser;
+
+ 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 mongoose.connection.dropDatabase();
+ await Role.create({
+ name: 'test-role',
+ permissions: {
+ MCP_SERVERS: {
+ USE: true,
+ CREATE: true,
+ SHARE: true,
+ },
+ },
+ });
+
+ // Create a test user
+ testUser = await User.create({
+ email: 'test@example.com',
+ name: 'Test User',
+ username: 'testuser',
+ role: 'test-role',
+ });
+
+ req = {
+ user: { id: testUser._id, role: testUser.role },
+ params: {},
+ };
+ res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+ next = jest.fn();
+
+ jest.clearAllMocks();
+ });
+
+ describe('middleware factory', () => {
+ test('should throw error if requiredPermission is not provided', () => {
+ expect(() => canAccessMCPServerResource({})).toThrow(
+ 'canAccessMCPServerResource: requiredPermission is required and must be a number',
+ );
+ });
+
+ test('should throw error if requiredPermission is not a number', () => {
+ expect(() => canAccessMCPServerResource({ requiredPermission: '1' })).toThrow(
+ 'canAccessMCPServerResource: requiredPermission is required and must be a number',
+ );
+ });
+
+ test('should throw error if requiredPermission is null', () => {
+ expect(() => canAccessMCPServerResource({ requiredPermission: null })).toThrow(
+ 'canAccessMCPServerResource: requiredPermission is required and must be a number',
+ );
+ });
+
+ test('should create middleware with default resourceIdParam (serverName)', () => {
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ expect(typeof middleware).toBe('function');
+ expect(middleware.length).toBe(3); // Express middleware signature
+ });
+
+ test('should create middleware with custom resourceIdParam', () => {
+ const middleware = canAccessMCPServerResource({
+ requiredPermission: 2,
+ resourceIdParam: 'mcpId',
+ });
+ expect(typeof middleware).toBe('function');
+ expect(middleware.length).toBe(3);
+ });
+ });
+
+ describe('permission checking with real MCP servers', () => {
+ test('should allow access when user is the MCP server author', async () => {
+ // Create an MCP server owned by the test user
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'Test MCP Server',
+ },
+ author: testUser._id,
+ });
+
+ // Create ACL entry for the author (owner permissions)
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 15, // All permissions (1+2+4+8)
+ grantedBy: testUser._id,
+ });
+
+ req.params.serverName = mcpServer.serverName;
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 }); // VIEW permission
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should deny access when user is not the author and has no ACL entry', async () => {
+ // Create an MCP server owned by a different user
+ const otherUser = await User.create({
+ email: 'other@example.com',
+ name: 'Other User',
+ username: 'otheruser',
+ role: 'test-role',
+ });
+
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'Other User MCP Server',
+ },
+ author: otherUser._id,
+ });
+
+ // Create ACL entry for the other user (owner)
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: otherUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 15, // All permissions
+ grantedBy: otherUser._id,
+ });
+
+ req.params.serverName = mcpServer.serverName;
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 }); // VIEW permission
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to access this mcpServer',
+ });
+ });
+
+ test('should allow access when user has ACL entry with sufficient permissions', async () => {
+ // Create an MCP server owned by a different user
+ const otherUser = await User.create({
+ email: 'other2@example.com',
+ name: 'Other User 2',
+ username: 'otheruser2',
+ role: 'test-role',
+ });
+
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'Shared MCP Server',
+ },
+ author: otherUser._id,
+ });
+
+ // Create ACL entry granting view permission to test user
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 1, // VIEW permission
+ grantedBy: otherUser._id,
+ });
+
+ req.params.serverName = mcpServer.serverName;
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 }); // VIEW permission
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should deny access when ACL permissions are insufficient', async () => {
+ // Create an MCP server owned by a different user
+ const otherUser = await User.create({
+ email: 'other3@example.com',
+ name: 'Other User 3',
+ username: 'otheruser3',
+ role: 'test-role',
+ });
+
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'Limited Access MCP Server',
+ },
+ author: otherUser._id,
+ });
+
+ // Create ACL entry granting only view permission
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 1, // VIEW permission only
+ grantedBy: otherUser._id,
+ });
+
+ req.params.serverName = mcpServer.serverName;
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 2 }); // EDIT permission required
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to access this mcpServer',
+ });
+ });
+
+ test('should handle non-existent MCP server', async () => {
+ req.params.serverName = 'non-existent-mcp-server';
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Not Found',
+ message: 'mcpServer not found',
+ });
+ });
+
+ test('should use custom resourceIdParam', async () => {
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'Custom Param MCP Server',
+ },
+ author: testUser._id,
+ });
+
+ // Create ACL entry for the author
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 15, // All permissions
+ grantedBy: testUser._id,
+ });
+
+ req.params.mcpId = mcpServer.serverName; // Using custom param name
+
+ const middleware = canAccessMCPServerResource({
+ requiredPermission: 1,
+ resourceIdParam: 'mcpId',
+ });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('permission levels', () => {
+ let mcpServer;
+
+ beforeEach(async () => {
+ mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'Permission Test MCP Server',
+ },
+ author: testUser._id,
+ });
+
+ // Create ACL entry with all permissions for the owner
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 15, // All permissions (1+2+4+8)
+ grantedBy: testUser._id,
+ });
+
+ req.params.serverName = mcpServer.serverName;
+ });
+
+ test('should support view permission (1)', async () => {
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should support edit permission (2)', async () => {
+ const middleware = canAccessMCPServerResource({ requiredPermission: 2 });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should support delete permission (4)', async () => {
+ const middleware = canAccessMCPServerResource({ requiredPermission: 4 });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should support share permission (8)', async () => {
+ const middleware = canAccessMCPServerResource({ requiredPermission: 8 });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should support combined permissions', async () => {
+ const viewAndEdit = 1 | 2; // 3
+ const middleware = canAccessMCPServerResource({ requiredPermission: viewAndEdit });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+ });
+
+ describe('integration with resolveMCPServerId', () => {
+ test('should resolve serverName to MongoDB ObjectId correctly', async () => {
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'Integration Test MCP Server',
+ },
+ author: testUser._id,
+ });
+
+ // Create ACL entry for the author
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 15, // All permissions
+ grantedBy: testUser._id,
+ });
+
+ req.params.serverName = mcpServer.serverName;
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ // Verify that req.resourceAccess was set correctly
+ expect(req.resourceAccess).toBeDefined();
+ expect(req.resourceAccess.resourceType).toBe(ResourceType.MCPSERVER);
+ expect(req.resourceAccess.resourceId.toString()).toBe(mcpServer._id.toString());
+ expect(req.resourceAccess.customResourceId).toBe(mcpServer.serverName);
+ });
+
+ test('should work with MCP server CRUD operations', async () => {
+ // Create MCP server
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'CRUD Test MCP Server',
+ description: 'Testing integration',
+ },
+ author: testUser._id,
+ });
+
+ // Create ACL entry for the author
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 15, // All permissions
+ grantedBy: testUser._id,
+ });
+
+ req.params.serverName = mcpServer.serverName;
+
+ // Test view access
+ const viewMiddleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await viewMiddleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ jest.clearAllMocks();
+
+ // Update the MCP server
+ const { updateMCPServer } = require('~/models');
+ await updateMCPServer(mcpServer.serverName, {
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'CRUD Test MCP Server',
+ description: 'Updated description',
+ },
+ });
+
+ // Test edit access
+ const editMiddleware = canAccessMCPServerResource({ requiredPermission: 2 });
+ await editMiddleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle stdio type MCP server', async () => {
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'stdio',
+ command: 'node',
+ args: ['server.js'],
+ title: 'Stdio MCP Server',
+ },
+ author: testUser._id,
+ });
+
+ // Create ACL entry for the author
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 15,
+ grantedBy: testUser._id,
+ });
+
+ req.params.serverName = mcpServer.serverName;
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.resourceAccess.resourceInfo.config.type).toBe('stdio');
+ });
+ });
+
+ describe('authentication and authorization edge cases', () => {
+ test('should return 400 when serverName parameter is missing', async () => {
+ // Don't set req.params.serverName
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Bad Request',
+ message: 'serverName is required',
+ });
+ });
+
+ test('should return 401 when user is not authenticated', async () => {
+ req.user = null;
+ req.params.serverName = 'some-server';
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Unauthorized',
+ message: 'Authentication required',
+ });
+ });
+
+ test('should return 401 when user id is missing', async () => {
+ req.user = { role: 'test-role' }; // No id
+ req.params.serverName = 'some-server';
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Unauthorized',
+ message: 'Authentication required',
+ });
+ });
+
+ test('should allow users with MANAGE_MCP_SERVERS capability to bypass permission checks', async () => {
+ const { SystemRoles } = require('librechat-data-provider');
+
+ // Create an MCP server owned by another user
+ const otherUser = await User.create({
+ email: 'owner@example.com',
+ name: 'Owner User',
+ username: 'owneruser',
+ role: 'test-role',
+ });
+
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'Admin Test MCP Server',
+ },
+ author: otherUser._id,
+ });
+
+ // Seed MANAGE_MCP_SERVERS capability for the ADMIN role
+ await SystemGrant.create({
+ principalType: PrincipalType.ROLE,
+ principalId: SystemRoles.ADMIN,
+ capability: SystemCapabilities.MANAGE_MCP_SERVERS,
+ grantedAt: new Date(),
+ });
+
+ // Set user as admin
+ req.user = { id: testUser._id, role: SystemRoles.ADMIN };
+ req.params.serverName = mcpServer.serverName;
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 4 }); // DELETE permission
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('error handling', () => {
+ test('should handle server returning null gracefully (treated as not found)', async () => {
+ // When an MCP server is not found, findMCPServerByServerName returns null
+ // which the middleware correctly handles as a 404
+ req.params.serverName = 'definitely-non-existent-server';
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Not Found',
+ message: 'mcpServer not found',
+ });
+ });
+ });
+
+ describe('multiple servers with same title', () => {
+ test('should handle MCP servers with auto-generated suffixes', async () => {
+ // Create multiple servers with the same title (will have different serverNames)
+ const mcpServer1 = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp1',
+ title: 'Duplicate Title',
+ },
+ author: testUser._id,
+ });
+
+ const mcpServer2 = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp2',
+ title: 'Duplicate Title',
+ },
+ author: testUser._id,
+ });
+
+ // Create ACL entries for both
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer1._id,
+ permBits: 15,
+ grantedBy: testUser._id,
+ });
+
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer2._id,
+ permBits: 15,
+ grantedBy: testUser._id,
+ });
+
+ // Verify they have different serverNames
+ expect(mcpServer1.serverName).toBe('duplicate-title');
+ expect(mcpServer2.serverName).toBe('duplicate-title-2');
+
+ // Test access to first server
+ req.params.serverName = mcpServer1.serverName;
+ const middleware1 = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware1(req, res, next);
+ expect(next).toHaveBeenCalled();
+ expect(req.resourceAccess.resourceId.toString()).toBe(mcpServer1._id.toString());
+
+ jest.clearAllMocks();
+
+ // Test access to second server
+ req.params.serverName = mcpServer2.serverName;
+ const middleware2 = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware2(req, res, next);
+ expect(next).toHaveBeenCalled();
+ expect(req.resourceAccess.resourceId.toString()).toBe(mcpServer2._id.toString());
+ });
+ });
+});
diff --git a/api/server/middleware/accessResources/canAccessPromptGroupResource.js b/api/server/middleware/accessResources/canAccessPromptGroupResource.js
new file mode 100644
index 0000000..9da1994
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessPromptGroupResource.js
@@ -0,0 +1,61 @@
+const { ResourceType } = require('librechat-data-provider');
+const { canAccessResource } = require('./canAccessResource');
+const { getPromptGroup } = require('~/models');
+
+/**
+ * PromptGroup ID resolver function
+ * Resolves promptGroup ID to MongoDB ObjectId
+ *
+ * @param {string} groupId - PromptGroup ID from route parameter
+ * @returns {Promise} PromptGroup document with _id field, or null if not found
+ */
+const resolvePromptGroupId = async (groupId) => {
+ return await getPromptGroup({ _id: groupId });
+};
+
+/**
+ * PromptGroup-specific middleware factory that creates middleware to check promptGroup access permissions.
+ * This middleware extends the generic canAccessResource to handle promptGroup ID resolution.
+ *
+ * @param {Object} options - Configuration options
+ * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
+ * @param {string} [options.resourceIdParam='groupId'] - The name of the route parameter containing the promptGroup ID
+ * @returns {Function} Express middleware function
+ *
+ * @example
+ * // Basic usage for viewing promptGroups
+ * router.get('/prompts/groups/:groupId',
+ * canAccessPromptGroupResource({ requiredPermission: 1 }),
+ * getPromptGroup
+ * );
+ *
+ * @example
+ * // Custom resource ID parameter and edit permission
+ * router.patch('/prompts/groups/:id',
+ * canAccessPromptGroupResource({
+ * requiredPermission: 2,
+ * resourceIdParam: 'id'
+ * }),
+ * updatePromptGroup
+ * );
+ */
+const canAccessPromptGroupResource = (options) => {
+ const { requiredPermission, resourceIdParam = 'groupId' } = options;
+
+ if (!requiredPermission || typeof requiredPermission !== 'number') {
+ throw new Error(
+ 'canAccessPromptGroupResource: requiredPermission is required and must be a number',
+ );
+ }
+
+ return canAccessResource({
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermission,
+ resourceIdParam,
+ idResolver: resolvePromptGroupId,
+ });
+};
+
+module.exports = {
+ canAccessPromptGroupResource,
+};
diff --git a/api/server/middleware/accessResources/canAccessPromptViaGroup.js b/api/server/middleware/accessResources/canAccessPromptViaGroup.js
new file mode 100644
index 0000000..534db3d
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessPromptViaGroup.js
@@ -0,0 +1,55 @@
+const { ResourceType } = require('librechat-data-provider');
+const { canAccessResource } = require('./canAccessResource');
+const { getPrompt } = require('~/models');
+
+/**
+ * Prompt to PromptGroup ID resolver function
+ * Resolves prompt ID to its parent promptGroup ID
+ *
+ * @param {string} promptId - Prompt ID from route parameter
+ * @returns {Promise} Object with promptGroup's _id field, or null if not found
+ */
+const resolvePromptToGroupId = async (promptId) => {
+ const prompt = await getPrompt({ _id: promptId });
+ if (!prompt || !prompt.groupId) {
+ return null;
+ }
+ // Return an object with _id that matches the promptGroup ID
+ return { _id: prompt.groupId };
+};
+
+/**
+ * Middleware factory that checks promptGroup permissions when accessing individual prompts.
+ * This allows permission management at the promptGroup level while still supporting
+ * individual prompt access patterns.
+ *
+ * @param {Object} options - Configuration options
+ * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
+ * @param {string} [options.resourceIdParam='promptId'] - The name of the route parameter containing the prompt ID
+ * @returns {Function} Express middleware function
+ *
+ * @example
+ * // Check promptGroup permissions when viewing a prompt
+ * router.get('/prompts/:promptId',
+ * canAccessPromptViaGroup({ requiredPermission: 1 }),
+ * getPrompt
+ * );
+ */
+const canAccessPromptViaGroup = (options) => {
+ const { requiredPermission, resourceIdParam = 'promptId' } = options;
+
+ if (!requiredPermission || typeof requiredPermission !== 'number') {
+ throw new Error('canAccessPromptViaGroup: requiredPermission is required and must be a number');
+ }
+
+ return canAccessResource({
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermission,
+ resourceIdParam,
+ idResolver: resolvePromptToGroupId,
+ });
+};
+
+module.exports = {
+ canAccessPromptViaGroup,
+};
diff --git a/api/server/middleware/accessResources/canAccessResource.js b/api/server/middleware/accessResources/canAccessResource.js
new file mode 100644
index 0000000..2431971
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessResource.js
@@ -0,0 +1,167 @@
+const { logger, ResourceCapabilityMap } = require('@librechat/data-schemas');
+const { hasCapability } = require('~/server/middleware/roles/capabilities');
+const { checkPermission } = require('~/server/services/PermissionService');
+
+/**
+ * Generic base middleware factory that creates middleware to check resource access permissions.
+ * This middleware expects MongoDB ObjectIds as resource identifiers for ACL permission checks.
+ *
+ * @param {Object} options - Configuration options
+ * @param {string} options.resourceType - The type of resource (e.g., 'agent', 'file', 'project')
+ * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
+ * @param {string} [options.resourceIdParam='resourceId'] - The name of the route parameter containing the resource ID
+ * @param {Function} [options.idResolver] - Optional function to resolve custom IDs to ObjectIds
+ * @returns {Function} Express middleware function
+ *
+ * @example
+ * // Direct usage with ObjectId (for resources that use MongoDB ObjectId in routes)
+ * router.get('/prompts/:promptId',
+ * canAccessResource({ resourceType: 'prompt', requiredPermission: 1 }),
+ * getPrompt
+ * );
+ *
+ * @example
+ * // Usage with custom ID resolver (for resources that use custom string IDs)
+ * router.get('/agents/:id',
+ * canAccessResource({
+ * resourceType: 'agent',
+ * requiredPermission: 1,
+ * resourceIdParam: 'id',
+ * idResolver: (customId) => resolveAgentId(customId)
+ * }),
+ * getAgent
+ * );
+ */
+const canAccessResource = (options) => {
+ const {
+ resourceType,
+ requiredPermission,
+ resourceIdParam = 'resourceId',
+ idResolver = null,
+ } = options;
+
+ if (!resourceType || typeof resourceType !== 'string') {
+ throw new Error('canAccessResource: resourceType is required and must be a string');
+ }
+
+ if (!requiredPermission || typeof requiredPermission !== 'number') {
+ throw new Error('canAccessResource: requiredPermission is required and must be a number');
+ }
+
+ return async (req, res, next) => {
+ try {
+ // Extract resource ID from route parameters
+ const rawResourceId = req.params[resourceIdParam];
+
+ if (!rawResourceId) {
+ logger.warn(`[canAccessResource] Missing ${resourceIdParam} in route parameters`);
+ return res.status(400).json({
+ error: 'Bad Request',
+ message: `${resourceIdParam} is required`,
+ });
+ }
+
+ // Check if user is authenticated
+ if (!req.user || !req.user.id) {
+ logger.warn(
+ `[canAccessResource] Unauthenticated request for ${resourceType} ${rawResourceId}`,
+ );
+ return res.status(401).json({
+ error: 'Unauthorized',
+ message: 'Authentication required',
+ });
+ }
+ const cap = ResourceCapabilityMap[resourceType];
+ let hasCap = false;
+ try {
+ hasCap = cap != null && (await hasCapability(req.user, cap));
+ } catch (err) {
+ logger.warn(`[canAccessResource] capability check failed, denying bypass: ${err.message}`);
+ }
+ if (hasCap) {
+ logger.debug(
+ `[canAccessResource] ${cap} bypass for user ${req.user.id} on ${resourceType} ${rawResourceId}`,
+ );
+ return next();
+ }
+ const userId = req.user.id;
+ let resourceId = rawResourceId;
+ let resourceInfo = null;
+
+ // Resolve custom ID to ObjectId if resolver is provided
+ if (idResolver) {
+ logger.debug(
+ `[canAccessResource] Resolving ${resourceType} custom ID ${rawResourceId} to ObjectId`,
+ );
+
+ const resolutionResult = await idResolver(rawResourceId);
+
+ if (!resolutionResult) {
+ logger.warn(`[canAccessResource] ${resourceType} not found: ${rawResourceId}`);
+ return res.status(404).json({
+ error: 'Not Found',
+ message: `${resourceType} not found`,
+ });
+ }
+
+ // Handle different resolver return formats
+ if (typeof resolutionResult === 'string' || resolutionResult._id) {
+ resourceId = resolutionResult._id || resolutionResult;
+ resourceInfo = typeof resolutionResult === 'object' ? resolutionResult : null;
+ } else {
+ resourceId = resolutionResult;
+ }
+
+ logger.debug(
+ `[canAccessResource] Resolved ${resourceType} ${rawResourceId} to ObjectId ${resourceId}`,
+ );
+ }
+
+ // Check permissions using PermissionService with ObjectId
+ const hasPermission = await checkPermission({
+ userId,
+ role: req.user.role,
+ resourceType,
+ resourceId,
+ requiredPermission,
+ });
+
+ if (hasPermission) {
+ logger.debug(
+ `[canAccessResource] User ${userId} has permission ${requiredPermission} on ${resourceType} ${rawResourceId} (${resourceId})`,
+ );
+
+ req.resourceAccess = {
+ resourceType,
+ resourceId, // MongoDB ObjectId for ACL operations
+ customResourceId: rawResourceId, // Original ID from route params
+ permission: requiredPermission,
+ userId,
+ ...(resourceInfo && { resourceInfo }),
+ };
+
+ return next();
+ }
+
+ logger.warn(
+ `[canAccessResource] User ${userId} denied access to ${resourceType} ${rawResourceId} ` +
+ `(required permission: ${requiredPermission})`,
+ );
+
+ return res.status(403).json({
+ error: 'Forbidden',
+ message: `Insufficient permissions to access this ${resourceType}`,
+ });
+ } catch (error) {
+ logger.error(`[canAccessResource] Error checking access for ${resourceType}:`, error);
+ return res.status(500).json({
+ error: 'Internal Server Error',
+ message: 'Failed to check resource access permissions',
+ });
+ }
+ };
+};
+
+module.exports = {
+ canAccessResource,
+};
diff --git a/api/server/middleware/accessResources/canAccessSkillResource.js b/api/server/middleware/accessResources/canAccessSkillResource.js
new file mode 100644
index 0000000..d68612d
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessSkillResource.js
@@ -0,0 +1,56 @@
+const { ResourceType, PermissionBits } = require('librechat-data-provider');
+const { canAccessResource } = require('./canAccessResource');
+const { getSkillById } = require('~/models');
+const { getDeploymentSkillById } = require('@librechat/api');
+
+/**
+ * Skill-specific middleware factory that checks skill access permissions.
+ * Wraps the generic `canAccessResource` with the SKILL resource type and
+ * `getSkillById` as the ID resolver.
+ *
+ * @param {Object} options
+ * @param {number} options.requiredPermission - Permission bit required (1=view, 2=edit, 4=delete, 8=share)
+ * @param {string} [options.resourceIdParam='id'] - Route parameter name holding the skill id
+ * @returns {Function} Express middleware
+ */
+const canAccessSkillResource = (options) => {
+ const { requiredPermission, resourceIdParam = 'id' } = options || {};
+
+ if (!requiredPermission || typeof requiredPermission !== 'number') {
+ throw new Error('canAccessSkillResource: requiredPermission is required and must be a number');
+ }
+
+ const aclMiddleware = canAccessResource({
+ resourceType: ResourceType.SKILL,
+ requiredPermission,
+ resourceIdParam,
+ idResolver: getSkillById,
+ });
+
+ return (req, res, next) => {
+ const rawResourceId = req.params[resourceIdParam];
+ const deploymentSkill = rawResourceId ? getDeploymentSkillById(rawResourceId) : null;
+ if (!deploymentSkill) {
+ return aclMiddleware(req, res, next);
+ }
+ if (requiredPermission !== PermissionBits.VIEW) {
+ return res.status(403).json({
+ error: 'Forbidden',
+ message: 'Deployment skills are read-only',
+ });
+ }
+ req.resourceAccess = {
+ resourceType: ResourceType.SKILL,
+ resourceId: deploymentSkill._id,
+ customResourceId: rawResourceId,
+ permission: requiredPermission,
+ userId: req.user?.id,
+ resourceInfo: deploymentSkill,
+ };
+ return next();
+ };
+};
+
+module.exports = {
+ canAccessSkillResource,
+};
diff --git a/api/server/middleware/accessResources/fileAccess.js b/api/server/middleware/accessResources/fileAccess.js
new file mode 100644
index 0000000..1864db4
--- /dev/null
+++ b/api/server/middleware/accessResources/fileAccess.js
@@ -0,0 +1,153 @@
+const { logger } = require('@librechat/data-schemas');
+const { PermissionBits, hasPermissions, ResourceType } = require('librechat-data-provider');
+const { getEffectivePermissions } = require('~/server/services/PermissionService');
+const { getAgents, getFiles } = require('~/models');
+
+/**
+ * Checks if user has access to a file through agent permissions
+ * Files inherit permissions from agents they are attached to.
+ */
+const checkAgentBasedFileAccess = async ({ userId, role, fileId, fileOwner }) => {
+ try {
+ const fileOwnerId = fileOwner?.toString();
+ if (!fileOwnerId) {
+ return false;
+ }
+
+ /** Agents that have this file in their tool_resources */
+ const agentsWithFile = await getAgents({
+ $or: [
+ { 'tool_resources.execute_code.file_ids': fileId },
+ { 'tool_resources.file_search.file_ids': fileId },
+ { 'tool_resources.image_edit.file_ids': fileId },
+ { 'tool_resources.context.file_ids': fileId },
+ { 'tool_resources.ocr.file_ids': fileId },
+ ],
+ });
+
+ if (!agentsWithFile || agentsWithFile.length === 0) {
+ return false;
+ }
+
+ const userIdStr = userId.toString();
+ for (const agent of agentsWithFile) {
+ const agentAuthorId = agent.author?.toString();
+ if (!agentAuthorId) {
+ continue;
+ }
+
+ if (agentAuthorId === userIdStr) {
+ logger.debug(`[fileAccess] User is author of agent ${agent.id}`);
+ return true;
+ }
+
+ try {
+ const permissions = await getEffectivePermissions({
+ userId,
+ role,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id || agent.id,
+ });
+
+ if (hasPermissions(permissions, PermissionBits.VIEW)) {
+ logger.debug(`[fileAccess] User ${userId} has VIEW permissions on agent ${agent.id}`);
+ return true;
+ }
+ } catch (permissionError) {
+ logger.warn(
+ `[fileAccess] Permission check failed for agent ${agent.id}:`,
+ permissionError.message,
+ );
+ }
+ }
+
+ return false;
+ } catch (error) {
+ logger.error('[fileAccess] Error checking agent-based access:', error);
+ return false;
+ }
+};
+
+const getTenantId = (value) => value?.toString?.() ?? null;
+
+const denyFileAccess = (res) =>
+ res.status(403).json({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to access this file',
+ });
+
+/**
+ * Middleware to check if user can access a file
+ * Checks: 1) File ownership, 2) Agent-based access through attached agents
+ */
+const fileAccess = async (req, res, next) => {
+ try {
+ const fileId = req.params.file_id;
+ const userId = req.user?.id;
+ const userRole = req.user?.role;
+ if (!fileId) {
+ return res.status(400).json({
+ error: 'Bad Request',
+ message: 'file_id is required',
+ });
+ }
+
+ if (!userId) {
+ return res.status(401).json({
+ error: 'Unauthorized',
+ message: 'Authentication required',
+ });
+ }
+
+ const [file] = await getFiles({ file_id: fileId });
+ if (!file) {
+ return res.status(404).json({
+ error: 'Not Found',
+ message: 'File not found',
+ });
+ }
+
+ const fileTenantId = getTenantId(file.tenantId);
+ const userTenantId = getTenantId(req.user?.tenantId);
+ // Tenant-scoped files are restricted to their tenant. Legacy files without
+ // tenantId remain governed by owner/agent ACLs for non-tenant migrations.
+ if (fileTenantId && fileTenantId !== userTenantId) {
+ logger.warn(
+ `[fileAccess] User ${userId} denied cross-tenant access to file ${fileId} (route ${req.originalUrl})`,
+ );
+ return denyFileAccess(res);
+ }
+
+ if (file.user && file.user.toString() === userId) {
+ req.fileAccess = { file };
+ return next();
+ }
+
+ /** Agent-based access (file inherits agent permissions) */
+ const hasAgentAccess = await checkAgentBasedFileAccess({
+ userId,
+ role: userRole,
+ fileId,
+ fileOwner: file.user,
+ });
+ if (hasAgentAccess) {
+ req.fileAccess = { file };
+ return next();
+ }
+
+ logger.warn(
+ `[fileAccess] User ${userId} denied access to file ${fileId} (route ${req.originalUrl})`,
+ );
+ return denyFileAccess(res);
+ } catch (error) {
+ logger.error('[fileAccess] Error checking file access:', error);
+ return res.status(500).json({
+ error: 'Internal Server Error',
+ message: 'Failed to check file access permissions',
+ });
+ }
+};
+
+module.exports = {
+ fileAccess,
+};
diff --git a/api/server/middleware/accessResources/fileAccess.spec.js b/api/server/middleware/accessResources/fileAccess.spec.js
new file mode 100644
index 0000000..68aea11
--- /dev/null
+++ b/api/server/middleware/accessResources/fileAccess.spec.js
@@ -0,0 +1,649 @@
+const mongoose = require('mongoose');
+const { tenantStorage } = require('@librechat/data-schemas');
+const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { fileAccess } = require('./fileAccess');
+const { User, Role, AclEntry } = require('~/db/models');
+const { createAgent, createFile } = require('~/models');
+
+describe('fileAccess middleware', () => {
+ let mongoServer;
+ let req, res, next;
+ let testUser, otherUser, thirdUser;
+
+ 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 mongoose.connection.dropDatabase();
+
+ // Create test role
+ await Role.create({
+ name: 'test-role',
+ permissions: {
+ AGENTS: {
+ USE: true,
+ CREATE: true,
+ SHARE: true,
+ },
+ },
+ });
+
+ // Create test users
+ testUser = await User.create({
+ email: 'test@example.com',
+ name: 'Test User',
+ username: 'testuser',
+ role: 'test-role',
+ });
+
+ otherUser = await User.create({
+ email: 'other@example.com',
+ name: 'Other User',
+ username: 'otheruser',
+ role: 'test-role',
+ });
+
+ thirdUser = await User.create({
+ email: 'third@example.com',
+ name: 'Third User',
+ username: 'thirduser',
+ role: 'test-role',
+ });
+
+ // Setup request/response objects
+ req = {
+ user: { id: testUser._id.toString(), role: testUser.role },
+ params: {},
+ };
+ res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+ next = jest.fn();
+
+ jest.clearAllMocks();
+ });
+
+ describe('basic file access', () => {
+ test('should allow access when user owns the file', async () => {
+ // Create a file owned by testUser
+ await createFile({
+ user: testUser._id.toString(),
+ file_id: 'file_owned_by_user',
+ filepath: '/test/file.txt',
+ filename: 'file.txt',
+ type: 'text/plain',
+ size: 100,
+ });
+
+ req.params.file_id = 'file_owned_by_user';
+ await fileAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.fileAccess).toBeDefined();
+ expect(req.fileAccess.file).toBeDefined();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should deny access when user does not own the file and no agent access', async () => {
+ // Create a file owned by otherUser
+ await createFile({
+ user: otherUser._id.toString(),
+ file_id: 'file_owned_by_other',
+ filepath: '/test/file.txt',
+ filename: 'file.txt',
+ type: 'text/plain',
+ size: 100,
+ });
+
+ req.params.file_id = 'file_owned_by_other';
+ await fileAccess(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to access this file',
+ });
+ });
+
+ test('should deny access when tenant does not match even if user owns the file', async () => {
+ await tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
+ createFile({
+ user: testUser._id.toString(),
+ file_id: 'file_owned_by_user_other_tenant',
+ filepath: '/test/file.txt',
+ filename: 'file.txt',
+ type: 'text/plain',
+ size: 100,
+ tenantId: 'tenant-a',
+ }),
+ );
+
+ req.user.tenantId = 'tenant-b';
+ req.params.file_id = 'file_owned_by_user_other_tenant';
+ await fileAccess(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to access this file',
+ });
+ });
+
+ test('should allow tenant-scoped users to access owned legacy files without tenantId', async () => {
+ await createFile({
+ user: testUser._id.toString(),
+ file_id: 'legacy_file_owned_by_user',
+ filepath: '/test/legacy.txt',
+ filename: 'legacy.txt',
+ type: 'text/plain',
+ size: 100,
+ });
+
+ req.user.tenantId = 'tenant-b';
+ req.params.file_id = 'legacy_file_owned_by_user';
+ await fileAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.fileAccess.file.file_id).toBe('legacy_file_owned_by_user');
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should return 404 when file does not exist', async () => {
+ req.params.file_id = 'non_existent_file';
+ await fileAccess(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Not Found',
+ message: 'File not found',
+ });
+ });
+
+ test('should return 400 when file_id is missing', async () => {
+ // Don't set file_id in params
+ await fileAccess(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Bad Request',
+ message: 'file_id is required',
+ });
+ });
+
+ test('should return 401 when user is not authenticated', async () => {
+ req.user = null;
+ req.params.file_id = 'some_file';
+
+ await fileAccess(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Unauthorized',
+ message: 'Authentication required',
+ });
+ });
+ });
+
+ describe('agent-based file access', () => {
+ beforeEach(async () => {
+ // Create a file owned by otherUser (not testUser)
+ await createFile({
+ user: otherUser._id.toString(),
+ file_id: 'shared_file_via_agent',
+ filepath: '/test/shared.txt',
+ filename: 'shared.txt',
+ type: 'text/plain',
+ size: 100,
+ });
+ });
+
+ test('should allow access when user authored an agent with an attached file from another owner', async () => {
+ await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ tool_resources: {
+ file_search: {
+ file_ids: ['shared_file_via_agent'],
+ },
+ },
+ });
+
+ req.params.file_id = 'shared_file_via_agent';
+ await fileAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.fileAccess).toBeDefined();
+ });
+
+ test('should allow access when user has VIEW permission on agent with file', async () => {
+ // Create agent owned by otherUser
+ const agent = await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Shared Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ tool_resources: {
+ execute_code: {
+ file_ids: ['shared_file_via_agent'],
+ },
+ },
+ });
+
+ // Grant VIEW permission to testUser
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 1, // VIEW permission
+ grantedBy: otherUser._id,
+ });
+
+ req.params.file_id = 'shared_file_via_agent';
+ await fileAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.fileAccess).toBeDefined();
+ });
+
+ test('should deny cross-tenant access even when user has VIEW permission on agent with file', async () => {
+ await tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
+ createFile({
+ user: otherUser._id.toString(),
+ file_id: 'cross_tenant_shared_file',
+ filepath: '/test/cross-tenant.txt',
+ filename: 'cross-tenant.txt',
+ type: 'text/plain',
+ size: 100,
+ tenantId: 'tenant-a',
+ }),
+ );
+
+ const agent = await createAgent({
+ id: `agent_cross_tenant_${Date.now()}`,
+ name: 'Cross Tenant Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ tool_resources: {
+ execute_code: {
+ file_ids: ['cross_tenant_shared_file'],
+ },
+ },
+ });
+
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 1,
+ grantedBy: otherUser._id,
+ });
+
+ req.user.tenantId = 'tenant-b';
+ req.params.file_id = 'cross_tenant_shared_file';
+ await fileAccess(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ test('should check file in ocr tool_resources', async () => {
+ const agent = await createAgent({
+ id: `agent_ocr_${Date.now()}`,
+ name: 'OCR Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ tool_resources: {
+ ocr: {
+ file_ids: ['shared_file_via_agent'],
+ },
+ },
+ });
+
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 1,
+ grantedBy: otherUser._id,
+ });
+
+ req.params.file_id = 'shared_file_via_agent';
+ await fileAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.fileAccess).toBeDefined();
+ });
+
+ test('should check file in image_edit tool_resources', async () => {
+ const agent = await createAgent({
+ id: `agent_image_${Date.now()}`,
+ name: 'Image Edit Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ tool_resources: {
+ image_edit: {
+ file_ids: ['shared_file_via_agent'],
+ },
+ },
+ });
+
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 1,
+ grantedBy: otherUser._id,
+ });
+
+ req.params.file_id = 'shared_file_via_agent';
+ await fileAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.fileAccess).toBeDefined();
+ });
+
+ test('should deny access when user has no permission on agent with file', async () => {
+ // Create agent owned by otherUser without granting permission to testUser
+ const agent = await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Private Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ tool_resources: {
+ file_search: {
+ file_ids: ['shared_file_via_agent'],
+ },
+ },
+ });
+
+ // Create ACL entry for otherUser only (owner)
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: otherUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 15, // All permissions
+ grantedBy: otherUser._id,
+ });
+
+ req.params.file_id = 'shared_file_via_agent';
+ await fileAccess(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+ });
+
+ describe('multiple agents with same file', () => {
+ /**
+ * This test suite verifies that when multiple agents have the same file,
+ * all agents are checked for permissions, not just the first one found.
+ * This ensures users can access files through any agent they have permission for.
+ */
+
+ test('should check ALL agents with file, not just first one', async () => {
+ // Create a file owned by someone else
+ await createFile({
+ user: thirdUser._id.toString(),
+ file_id: 'multi_agent_file',
+ filepath: '/test/multi.txt',
+ filename: 'multi.txt',
+ type: 'text/plain',
+ size: 100,
+ });
+
+ // Create first agent (owned by otherUser, no access for testUser)
+ const agent1 = await createAgent({
+ id: 'agent_no_access',
+ name: 'No Access Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ tool_resources: {
+ file_search: {
+ file_ids: ['multi_agent_file'],
+ },
+ },
+ });
+
+ // Create ACL for agent1 - only otherUser has access
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: otherUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent1._id,
+ permBits: 15,
+ grantedBy: otherUser._id,
+ });
+
+ // Create second agent (owned by the file owner, and testUser has VIEW access)
+ const agent2 = await createAgent({
+ id: 'agent_with_access',
+ name: 'Accessible Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: thirdUser._id,
+ tool_resources: {
+ file_search: {
+ file_ids: ['multi_agent_file'],
+ },
+ },
+ });
+
+ // Grant testUser VIEW access to agent2
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent2._id,
+ permBits: 1, // VIEW permission
+ grantedBy: thirdUser._id,
+ });
+
+ req.params.file_id = 'multi_agent_file';
+ await fileAccess(req, res, next);
+
+ /**
+ * Should succeed because testUser has access to an attached agent,
+ * even though a non-owner agent without access is found first.
+ */
+ expect(next).toHaveBeenCalled();
+ expect(req.fileAccess).toBeDefined();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should find file in any agent tool_resources type', async () => {
+ // Create a file
+ await createFile({
+ user: otherUser._id.toString(),
+ file_id: 'multi_tool_file',
+ filepath: '/test/tool.txt',
+ filename: 'tool.txt',
+ type: 'text/plain',
+ size: 100,
+ });
+
+ // Agent 1: file in file_search (no access for testUser)
+ await createAgent({
+ id: 'agent_file_search',
+ name: 'File Search Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ tool_resources: {
+ file_search: {
+ file_ids: ['multi_tool_file'],
+ },
+ },
+ });
+
+ // Agent 2: same file in execute_code (testUser has access)
+ const agent2 = await createAgent({
+ id: 'agent_execute_code',
+ name: 'Execute Code Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ tool_resources: {
+ execute_code: {
+ file_ids: ['multi_tool_file'],
+ },
+ },
+ });
+
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent2._id,
+ permBits: 1,
+ grantedBy: otherUser._id,
+ });
+
+ // Agent 3: same file in ocr on the requesting user's own agent
+ await createAgent({
+ id: 'agent_ocr',
+ name: 'OCR Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ tool_resources: {
+ ocr: {
+ file_ids: ['multi_tool_file'],
+ },
+ },
+ });
+
+ req.params.file_id = 'multi_tool_file';
+ await fileAccess(req, res, next);
+
+ /**
+ * Should succeed through an attached agent,
+ * even if other agents with the file are found first.
+ */
+ expect(next).toHaveBeenCalled();
+ expect(req.fileAccess).toBeDefined();
+ });
+ });
+
+ describe('edge cases', () => {
+ test('should handle agent with empty tool_resources', async () => {
+ await createFile({
+ user: otherUser._id.toString(),
+ file_id: 'orphan_file',
+ filepath: '/test/orphan.txt',
+ filename: 'orphan.txt',
+ type: 'text/plain',
+ size: 100,
+ });
+
+ // Create agent with no files in tool_resources
+ await createAgent({
+ id: `agent_empty_${Date.now()}`,
+ name: 'Empty Resources Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ tool_resources: {},
+ });
+
+ req.params.file_id = 'orphan_file';
+ await fileAccess(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ test('should handle agent with null tool_resources', async () => {
+ await createFile({
+ user: otherUser._id.toString(),
+ file_id: 'another_orphan_file',
+ filepath: '/test/orphan2.txt',
+ filename: 'orphan2.txt',
+ type: 'text/plain',
+ size: 100,
+ });
+
+ // Create agent with null tool_resources
+ await createAgent({
+ id: `agent_null_${Date.now()}`,
+ name: 'Null Resources Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ tool_resources: null,
+ });
+
+ req.params.file_id = 'another_orphan_file';
+ await fileAccess(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ test('should deny agent-based access when file has no owner', async () => {
+ await mongoose.models.File.collection.insertOne({
+ file_id: 'ownerless_file',
+ filepath: '/test/ownerless.txt',
+ filename: 'ownerless.txt',
+ type: 'text/plain',
+ bytes: 100,
+ object: 'file',
+ });
+
+ await createAgent({
+ id: `agent_ownerless_${Date.now()}`,
+ name: 'Ownerless File Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ tool_resources: {
+ file_search: {
+ file_ids: ['ownerless_file'],
+ },
+ },
+ });
+
+ req.params.file_id = 'ownerless_file';
+ await fileAccess(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+ });
+});
diff --git a/api/server/middleware/accessResources/index.js b/api/server/middleware/accessResources/index.js
new file mode 100644
index 0000000..3154841
--- /dev/null
+++ b/api/server/middleware/accessResources/index.js
@@ -0,0 +1,17 @@
+const { canAccessResource } = require('./canAccessResource');
+const { canAccessAgentResource } = require('./canAccessAgentResource');
+const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
+const { canAccessPromptViaGroup } = require('./canAccessPromptViaGroup');
+const { canAccessPromptGroupResource } = require('./canAccessPromptGroupResource');
+const { canAccessMCPServerResource } = require('./canAccessMCPServerResource');
+const { canAccessSkillResource } = require('./canAccessSkillResource');
+
+module.exports = {
+ canAccessResource,
+ canAccessAgentResource,
+ canAccessAgentFromBody,
+ canAccessPromptViaGroup,
+ canAccessPromptGroupResource,
+ canAccessMCPServerResource,
+ canAccessSkillResource,
+};
diff --git a/api/server/middleware/assistants/validate.js b/api/server/middleware/assistants/validate.js
new file mode 100644
index 0000000..a09f1ee
--- /dev/null
+++ b/api/server/middleware/assistants/validate.js
@@ -0,0 +1,45 @@
+const { v4 } = require('uuid');
+const { handleAbortError } = require('~/server/middleware/abortMiddleware');
+
+/**
+ * Checks if the assistant is supported or excluded
+ * @param {object} req - Express Request
+ * @param {object} req.body - The request payload.
+ * @param {object} res - Express Response
+ * @param {function} next - Express next middleware function.
+ * @returns {Promise}
+ */
+const validateAssistant = async (req, res, next) => {
+ const { endpoint, conversationId, assistant_id, messageId } = req.body;
+
+ const appConfig = req.config;
+ /** @type {Partial} */
+ const assistantsConfig = appConfig.endpoints?.[endpoint];
+ if (!assistantsConfig) {
+ return next();
+ }
+
+ const { supportedIds, excludedIds } = assistantsConfig;
+ const error = { message: 'validateAssistant: Assistant not supported' };
+
+ if (supportedIds?.length && !supportedIds.includes(assistant_id)) {
+ return await handleAbortError(res, req, error, {
+ sender: 'System',
+ conversationId,
+ messageId: v4(),
+ parentMessageId: messageId,
+ error,
+ });
+ } else if (excludedIds?.length && excludedIds.includes(assistant_id)) {
+ return await handleAbortError(res, req, error, {
+ sender: 'System',
+ conversationId,
+ messageId: v4(),
+ parentMessageId: messageId,
+ });
+ }
+
+ return next();
+};
+
+module.exports = validateAssistant;
diff --git a/api/server/middleware/assistants/validateAuthor.js b/api/server/middleware/assistants/validateAuthor.js
new file mode 100644
index 0000000..024d6ab
--- /dev/null
+++ b/api/server/middleware/assistants/validateAuthor.js
@@ -0,0 +1,53 @@
+const { logger, SystemCapabilities } = require('@librechat/data-schemas');
+const { hasCapability } = require('~/server/middleware/roles/capabilities');
+const { getAssistant } = require('~/models');
+
+/**
+ * Checks if the assistant is supported or excluded
+ * @param {object} params
+ * @param {object} params.req - Express Request
+ * @param {object} params.req.body - The request payload.
+ * @param {string} params.overrideEndpoint - The override endpoint
+ * @param {string} params.overrideAssistantId - The override assistant ID
+ * @param {OpenAIClient} params.openai - OpenAI API Client
+ * @returns {Promise}
+ */
+const validateAuthor = async ({ req, openai, overrideEndpoint, overrideAssistantId }) => {
+ const endpoint = overrideEndpoint ?? req.body.endpoint ?? req.query.endpoint;
+ const assistant_id =
+ overrideAssistantId ?? req.params.id ?? req.body.assistant_id ?? req.query.assistant_id;
+
+ const appConfig = req.config;
+ /** @type {Partial} */
+ const assistantsConfig = appConfig.endpoints?.[endpoint];
+ if (!assistantsConfig) {
+ return;
+ }
+
+ if (!assistantsConfig.privateAssistants) {
+ return;
+ }
+
+ let canManageAssistants = false;
+ try {
+ canManageAssistants = await hasCapability(req.user, SystemCapabilities.MANAGE_ASSISTANTS);
+ } catch (err) {
+ logger.warn(`[validateAuthor] capability check failed, denying bypass: ${err.message}`);
+ }
+
+ if (canManageAssistants) {
+ logger.debug(`[validateAuthor] MANAGE_ASSISTANTS bypass for user ${req.user.id}`);
+ return;
+ }
+
+ const assistantDoc = await getAssistant({ assistant_id, user: req.user.id });
+ if (assistantDoc) {
+ return;
+ }
+ const assistant = await openai.beta.assistants.retrieve(assistant_id);
+ if (req.user.id !== assistant?.metadata?.author) {
+ throw new Error(`Assistant ${assistant_id} is not authored by the user.`);
+ }
+};
+
+module.exports = validateAuthor;
diff --git a/api/server/middleware/buildEndpointOption.js b/api/server/middleware/buildEndpointOption.js
new file mode 100644
index 0000000..84fb85d
--- /dev/null
+++ b/api/server/middleware/buildEndpointOption.js
@@ -0,0 +1,158 @@
+const {
+ handleError,
+ applyModelSpecPreset,
+ findModelSpecByName,
+ isModelSpecEndpointMatch,
+ resolveModelSpecPromptPrefixVariables,
+} = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const {
+ EndpointURLs,
+ EModelEndpoint,
+ isAgentsEndpoint,
+ parseCompactConvo,
+ getDefaultParamsEndpoint,
+} = require('librechat-data-provider');
+const azureAssistants = require('~/server/services/Endpoints/azureAssistants');
+const assistants = require('~/server/services/Endpoints/assistants');
+const { getEndpointsConfig } = require('~/server/services/Config');
+const agents = require('~/server/services/Endpoints/agents');
+const { updateFilesUsage } = require('~/models');
+
+const buildFunction = {
+ [EModelEndpoint.agents]: agents.buildOptions,
+ [EModelEndpoint.assistants]: assistants.buildOptions,
+ [EModelEndpoint.azureAssistants]: azureAssistants.buildOptions,
+};
+
+async function buildEndpointOption(req, res, next) {
+ const { endpoint, endpointType } = req.body;
+ const isAgents =
+ isAgentsEndpoint(endpoint) || req.baseUrl.startsWith(EndpointURLs[EModelEndpoint.agents]);
+
+ let endpointsConfig;
+ try {
+ endpointsConfig = await getEndpointsConfig(req);
+ } catch (error) {
+ logger.error('Error fetching endpoints config in buildEndpointOption', error);
+ }
+
+ const defaultParamsEndpoint = getDefaultParamsEndpoint(endpointsConfig, endpoint);
+
+ let parsedBody;
+ try {
+ parsedBody = parseCompactConvo({
+ endpoint,
+ endpointType,
+ conversation: req.body,
+ defaultParamsEndpoint,
+ });
+ } catch (error) {
+ logger.error(`Error parsing compact conversation for endpoint ${endpoint}`, error);
+ logger.debug({
+ 'Error parsing compact conversation': { endpoint, endpointType, conversation: req.body },
+ });
+ return handleError(res, { text: 'Error parsing conversation' });
+ }
+
+ const appConfig = req.config;
+ let appliedModelSpecPrivateFields = new Set();
+ if (appConfig.modelSpecs?.list?.length && appConfig.modelSpecs?.enforce) {
+ /** @type {{ list: TModelSpec[] }}*/
+ const { list } = appConfig.modelSpecs;
+ const rawSpec = req.body.spec;
+ const spec = parsedBody.spec ?? (typeof rawSpec === 'string' ? rawSpec : undefined);
+ const rawChatProjectId = req.body.chatProjectId;
+ const parsedBodyForModelSpec =
+ parsedBody.chatProjectId === undefined &&
+ (typeof rawChatProjectId === 'string' || rawChatProjectId === null)
+ ? { ...parsedBody, chatProjectId: rawChatProjectId }
+ : parsedBody;
+
+ if (!spec) {
+ return handleError(res, { text: 'No model spec selected' });
+ }
+
+ const currentModelSpec = findModelSpecByName({ list }, spec);
+ if (!currentModelSpec) {
+ return handleError(res, { text: 'Invalid model spec' });
+ }
+
+ if (!isModelSpecEndpointMatch(currentModelSpec, endpoint)) {
+ return handleError(res, { text: 'Model spec mismatch' });
+ }
+
+ try {
+ const result = applyModelSpecPreset({
+ modelSpec: currentModelSpec,
+ parsedBody: parsedBodyForModelSpec,
+ endpoint,
+ endpointType,
+ defaultParamsEndpoint,
+ includePresetDefaults: true,
+ });
+ parsedBody = result.parsedBody;
+ appliedModelSpecPrivateFields = result.appliedPrivateFields;
+ } catch (error) {
+ logger.error(`Error parsing model spec for endpoint ${endpoint}`, error);
+ return handleError(res, { text: 'Error parsing model spec' });
+ }
+ } else if (parsedBody.spec && appConfig.modelSpecs?.list) {
+ const modelSpec = findModelSpecByName(appConfig.modelSpecs, parsedBody.spec);
+ if (modelSpec) {
+ if (!isModelSpecEndpointMatch(modelSpec, endpoint)) {
+ return handleError(res, { text: 'Model spec mismatch' });
+ }
+
+ try {
+ const result = applyModelSpecPreset({
+ modelSpec,
+ parsedBody,
+ endpoint,
+ endpointType,
+ defaultParamsEndpoint,
+ });
+ parsedBody = result.parsedBody;
+ appliedModelSpecPrivateFields = result.appliedPrivateFields;
+ } catch (error) {
+ logger.error(`Error parsing model spec for endpoint ${endpoint}`, error);
+ return handleError(res, { text: 'Error parsing model spec' });
+ }
+ }
+ }
+
+ if (!isAgents && appliedModelSpecPrivateFields.has('promptPrefix')) {
+ parsedBody = resolveModelSpecPromptPrefixVariables(
+ parsedBody,
+ req.user,
+ req.body.clientTimestamp,
+ );
+ }
+
+ try {
+ const builder = isAgents
+ ? (...args) => buildFunction[EModelEndpoint.agents](req, ...args)
+ : buildFunction[endpointType ?? endpoint];
+
+ // TODO: use object params
+ req.body = req.body || {}; // Express 5: ensure req.body exists
+ req.body.endpointOption = await builder(endpoint, parsedBody, endpointType);
+
+ if (req.body.files && !isAgents) {
+ req.body.endpointOption.attachments = updateFilesUsage(req.body.files, undefined, {
+ user: req.user.id,
+ tenantId: req.user.tenantId,
+ });
+ }
+
+ next();
+ } catch (error) {
+ logger.error(
+ `Error building endpoint option for endpoint ${endpoint} with type ${endpointType}`,
+ error,
+ );
+ return handleError(res, { text: 'Error building endpoint option' });
+ }
+}
+
+module.exports = buildEndpointOption;
diff --git a/api/server/middleware/buildEndpointOption.spec.js b/api/server/middleware/buildEndpointOption.spec.js
new file mode 100644
index 0000000..abf49c1
--- /dev/null
+++ b/api/server/middleware/buildEndpointOption.spec.js
@@ -0,0 +1,521 @@
+/**
+ * Wrap parseCompactConvo: the REAL function runs, but jest can observe
+ * calls and return values. Must be declared before require('./buildEndpointOption')
+ * so the destructured reference in the middleware captures the wrapper.
+ */
+jest.mock('librechat-data-provider', () => {
+ const actual = jest.requireActual('librechat-data-provider');
+ return {
+ ...actual,
+ parseCompactConvo: jest.fn((...args) => actual.parseCompactConvo(...args)),
+ };
+});
+
+const { EModelEndpoint, parseCompactConvo } = require('librechat-data-provider');
+
+const mockBuildOptions = jest.fn((_endpoint, parsedBody) => ({
+ ...parsedBody,
+ endpoint: _endpoint,
+}));
+const mockAgentBuildOptions = jest.fn((_req, endpoint, parsedBody) => ({
+ ...parsedBody,
+ endpoint,
+}));
+
+jest.mock('~/server/services/Endpoints/azureAssistants', () => ({
+ buildOptions: mockBuildOptions,
+}));
+jest.mock('~/server/services/Endpoints/assistants', () => ({
+ buildOptions: mockBuildOptions,
+}));
+jest.mock('~/server/services/Endpoints/agents', () => ({
+ buildOptions: mockAgentBuildOptions,
+}));
+
+jest.mock('~/models', () => ({
+ updateFilesUsage: jest.fn(),
+}));
+const { updateFilesUsage } = require('~/models');
+
+const mockGetEndpointsConfig = jest.fn();
+jest.mock('~/server/services/Config', () => ({
+ getEndpointsConfig: (...args) => mockGetEndpointsConfig(...args),
+}));
+
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ handleError: jest.fn(),
+}));
+
+const buildEndpointOption = require('./buildEndpointOption');
+
+const createReq = (body, config = {}) => ({
+ body,
+ config,
+ baseUrl: '/api/chat',
+});
+
+const createRes = () => ({
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+});
+
+describe('buildEndpointOption - defaultParamsEndpoint parsing', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should pass defaultParamsEndpoint to parseCompactConvo and preserve maxOutputTokens', async () => {
+ mockGetEndpointsConfig.mockResolvedValue({
+ AnthropicClaude: {
+ type: EModelEndpoint.custom,
+ customParams: {
+ defaultParamsEndpoint: EModelEndpoint.anthropic,
+ },
+ },
+ });
+
+ const req = createReq(
+ {
+ endpoint: 'AnthropicClaude',
+ endpointType: EModelEndpoint.custom,
+ model: 'anthropic/claude-opus-4.5',
+ temperature: 0.7,
+ maxOutputTokens: 8192,
+ topP: 0.9,
+ maxContextTokens: 50000,
+ },
+ { modelSpecs: null },
+ );
+
+ await buildEndpointOption(req, createRes(), jest.fn());
+
+ expect(parseCompactConvo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ defaultParamsEndpoint: EModelEndpoint.anthropic,
+ }),
+ );
+
+ const parsedResult = parseCompactConvo.mock.results[0].value;
+ expect(parsedResult.maxOutputTokens).toBe(8192);
+ expect(parsedResult.topP).toBe(0.9);
+ expect(parsedResult.temperature).toBe(0.7);
+ expect(parsedResult.maxContextTokens).toBe(50000);
+ });
+
+ it('should strip maxOutputTokens when no defaultParamsEndpoint is configured', async () => {
+ mockGetEndpointsConfig.mockResolvedValue({
+ MyOpenRouter: {
+ type: EModelEndpoint.custom,
+ },
+ });
+
+ const req = createReq(
+ {
+ endpoint: 'MyOpenRouter',
+ endpointType: EModelEndpoint.custom,
+ model: 'gpt-4o',
+ temperature: 0.7,
+ maxOutputTokens: 8192,
+ max_tokens: 4096,
+ },
+ { modelSpecs: null },
+ );
+
+ await buildEndpointOption(req, createRes(), jest.fn());
+
+ expect(parseCompactConvo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ defaultParamsEndpoint: undefined,
+ }),
+ );
+
+ const parsedResult = parseCompactConvo.mock.results[0].value;
+ expect(parsedResult.maxOutputTokens).toBeUndefined();
+ expect(parsedResult.max_tokens).toBe(4096);
+ expect(parsedResult.temperature).toBe(0.7);
+ });
+
+ it('should strip bedrock region from custom endpoint without defaultParamsEndpoint', async () => {
+ mockGetEndpointsConfig.mockResolvedValue({
+ MyEndpoint: {
+ type: EModelEndpoint.custom,
+ },
+ });
+
+ const req = createReq(
+ {
+ endpoint: 'MyEndpoint',
+ endpointType: EModelEndpoint.custom,
+ model: 'gpt-4o',
+ temperature: 0.7,
+ region: 'us-east-1',
+ },
+ { modelSpecs: null },
+ );
+
+ await buildEndpointOption(req, createRes(), jest.fn());
+
+ const parsedResult = parseCompactConvo.mock.results[0].value;
+ expect(parsedResult.region).toBeUndefined();
+ expect(parsedResult.temperature).toBe(0.7);
+ });
+
+ it('should pass defaultParamsEndpoint when re-parsing enforced model spec', async () => {
+ mockGetEndpointsConfig.mockResolvedValue({
+ AnthropicClaude: {
+ type: EModelEndpoint.custom,
+ customParams: {
+ defaultParamsEndpoint: EModelEndpoint.anthropic,
+ },
+ },
+ });
+
+ const modelSpec = {
+ name: 'claude-opus-4.5',
+ preset: {
+ endpoint: 'AnthropicClaude',
+ endpointType: EModelEndpoint.custom,
+ model: 'anthropic/claude-opus-4.5',
+ temperature: 0.7,
+ maxOutputTokens: 8192,
+ maxContextTokens: 50000,
+ },
+ };
+
+ const req = createReq(
+ {
+ endpoint: 'AnthropicClaude',
+ endpointType: EModelEndpoint.custom,
+ spec: 'claude-opus-4.5',
+ model: 'anthropic/claude-opus-4.5',
+ temperature: 0.1,
+ topP: 0.2,
+ chatProjectId: 'project-1',
+ },
+ {
+ modelSpecs: {
+ enforce: true,
+ list: [modelSpec],
+ },
+ },
+ );
+ req.baseUrl = '/api/agents/chat';
+
+ await buildEndpointOption(req, createRes(), jest.fn());
+
+ const enforcedCall = parseCompactConvo.mock.calls[1];
+ expect(enforcedCall[0]).toEqual(
+ expect.objectContaining({
+ defaultParamsEndpoint: EModelEndpoint.anthropic,
+ }),
+ );
+
+ const enforcedResult = parseCompactConvo.mock.results[1].value;
+ expect(enforcedResult.maxOutputTokens).toBe(8192);
+ expect(enforcedResult.temperature).toBe(0.7);
+ expect(enforcedResult.topP).toBeUndefined();
+ expect(enforcedResult.maxContextTokens).toBe(50000);
+ expect(enforcedResult.chatProjectId).toBe('project-1');
+ expect(req.body.endpointOption.chatProjectId).toBe('project-1');
+ });
+
+ it('should rebuild enforced custom specs from the backend preset when compact parsing drops raw fields', async () => {
+ mockGetEndpointsConfig.mockResolvedValue({});
+
+ const modelSpec = {
+ name: 'approved-custom',
+ preset: {
+ endpoint: 'Mock Provider A',
+ endpointType: EModelEndpoint.custom,
+ model: 'mock-model-a',
+ promptPrefix: 'Use the approved custom model spec.',
+ },
+ };
+
+ const req = createReq(
+ {
+ endpoint: 'Mock Provider A',
+ endpointType: EModelEndpoint.custom,
+ spec: 'approved-custom',
+ model: { stale: 'cached-client-value' },
+ agent_id: 'agent_from_cached_client_state',
+ chatProjectId: 'project-1',
+ },
+ {
+ modelSpecs: {
+ enforce: true,
+ list: [modelSpec],
+ },
+ },
+ );
+ req.baseUrl = '/api/agents/chat';
+
+ await buildEndpointOption(req, createRes(), jest.fn());
+
+ expect(parseCompactConvo.mock.results[0].value).toEqual({});
+ expect(req.body.endpointOption.spec).toBe('approved-custom');
+ expect(req.body.endpointOption.model).toBe('mock-model-a');
+ expect(req.body.endpointOption.promptPrefix).toBe('Use the approved custom model spec.');
+ expect(req.body.endpointOption.chatProjectId).toBe('project-1');
+ });
+
+ it('should restore private model spec preset fields in non-enforced mode', async () => {
+ mockGetEndpointsConfig.mockResolvedValue({});
+
+ const modelSpec = {
+ name: 'guarded-openai',
+ iconURL: 'openAI',
+ preset: {
+ endpoint: EModelEndpoint.openAI,
+ model: 'gpt-4o',
+ promptPrefix: 'private prompt prefix',
+ instructions: 'private instructions',
+ additional_instructions: 'private additional instructions',
+ temperature: 0.2,
+ maxContextTokens: 10000,
+ },
+ };
+
+ const req = createReq(
+ {
+ endpoint: EModelEndpoint.openAI,
+ spec: 'guarded-openai',
+ model: 'gpt-4o',
+ temperature: 0.8,
+ },
+ {
+ modelSpecs: {
+ enforce: false,
+ list: [modelSpec],
+ },
+ },
+ );
+ req.baseUrl = '/api/agents/chat';
+
+ await buildEndpointOption(req, createRes(), jest.fn());
+
+ expect(req.body.endpointOption.promptPrefix).toBe('private prompt prefix');
+ expect(req.body.endpointOption.instructions).toBeUndefined();
+ expect(req.body.endpointOption.additional_instructions).toBeUndefined();
+ expect(req.body.endpointOption.temperature).toBe(0.8);
+ expect(req.body.endpointOption.maxContextTokens).toBeUndefined();
+ expect(req.body.endpointOption.iconURL).toBe('openAI');
+ });
+
+ it('should reject non-enforced model specs for a different endpoint', async () => {
+ mockGetEndpointsConfig.mockResolvedValue({});
+
+ const req = createReq(
+ {
+ endpoint: EModelEndpoint.openAI,
+ spec: 'guarded-google',
+ model: 'gpt-4o',
+ },
+ {
+ modelSpecs: {
+ enforce: false,
+ list: [
+ {
+ name: 'guarded-google',
+ preset: {
+ endpoint: EModelEndpoint.google,
+ model: 'gemini-pro',
+ promptPrefix: 'private google prompt',
+ },
+ },
+ ],
+ },
+ },
+ );
+ const res = createRes();
+ const next = jest.fn();
+ const { handleError } = require('@librechat/api');
+
+ await buildEndpointOption(req, res, next);
+
+ expect(handleError).toHaveBeenCalledWith(res, { text: 'Model spec mismatch' });
+ expect(mockAgentBuildOptions).not.toHaveBeenCalled();
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should restore private model spec examples when the parser supplies an empty default', async () => {
+ mockGetEndpointsConfig.mockResolvedValue({});
+
+ const examples = [{ input: { content: 'hello' }, output: { content: 'world' } }];
+ const req = createReq(
+ {
+ endpoint: EModelEndpoint.google,
+ spec: 'guarded-google',
+ model: 'gemini-pro',
+ },
+ {
+ modelSpecs: {
+ enforce: false,
+ list: [
+ {
+ name: 'guarded-google',
+ preset: {
+ endpoint: EModelEndpoint.google,
+ model: 'gemini-pro',
+ examples,
+ },
+ },
+ ],
+ },
+ },
+ );
+ req.baseUrl = '/api/agents/chat';
+
+ await buildEndpointOption(req, createRes(), jest.fn());
+
+ expect(req.body.endpointOption.examples).toEqual(examples);
+ });
+
+ it('should resolve special variables for restored non-agent promptPrefix', async () => {
+ mockGetEndpointsConfig.mockResolvedValue({});
+
+ const req = createReq(
+ {
+ endpoint: EModelEndpoint.assistants,
+ spec: 'guarded-assistant',
+ assistant_id: 'asst_123',
+ },
+ {
+ modelSpecs: {
+ enforce: false,
+ list: [
+ {
+ name: 'guarded-assistant',
+ preset: {
+ endpoint: EModelEndpoint.assistants,
+ assistant_id: 'asst_123',
+ promptPrefix: 'Help {{current_user}}.',
+ },
+ },
+ ],
+ },
+ },
+ );
+ req.user = { name: 'Ada' };
+
+ await buildEndpointOption(req, createRes(), jest.fn());
+
+ expect(req.body.endpointOption.promptPrefix).toBe('Help Ada.');
+ });
+
+ it('should leave restored agent promptPrefix variables for agent initialization', async () => {
+ mockGetEndpointsConfig.mockResolvedValue({});
+
+ const req = createReq(
+ {
+ endpoint: EModelEndpoint.openAI,
+ spec: 'guarded-openai',
+ model: 'gpt-4o',
+ },
+ {
+ modelSpecs: {
+ enforce: false,
+ list: [
+ {
+ name: 'guarded-openai',
+ preset: {
+ endpoint: EModelEndpoint.openAI,
+ model: 'gpt-4o',
+ promptPrefix: 'Help {{current_user}}.',
+ },
+ },
+ ],
+ },
+ },
+ );
+ req.baseUrl = '/api/agents/chat';
+ req.user = { name: 'Ada' };
+
+ await buildEndpointOption(req, createRes(), jest.fn());
+
+ expect(req.body.endpointOption.promptPrefix).toBe('Help {{current_user}}.');
+ });
+
+ it('should fall back to OpenAI schema when getEndpointsConfig fails', async () => {
+ mockGetEndpointsConfig.mockRejectedValue(new Error('Config unavailable'));
+
+ const req = createReq(
+ {
+ endpoint: 'AnthropicClaude',
+ endpointType: EModelEndpoint.custom,
+ model: 'anthropic/claude-opus-4.5',
+ temperature: 0.7,
+ maxOutputTokens: 8192,
+ max_tokens: 4096,
+ },
+ { modelSpecs: null },
+ );
+
+ await buildEndpointOption(req, createRes(), jest.fn());
+
+ expect(parseCompactConvo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ defaultParamsEndpoint: undefined,
+ }),
+ );
+
+ const parsedResult = parseCompactConvo.mock.results[0].value;
+ expect(parsedResult.maxOutputTokens).toBeUndefined();
+ expect(parsedResult.max_tokens).toBe(4096);
+ });
+
+ it('should scope non-agent chat attachment usage updates to the authenticated user', async () => {
+ const attachments = Promise.resolve([]);
+ updateFilesUsage.mockReturnValueOnce(attachments);
+ mockGetEndpointsConfig.mockResolvedValue({});
+
+ const req = createReq(
+ {
+ endpoint: EModelEndpoint.assistants,
+ assistant_id: 'asst_123',
+ files: [{ file_id: 'forged-file-id' }],
+ },
+ { modelSpecs: null },
+ );
+ req.user = { id: 'user-1' };
+
+ await buildEndpointOption(req, createRes(), jest.fn());
+
+ expect(updateFilesUsage).toHaveBeenCalledWith(req.body.files, undefined, {
+ user: 'user-1',
+ tenantId: undefined,
+ });
+ expect(req.body.endpointOption.attachments).toBe(attachments);
+ });
+
+ it('should not enter the enforce branch when modelSpecs.list is empty', async () => {
+ mockGetEndpointsConfig.mockResolvedValue({});
+
+ const req = createReq(
+ {
+ endpoint: EModelEndpoint.openAI,
+ model: 'gpt-4',
+ },
+ {
+ modelSpecs: {
+ enforce: true,
+ list: [],
+ },
+ },
+ );
+ const res = createRes();
+ const { handleError } = require('@librechat/api');
+
+ await buildEndpointOption(req, res, jest.fn());
+
+ expect(handleError).not.toHaveBeenCalledWith(
+ res,
+ expect.objectContaining({ text: 'No model spec selected' }),
+ );
+ expect(handleError).not.toHaveBeenCalledWith(
+ res,
+ expect.objectContaining({ text: 'Invalid model spec' }),
+ );
+ });
+});
diff --git a/api/server/middleware/canAccessSharedLink.js b/api/server/middleware/canAccessSharedLink.js
new file mode 100644
index 0000000..79fd93e
--- /dev/null
+++ b/api/server/middleware/canAccessSharedLink.js
@@ -0,0 +1,6 @@
+const mongoose = require('mongoose');
+const { createSharedLinkAccessMiddleware } = require('@librechat/api');
+
+const canAccessSharedLink = createSharedLinkAccessMiddleware({ mongoose });
+
+module.exports = canAccessSharedLink;
diff --git a/api/server/middleware/canDeleteAccount.js b/api/server/middleware/canDeleteAccount.js
new file mode 100644
index 0000000..3c08745
--- /dev/null
+++ b/api/server/middleware/canDeleteAccount.js
@@ -0,0 +1,45 @@
+const { isEnabled } = require('@librechat/api');
+const { logger, SystemCapabilities } = require('@librechat/data-schemas');
+const { hasCapability } = require('~/server/middleware/roles/capabilities');
+
+/**
+ * Checks if the user can delete their account
+ *
+ * @async
+ * @function
+ * @param {Object} req - Express request object
+ * @param {Object} res - Express response object
+ * @param {Function} next - Next middleware function
+ *
+ * @returns {Promise} - Returns a Promise which when resolved calls next middleware if the user can delete their account
+ */
+
+const canDeleteAccount = async (req, res, next = () => {}) => {
+ const { user } = req;
+ const { ALLOW_ACCOUNT_DELETION = true } = process.env;
+ if (isEnabled(ALLOW_ACCOUNT_DELETION)) {
+ return next();
+ }
+ let hasAdminAccess = false;
+ if (user) {
+ try {
+ const id = user.id ?? user._id?.toString();
+ if (id) {
+ hasAdminAccess = await hasCapability(
+ { id, role: user.role ?? '', tenantId: user.tenantId },
+ SystemCapabilities.ACCESS_ADMIN,
+ );
+ }
+ } catch (err) {
+ logger.warn(`[canDeleteAccount] capability check failed, denying: ${err.message}`);
+ }
+ }
+ if (hasAdminAccess) {
+ logger.debug(`[canDeleteAccount] ACCESS_ADMIN bypass for user ${user.id}`);
+ return next();
+ }
+ logger.error(`[User] [Delete Account] [User cannot delete account] [User: ${user?.id}]`);
+ return res.status(403).send({ message: 'You do not have permission to delete this account' });
+};
+
+module.exports = canDeleteAccount;
diff --git a/api/server/middleware/canDeleteAccount.spec.js b/api/server/middleware/canDeleteAccount.spec.js
new file mode 100644
index 0000000..abb888c
--- /dev/null
+++ b/api/server/middleware/canDeleteAccount.spec.js
@@ -0,0 +1,180 @@
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { SystemRoles, PrincipalType } = require('librechat-data-provider');
+const { SystemCapabilities } = require('@librechat/data-schemas');
+
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
+}));
+
+jest.mock('~/cache', () => ({
+ getLogStores: jest.fn(() => ({
+ get: jest.fn(),
+ set: jest.fn(),
+ })),
+}));
+
+const { User, SystemGrant } = require('~/db/models');
+const canDeleteAccount = require('./canDeleteAccount');
+
+let mongoServer;
+
+beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ await mongoose.connect(mongoServer.getUri());
+});
+
+afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+});
+
+beforeEach(async () => {
+ await mongoose.connection.dropDatabase();
+ delete process.env.ALLOW_ACCOUNT_DELETION;
+});
+
+const makeRes = () => {
+ const send = jest.fn();
+ const status = jest.fn().mockReturnValue({ send });
+ return { status, send };
+};
+
+describe('canDeleteAccount', () => {
+ describe('ALLOW_ACCOUNT_DELETION=true (default)', () => {
+ it('calls next without hitting the DB', async () => {
+ process.env.ALLOW_ACCOUNT_DELETION = 'true';
+ const next = jest.fn();
+ const req = { user: { id: 'user-1', role: SystemRoles.USER } };
+
+ await canDeleteAccount(req, makeRes(), next);
+
+ expect(next).toHaveBeenCalled();
+ });
+
+ it('skips capability check entirely when deletion is allowed', async () => {
+ process.env.ALLOW_ACCOUNT_DELETION = 'true';
+ const next = jest.fn();
+ const req = { user: { id: 'user-1', role: SystemRoles.USER } };
+
+ await canDeleteAccount(req, makeRes(), next);
+
+ expect(next).toHaveBeenCalled();
+ const grantCount = await SystemGrant.countDocuments();
+ expect(grantCount).toBe(0);
+ });
+ });
+
+ describe('ALLOW_ACCOUNT_DELETION=false', () => {
+ beforeEach(() => {
+ process.env.ALLOW_ACCOUNT_DELETION = 'false';
+ });
+
+ it('allows admin with ACCESS_ADMIN grant (real DB check)', async () => {
+ const admin = await User.create({
+ name: 'Admin',
+ email: 'admin@test.com',
+ password: 'password123',
+ provider: 'local',
+ role: SystemRoles.ADMIN,
+ });
+
+ await SystemGrant.create({
+ principalType: PrincipalType.ROLE,
+ principalId: SystemRoles.ADMIN,
+ capability: SystemCapabilities.ACCESS_ADMIN,
+ grantedAt: new Date(),
+ });
+
+ const next = jest.fn();
+ const req = { user: { id: admin._id.toString(), role: SystemRoles.ADMIN } };
+
+ await canDeleteAccount(req, makeRes(), next);
+
+ expect(next).toHaveBeenCalled();
+ });
+
+ it('blocks regular user without ACCESS_ADMIN grant', async () => {
+ const user = await User.create({
+ name: 'Regular',
+ email: 'user@test.com',
+ password: 'password123',
+ provider: 'local',
+ role: SystemRoles.USER,
+ });
+
+ const next = jest.fn();
+ const res = makeRes();
+ const req = { user: { id: user._id.toString(), role: SystemRoles.USER } };
+
+ await canDeleteAccount(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ it('blocks admin role WITHOUT the ACCESS_ADMIN grant', async () => {
+ const admin = await User.create({
+ name: 'Admin No Grant',
+ email: 'admin2@test.com',
+ password: 'password123',
+ provider: 'local',
+ role: SystemRoles.ADMIN,
+ });
+
+ const next = jest.fn();
+ const res = makeRes();
+ const req = { user: { id: admin._id.toString(), role: SystemRoles.ADMIN } };
+
+ await canDeleteAccount(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ it('allows user-level grant (not just role-level)', async () => {
+ const user = await User.create({
+ name: 'Privileged User',
+ email: 'priv@test.com',
+ password: 'password123',
+ provider: 'local',
+ role: SystemRoles.USER,
+ });
+
+ await SystemGrant.create({
+ principalType: PrincipalType.USER,
+ principalId: user._id,
+ capability: SystemCapabilities.ACCESS_ADMIN,
+ grantedAt: new Date(),
+ });
+
+ const next = jest.fn();
+ const req = { user: { id: user._id.toString(), role: SystemRoles.USER } };
+
+ await canDeleteAccount(req, makeRes(), next);
+
+ expect(next).toHaveBeenCalled();
+ });
+
+ it('blocks when user is undefined — does not throw', async () => {
+ const next = jest.fn();
+ const res = makeRes();
+
+ await canDeleteAccount({ user: undefined }, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ it('blocks when user is null — does not throw', async () => {
+ const next = jest.fn();
+ const res = makeRes();
+
+ await canDeleteAccount({ user: null }, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+ });
+});
diff --git a/api/server/middleware/checkBan.js b/api/server/middleware/checkBan.js
new file mode 100644
index 0000000..5d1b602
--- /dev/null
+++ b/api/server/middleware/checkBan.js
@@ -0,0 +1,145 @@
+const { Keyv } = require('keyv');
+const uap = require('ua-parser-js');
+const { logger } = require('@librechat/data-schemas');
+const { ViolationTypes } = require('librechat-data-provider');
+const { isEnabled, keyvMongo, removePorts } = require('@librechat/api');
+const { getLogStores } = require('~/cache');
+const denyRequest = require('./denyRequest');
+const { findUser } = require('~/models');
+
+const banCache = new Keyv({ store: keyvMongo, namespace: ViolationTypes.BAN, ttl: 0 });
+const message = 'Your account has been temporarily banned due to violations of our service.';
+
+/** @returns {string} Cache key for ban lookups, prefixed for Redis or raw for MongoDB */
+const getBanCacheKey = (prefix, value, useRedis) => {
+ if (!value) {
+ return '';
+ }
+ return useRedis ? `ban_cache:${prefix}:${value}` : value;
+};
+
+/**
+ * Respond to the request if the user is banned.
+ *
+ * @async
+ * @function
+ * @param {Object} req - Express Request object.
+ * @param {Object} res - Express Response object.
+ *
+ * @returns {Promise} - Returns a Promise which when resolved sends a response status of 403 with a specific message if request is not of api/agents/chat. If it is, calls `denyRequest()` function.
+ */
+const banResponse = async (req, res) => {
+ const ua = uap(req.headers['user-agent']);
+ const { baseUrl, originalUrl } = req;
+ if (!ua.browser.name) {
+ return res.status(403).json({ message });
+ } else if (baseUrl === '/api/agents' && originalUrl.startsWith('/api/agents/chat')) {
+ return await denyRequest(req, res, { type: ViolationTypes.BAN });
+ }
+
+ return res.status(403).json({ message });
+};
+
+/**
+ * Checks if the source IP or user is banned or not.
+ *
+ * @async
+ * @function
+ * @param {Object} req - Express request object.
+ * @param {Object} res - Express response object.
+ * @param {import('express').NextFunction} next - Next middleware function.
+ *
+ * @returns {Promise} - Returns a Promise which when resolved calls next middleware if user or source IP is not banned. Otherwise calls `banResponse()` and sets ban details in `banCache`.
+ */
+const checkBan = async (req, res, next = () => {}) => {
+ try {
+ const { BAN_VIOLATIONS } = process.env ?? {};
+
+ if (!isEnabled(BAN_VIOLATIONS)) {
+ return next();
+ }
+
+ req.ip = removePorts(req);
+ let userId = req.user?.id ?? req.user?._id ?? null;
+
+ if (!userId && req?.body?.email) {
+ const user = await findUser({ email: req.body.email }, '_id');
+ userId = user?._id ? user._id.toString() : userId;
+ }
+
+ if (!userId && !req.ip) {
+ return next();
+ }
+
+ const useRedis = isEnabled(process.env.USE_REDIS);
+ const ipKey = getBanCacheKey('ip', req.ip, useRedis);
+ const userKey = getBanCacheKey('user', userId, useRedis);
+
+ const [cachedIPBan, cachedUserBan] = await Promise.all([
+ ipKey ? banCache.get(ipKey) : undefined,
+ userKey ? banCache.get(userKey) : undefined,
+ ]);
+
+ if (cachedIPBan || cachedUserBan) {
+ req.banned = true;
+ return await banResponse(req, res);
+ }
+
+ const banLogs = getLogStores(ViolationTypes.BAN);
+ const duration = banLogs.opts.ttl;
+
+ if (duration <= 0) {
+ return next();
+ }
+
+ const [ipBan, userBan] = await Promise.all([
+ req.ip ? banLogs.get(req.ip) : undefined,
+ userId ? banLogs.get(userId) : undefined,
+ ]);
+
+ const banData = ipBan || userBan;
+
+ if (!banData) {
+ return next();
+ }
+
+ const expiresAt = Number(banData.expiresAt);
+ if (!banData.expiresAt || isNaN(expiresAt)) {
+ req.banned = true;
+ return await banResponse(req, res);
+ }
+
+ const timeLeft = expiresAt - Date.now();
+
+ if (timeLeft <= 0) {
+ const cleanups = [];
+ if (ipBan) {
+ cleanups.push(banLogs.delete(req.ip));
+ }
+ if (userBan) {
+ cleanups.push(banLogs.delete(userId));
+ }
+ await Promise.all(cleanups);
+ return next();
+ }
+
+ const cacheWrites = [];
+ if (ipKey) {
+ cacheWrites.push(banCache.set(ipKey, banData, timeLeft));
+ }
+ if (userKey) {
+ cacheWrites.push(banCache.set(userKey, banData, timeLeft));
+ }
+ await Promise.all(cacheWrites).catch((err) =>
+ logger.warn('[checkBan] Failed to write ban cache:', err),
+ );
+
+ req.banned = true;
+ return await banResponse(req, res);
+ } catch (error) {
+ logger.error('Error in checkBan middleware:', error);
+ return next(error);
+ }
+};
+
+module.exports = checkBan;
diff --git a/api/server/middleware/checkDomainAllowed.js b/api/server/middleware/checkDomainAllowed.js
new file mode 100644
index 0000000..7e0b746
--- /dev/null
+++ b/api/server/middleware/checkDomainAllowed.js
@@ -0,0 +1,34 @@
+const { logger } = require('@librechat/data-schemas');
+const { getAppConfigOptionsFromUser, isEmailDomainAllowed } = require('@librechat/api');
+const { getAppConfig } = require('~/server/services/Config');
+
+/**
+ * Checks the domain's social login is allowed
+ *
+ * @async
+ * @function
+ * @param {Object} req - Express request object.
+ * @param {Object} res - Express response object.
+ * @param {Function} next - Next middleware function.
+ *
+ * @returns {Promise} - Calls next middleware if the domain's email is allowed, otherwise redirects to login
+ */
+const checkDomainAllowed = async (req, res, next) => {
+ try {
+ const email = req?.user?.email;
+ const appConfig = await getAppConfig(getAppConfigOptionsFromUser(req?.user));
+
+ if (email && !isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
+ logger.error(`[Social Login] [Social Login not allowed] [Email: ${email}]`);
+ res.redirect('/login');
+ return;
+ }
+
+ next();
+ } catch (error) {
+ logger.error('[checkDomainAllowed] Error checking domain:', error);
+ res.redirect('/login');
+ }
+};
+
+module.exports = checkDomainAllowed;
diff --git a/api/server/middleware/checkInviteUser.js b/api/server/middleware/checkInviteUser.js
new file mode 100644
index 0000000..22f2824
--- /dev/null
+++ b/api/server/middleware/checkInviteUser.js
@@ -0,0 +1,30 @@
+const { getInvite: getInviteFn } = require('@librechat/api');
+const { createToken, findToken, deleteTokens } = require('~/models');
+
+const getInvite = (encodedToken, email) =>
+ getInviteFn(encodedToken, email, { createToken, findToken });
+
+async function checkInviteUser(req, res, next) {
+ const token = req.body.token;
+
+ if (!token || token === 'undefined') {
+ next();
+ return;
+ }
+
+ try {
+ const invite = await getInvite(token, req.body.email);
+
+ if (!invite || invite.error === true) {
+ return res.status(400).json({ message: 'Invalid invite token' });
+ }
+
+ await deleteTokens({ token: invite.token });
+ req.invite = invite;
+ next();
+ } catch (error) {
+ return res.status(429).json({ message: error.message });
+ }
+}
+
+module.exports = checkInviteUser;
diff --git a/api/server/middleware/checkPeoplePickerAccess.js b/api/server/middleware/checkPeoplePickerAccess.js
new file mode 100644
index 0000000..50f1372
--- /dev/null
+++ b/api/server/middleware/checkPeoplePickerAccess.js
@@ -0,0 +1,106 @@
+const { logger } = require('@librechat/data-schemas');
+const { PrincipalType, PermissionTypes, Permissions } = require('librechat-data-provider');
+const { getRoleByName } = require('~/models');
+
+const VALID_PRINCIPAL_TYPES = new Set([
+ PrincipalType.USER,
+ PrincipalType.GROUP,
+ PrincipalType.ROLE,
+]);
+
+/**
+ * Middleware to check if user has permission to access people picker functionality.
+ * Validates requested principal types via `type` (singular) and `types` (comma-separated or array)
+ * query parameters against the caller's role permissions:
+ * - user: requires VIEW_USERS permission
+ * - group: requires VIEW_GROUPS permission
+ * - role: requires VIEW_ROLES permission
+ * - no type filter (mixed search): requires at least one of the above
+ */
+const checkPeoplePickerAccess = async (req, res, next) => {
+ try {
+ const user = req.user;
+ if (!user || !user.role) {
+ return res.status(401).json({
+ error: 'Unauthorized',
+ message: 'Authentication required',
+ });
+ }
+
+ const role = await getRoleByName(user.role);
+ if (!role || !role.permissions) {
+ return res.status(403).json({
+ error: 'Forbidden',
+ message: 'No permissions configured for user role',
+ });
+ }
+
+ const { type, types } = req.query;
+ const peoplePickerPerms = role.permissions[PermissionTypes.PEOPLE_PICKER] || {};
+ const canViewUsers = peoplePickerPerms[Permissions.VIEW_USERS] === true;
+ const canViewGroups = peoplePickerPerms[Permissions.VIEW_GROUPS] === true;
+ const canViewRoles = peoplePickerPerms[Permissions.VIEW_ROLES] === true;
+
+ const permissionChecks = {
+ [PrincipalType.USER]: {
+ hasPermission: canViewUsers,
+ message: 'Insufficient permissions to search for users',
+ },
+ [PrincipalType.GROUP]: {
+ hasPermission: canViewGroups,
+ message: 'Insufficient permissions to search for groups',
+ },
+ [PrincipalType.ROLE]: {
+ hasPermission: canViewRoles,
+ message: 'Insufficient permissions to search for roles',
+ },
+ };
+
+ const requestedTypes = new Set();
+
+ if (type && VALID_PRINCIPAL_TYPES.has(type)) {
+ requestedTypes.add(type);
+ }
+
+ if (types) {
+ const typesArray = Array.isArray(types) ? types : types.split(',');
+ for (const t of typesArray) {
+ if (VALID_PRINCIPAL_TYPES.has(t)) {
+ requestedTypes.add(t);
+ }
+ }
+ }
+
+ for (const requested of requestedTypes) {
+ const check = permissionChecks[requested];
+ if (!check.hasPermission) {
+ return res.status(403).json({
+ error: 'Forbidden',
+ message: check.message,
+ });
+ }
+ }
+
+ if (requestedTypes.size === 0 && !canViewUsers && !canViewGroups && !canViewRoles) {
+ return res.status(403).json({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to search for users, groups, or roles',
+ });
+ }
+
+ next();
+ } catch (error) {
+ logger.error(
+ `[checkPeoplePickerAccess][${req.user?.id}] error for type=${req.query.type}, types=${req.query.types}`,
+ error,
+ );
+ return res.status(500).json({
+ error: 'Internal Server Error',
+ message: 'Failed to check permissions',
+ });
+ }
+};
+
+module.exports = {
+ checkPeoplePickerAccess,
+};
diff --git a/api/server/middleware/checkPeoplePickerAccess.spec.js b/api/server/middleware/checkPeoplePickerAccess.spec.js
new file mode 100644
index 0000000..c394bba
--- /dev/null
+++ b/api/server/middleware/checkPeoplePickerAccess.spec.js
@@ -0,0 +1,416 @@
+const { logger } = require('@librechat/data-schemas');
+const { PrincipalType, PermissionTypes, Permissions } = require('librechat-data-provider');
+const { checkPeoplePickerAccess } = require('./checkPeoplePickerAccess');
+const { getRoleByName } = require('~/models');
+
+jest.mock('~/models');
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ logger: {
+ error: jest.fn(),
+ },
+}));
+
+describe('checkPeoplePickerAccess', () => {
+ let req, res, next;
+
+ beforeEach(() => {
+ req = {
+ user: { id: 'user123', role: 'USER' },
+ query: {},
+ };
+ res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+ next = jest.fn();
+ jest.clearAllMocks();
+ });
+
+ it('should return 401 if user is not authenticated', async () => {
+ req.user = null;
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Unauthorized',
+ message: 'Authentication required',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should return 403 if role has no permissions', async () => {
+ getRoleByName.mockResolvedValue(null);
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'No permissions configured for user role',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should allow access when searching for users with VIEW_USERS permission', async () => {
+ req.query.type = PrincipalType.USER;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: true,
+ [Permissions.VIEW_GROUPS]: false,
+ [Permissions.VIEW_ROLES]: false,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ it('should deny access when searching for users without VIEW_USERS permission', async () => {
+ req.query.type = PrincipalType.USER;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: false,
+ [Permissions.VIEW_GROUPS]: true,
+ [Permissions.VIEW_ROLES]: true,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to search for users',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should allow access when searching for groups with VIEW_GROUPS permission', async () => {
+ req.query.type = PrincipalType.GROUP;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: false,
+ [Permissions.VIEW_GROUPS]: true,
+ [Permissions.VIEW_ROLES]: false,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ it('should deny access when searching for groups without VIEW_GROUPS permission', async () => {
+ req.query.type = PrincipalType.GROUP;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: true,
+ [Permissions.VIEW_GROUPS]: false,
+ [Permissions.VIEW_ROLES]: true,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to search for groups',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should allow access when searching for roles with VIEW_ROLES permission', async () => {
+ req.query.type = PrincipalType.ROLE;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: false,
+ [Permissions.VIEW_GROUPS]: false,
+ [Permissions.VIEW_ROLES]: true,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ it('should deny access when searching for roles without VIEW_ROLES permission', async () => {
+ req.query.type = PrincipalType.ROLE;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: true,
+ [Permissions.VIEW_GROUPS]: true,
+ [Permissions.VIEW_ROLES]: false,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to search for roles',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should deny access when using types param to bypass type-specific check', async () => {
+ req.query.types = PrincipalType.GROUP;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: true,
+ [Permissions.VIEW_GROUPS]: false,
+ [Permissions.VIEW_ROLES]: false,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to search for groups',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should deny access when types contains any unpermitted type', async () => {
+ req.query.types = `${PrincipalType.USER},${PrincipalType.ROLE}`;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: true,
+ [Permissions.VIEW_GROUPS]: false,
+ [Permissions.VIEW_ROLES]: false,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to search for roles',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should allow access when all requested types are permitted', async () => {
+ req.query.types = `${PrincipalType.USER},${PrincipalType.GROUP}`;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: true,
+ [Permissions.VIEW_GROUPS]: true,
+ [Permissions.VIEW_ROLES]: false,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ it('should validate types when provided as array (Express qs parsing)', async () => {
+ req.query.types = [PrincipalType.GROUP, PrincipalType.ROLE];
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: true,
+ [Permissions.VIEW_GROUPS]: false,
+ [Permissions.VIEW_ROLES]: true,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to search for groups',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should enforce permissions for combined type and types params', async () => {
+ req.query.type = PrincipalType.USER;
+ req.query.types = PrincipalType.GROUP;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: true,
+ [Permissions.VIEW_GROUPS]: false,
+ [Permissions.VIEW_ROLES]: false,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to search for groups',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should treat all-invalid types values as mixed search', async () => {
+ req.query.types = 'foobar';
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: true,
+ [Permissions.VIEW_GROUPS]: false,
+ [Permissions.VIEW_ROLES]: false,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ it('should deny when types is empty string and user has no permissions', async () => {
+ req.query.types = '';
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: false,
+ [Permissions.VIEW_GROUPS]: false,
+ [Permissions.VIEW_ROLES]: false,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to search for users, groups, or roles',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should treat types=public as mixed search since PUBLIC is not a searchable principal type', async () => {
+ req.query.types = PrincipalType.PUBLIC;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: true,
+ [Permissions.VIEW_GROUPS]: false,
+ [Permissions.VIEW_ROLES]: false,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ it('should allow mixed search when user has at least one permission', async () => {
+ // No type specified = mixed search
+ req.query.type = undefined;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: false,
+ [Permissions.VIEW_GROUPS]: false,
+ [Permissions.VIEW_ROLES]: true,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ it('should deny mixed search when user has no permissions', async () => {
+ // No type specified = mixed search
+ req.query.type = undefined;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: false,
+ [Permissions.VIEW_GROUPS]: false,
+ [Permissions.VIEW_ROLES]: false,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to search for users, groups, or roles',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should handle errors gracefully', async () => {
+ const error = new Error('Database error');
+ getRoleByName.mockRejectedValue(error);
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(logger.error).toHaveBeenCalledWith(
+ '[checkPeoplePickerAccess][user123] error for type=undefined, types=undefined',
+ error,
+ );
+ expect(res.status).toHaveBeenCalledWith(500);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Internal Server Error',
+ message: 'Failed to check permissions',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should handle missing permissions object gracefully', async () => {
+ req.query.type = PrincipalType.USER;
+ getRoleByName.mockResolvedValue({
+ permissions: {}, // No PEOPLE_PICKER permissions
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to search for users',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+});
diff --git a/api/server/middleware/checkSharePublicAccess.js b/api/server/middleware/checkSharePublicAccess.js
new file mode 100644
index 0000000..7b9fae3
--- /dev/null
+++ b/api/server/middleware/checkSharePublicAccess.js
@@ -0,0 +1,8 @@
+const { createSharePolicyMiddleware } = require('@librechat/api');
+const { hasCapability } = require('~/server/middleware/roles/capabilities');
+const { getRoleByName } = require('~/models');
+
+module.exports = createSharePolicyMiddleware({
+ getRoleByName,
+ hasCapability,
+});
diff --git a/api/server/middleware/checkSharePublicAccess.spec.js b/api/server/middleware/checkSharePublicAccess.spec.js
new file mode 100644
index 0000000..762f013
--- /dev/null
+++ b/api/server/middleware/checkSharePublicAccess.spec.js
@@ -0,0 +1,306 @@
+jest.mock('~/models', () => ({
+ getRoleByName: jest.fn(),
+}));
+
+jest.mock('~/server/middleware/roles/capabilities', () => ({
+ hasCapability: jest.fn(),
+}));
+
+const { ResourceType, PermissionTypes, Permissions } = require('librechat-data-provider');
+const { hasCapability } = require('~/server/middleware/roles/capabilities');
+const { checkShareAccess, checkSharePublicAccess } = require('./checkSharePublicAccess');
+const { getRoleByName } = require('~/models');
+
+describe('checkSharePublicAccess middleware', () => {
+ let mockReq;
+ let mockRes;
+ let mockNext;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ hasCapability.mockResolvedValue(false);
+ mockReq = {
+ user: { id: 'user123', role: 'USER' },
+ params: { resourceType: ResourceType.AGENT },
+ body: {},
+ };
+ mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+ mockNext = jest.fn();
+ });
+
+ it('should call next() when public is not true', async () => {
+ mockReq.body = { public: false };
+
+ await checkSharePublicAccess(mockReq, mockRes, mockNext);
+
+ expect(mockNext).toHaveBeenCalled();
+ expect(mockRes.status).not.toHaveBeenCalled();
+ });
+
+ it('should call next() when public is undefined', async () => {
+ mockReq.body = { updated: [] };
+
+ await checkSharePublicAccess(mockReq, mockRes, mockNext);
+
+ expect(mockNext).toHaveBeenCalled();
+ expect(mockRes.status).not.toHaveBeenCalled();
+ });
+
+ it('should return 401 when user is not authenticated', async () => {
+ mockReq.body = { public: true };
+ mockReq.user = null;
+
+ await checkSharePublicAccess(mockReq, mockRes, mockNext);
+
+ expect(mockRes.status).toHaveBeenCalledWith(401);
+ expect(mockRes.json).toHaveBeenCalledWith({
+ error: 'Unauthorized',
+ message: 'Authentication required',
+ });
+ expect(mockNext).not.toHaveBeenCalled();
+ });
+
+ it('should return 403 when user role has no SHARE_PUBLIC permission for agents', async () => {
+ mockReq.body = { public: true };
+ mockReq.params = { resourceType: ResourceType.AGENT };
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.AGENTS]: {
+ [Permissions.SHARE]: true,
+ [Permissions.SHARE_PUBLIC]: false,
+ },
+ },
+ });
+
+ await checkSharePublicAccess(mockReq, mockRes, mockNext);
+
+ expect(mockRes.status).toHaveBeenCalledWith(403);
+ expect(mockRes.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: `You do not have permission to share ${ResourceType.AGENT} resources publicly`,
+ });
+ expect(mockNext).not.toHaveBeenCalled();
+ });
+
+ it('should call next() when user has SHARE_PUBLIC permission for agents', async () => {
+ mockReq.body = { public: true };
+ mockReq.params = { resourceType: ResourceType.AGENT };
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.AGENTS]: {
+ [Permissions.SHARE]: true,
+ [Permissions.SHARE_PUBLIC]: true,
+ },
+ },
+ });
+
+ await checkSharePublicAccess(mockReq, mockRes, mockNext);
+
+ expect(mockNext).toHaveBeenCalled();
+ expect(mockRes.status).not.toHaveBeenCalled();
+ });
+
+ it('should check prompts permission for promptgroup resource type', async () => {
+ mockReq.body = { public: true };
+ mockReq.params = { resourceType: ResourceType.PROMPTGROUP };
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PROMPTS]: {
+ [Permissions.SHARE_PUBLIC]: true,
+ },
+ },
+ });
+
+ await checkSharePublicAccess(mockReq, mockRes, mockNext);
+
+ expect(mockNext).toHaveBeenCalled();
+ });
+
+ it('should check mcp_servers permission for mcpserver resource type', async () => {
+ mockReq.body = { public: true };
+ mockReq.params = { resourceType: ResourceType.MCPSERVER };
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.MCP_SERVERS]: {
+ [Permissions.SHARE_PUBLIC]: true,
+ },
+ },
+ });
+
+ await checkSharePublicAccess(mockReq, mockRes, mockNext);
+
+ expect(mockNext).toHaveBeenCalled();
+ });
+
+ it('should return 400 for unsupported resource type', async () => {
+ mockReq.body = { public: true };
+ mockReq.params = { resourceType: 'unsupported' };
+
+ await checkSharePublicAccess(mockReq, mockRes, mockNext);
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(mockRes.json).toHaveBeenCalledWith({
+ error: 'Bad Request',
+ message: 'Unsupported resource type for public sharing: unsupported',
+ });
+ });
+
+ it('should return 403 when role has no permissions object', async () => {
+ mockReq.body = { public: true };
+ getRoleByName.mockResolvedValue({ permissions: null });
+
+ await checkSharePublicAccess(mockReq, mockRes, mockNext);
+
+ expect(mockRes.status).toHaveBeenCalledWith(403);
+ });
+
+ it('should return 500 on error', async () => {
+ mockReq.body = { public: true };
+ getRoleByName.mockRejectedValue(new Error('Database error'));
+
+ await checkSharePublicAccess(mockReq, mockRes, mockNext);
+
+ expect(mockRes.status).toHaveBeenCalledWith(500);
+ expect(mockRes.json).toHaveBeenCalledWith({
+ error: 'Internal Server Error',
+ message: 'Failed to check public sharing permissions',
+ });
+ });
+});
+
+describe('checkShareAccess middleware', () => {
+ let mockReq;
+ let mockRes;
+ let mockNext;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ hasCapability.mockResolvedValue(false);
+ mockReq = {
+ user: { id: 'user123', role: 'USER' },
+ params: { resourceType: ResourceType.SKILL },
+ body: { updated: [] },
+ };
+ mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+ mockNext = jest.fn();
+ });
+
+ it('should return 403 when user role has no SHARE permission for skills', async () => {
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.SKILLS]: {
+ [Permissions.SHARE]: false,
+ [Permissions.SHARE_PUBLIC]: false,
+ },
+ },
+ });
+
+ await checkShareAccess(mockReq, mockRes, mockNext);
+
+ expect(mockRes.status).toHaveBeenCalledWith(403);
+ expect(mockRes.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: `You do not have permission to share ${ResourceType.SKILL} resources`,
+ });
+ expect(mockNext).not.toHaveBeenCalled();
+ });
+
+ it('should call next() when user has resource management capability for skills', async () => {
+ hasCapability.mockResolvedValue(true);
+
+ await checkShareAccess(mockReq, mockRes, mockNext);
+
+ expect(hasCapability).toHaveBeenCalledWith(mockReq.user, 'manage:skills');
+ expect(getRoleByName).not.toHaveBeenCalled();
+ expect(mockNext).toHaveBeenCalled();
+ expect(mockRes.status).not.toHaveBeenCalled();
+ });
+
+ it('should call next() when user role has SHARE permission for skills', async () => {
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.SKILLS]: {
+ [Permissions.SHARE]: true,
+ [Permissions.SHARE_PUBLIC]: false,
+ },
+ },
+ });
+
+ await checkShareAccess(mockReq, mockRes, mockNext);
+
+ expect(hasCapability).toHaveBeenCalledWith(mockReq.user, 'manage:skills');
+ expect(mockNext).toHaveBeenCalled();
+ expect(mockRes.status).not.toHaveBeenCalled();
+ });
+
+ it('should return 401 when user is not authenticated', async () => {
+ mockReq.user = null;
+
+ await checkShareAccess(mockReq, mockRes, mockNext);
+
+ expect(mockRes.status).toHaveBeenCalledWith(401);
+ expect(mockRes.json).toHaveBeenCalledWith({
+ error: 'Unauthorized',
+ message: 'Authentication required',
+ });
+ expect(mockNext).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 for unsupported resource type', async () => {
+ mockReq.params = { resourceType: 'unsupported' };
+
+ await checkShareAccess(mockReq, mockRes, mockNext);
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(mockRes.json).toHaveBeenCalledWith({
+ error: 'Bad Request',
+ message: 'Unsupported resource type for sharing: unsupported',
+ });
+ });
+
+ it('should return 403 when role has no permissions object', async () => {
+ getRoleByName.mockResolvedValue({ permissions: null });
+
+ await checkShareAccess(mockReq, mockRes, mockNext);
+
+ expect(mockRes.status).toHaveBeenCalledWith(403);
+ expect(mockNext).not.toHaveBeenCalled();
+ });
+
+ it('should return 500 on error', async () => {
+ getRoleByName.mockRejectedValue(new Error('Database error'));
+
+ await checkShareAccess(mockReq, mockRes, mockNext);
+
+ expect(mockRes.status).toHaveBeenCalledWith(500);
+ expect(mockRes.json).toHaveBeenCalledWith({
+ error: 'Internal Server Error',
+ message: 'Failed to check sharing permissions',
+ });
+ });
+
+ it('should reuse the role permission lookup for public sharing checks', async () => {
+ mockReq.body = { updated: [], public: true };
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.SKILLS]: {
+ [Permissions.SHARE]: true,
+ [Permissions.SHARE_PUBLIC]: true,
+ },
+ },
+ });
+
+ await checkShareAccess(mockReq, mockRes, mockNext);
+ await checkSharePublicAccess(mockReq, mockRes, mockNext);
+
+ expect(getRoleByName).toHaveBeenCalledTimes(1);
+ expect(mockNext).toHaveBeenCalledTimes(2);
+ expect(mockRes.status).not.toHaveBeenCalled();
+ });
+});
diff --git a/api/server/middleware/config/app.js b/api/server/middleware/config/app.js
new file mode 100644
index 0000000..3768089
--- /dev/null
+++ b/api/server/middleware/config/app.js
@@ -0,0 +1,27 @@
+const { logger } = require('@librechat/data-schemas');
+const { getAppConfigOptionsFromUser } = require('@librechat/api');
+const { getAppConfig } = require('~/server/services/Config');
+
+const configMiddleware = async (req, res, next) => {
+ try {
+ req.config = await getAppConfig(getAppConfigOptionsFromUser(req.user));
+
+ next();
+ } catch (error) {
+ logger.error('Config middleware error:', {
+ error: error.message,
+ userRole: req.user?.role,
+ path: req.path,
+ });
+
+ try {
+ req.config = await getAppConfig({ tenantId: req.user?.tenantId });
+ next();
+ } catch (fallbackError) {
+ logger.error('Fallback config middleware error:', fallbackError);
+ next(fallbackError);
+ }
+ }
+};
+
+module.exports = configMiddleware;
diff --git a/api/server/middleware/denyRequest.js b/api/server/middleware/denyRequest.js
new file mode 100644
index 0000000..86054d0
--- /dev/null
+++ b/api/server/middleware/denyRequest.js
@@ -0,0 +1,67 @@
+const crypto = require('crypto');
+const { sendEvent } = require('@librechat/api');
+const { getResponseSender, Constants } = require('librechat-data-provider');
+const { sendError } = require('~/server/middleware/error');
+const { saveMessage } = require('~/models');
+
+/**
+ * Denies a request by sending an error message and optionally saves the user's message.
+ *
+ * @async
+ * @function
+ * @param {Object} req - Express request object.
+ * @param {Object} req.body - The body of the request.
+ * @param {string} [req.body.messageId] - The ID of the message.
+ * @param {string} [req.body.conversationId] - The ID of the conversation.
+ * @param {string} [req.body.parentMessageId] - The ID of the parent message.
+ * @param {string} req.body.text - The text of the message.
+ * @param {Object} res - Express response object.
+ * @param {string} errorMessage - The error message to be sent.
+ * @returns {Promise} A promise that resolves with the error response.
+ * @throws {Error} Throws an error if there's an issue saving the message or sending the error.
+ */
+const denyRequest = async (req, res, errorMessage) => {
+ let responseText = errorMessage;
+ if (typeof errorMessage === 'object') {
+ responseText = JSON.stringify(errorMessage);
+ }
+
+ const { messageId, conversationId: _convoId, parentMessageId, text } = req.body;
+ const conversationId = _convoId ?? crypto.randomUUID();
+
+ const userMessage = {
+ sender: 'User',
+ messageId: messageId ?? crypto.randomUUID(),
+ parentMessageId,
+ conversationId,
+ isCreatedByUser: true,
+ text,
+ };
+ sendEvent(res, { message: userMessage, created: true });
+
+ const shouldSaveMessage = _convoId && parentMessageId && parentMessageId !== Constants.NO_PARENT;
+
+ if (shouldSaveMessage) {
+ await saveMessage(
+ {
+ userId: req?.user?.id,
+ isTemporary: req?.body?.isTemporary,
+ interfaceConfig: req?.config?.interfaceConfig,
+ },
+ { ...userMessage, user: req.user.id },
+ { context: `api/server/middleware/denyRequest.js - ${responseText}` },
+ );
+ }
+
+ return await sendError(req, res, {
+ sender: getResponseSender(req.body),
+ messageId: crypto.randomUUID(),
+ conversationId,
+ parentMessageId: userMessage.messageId,
+ text: responseText,
+ shouldSaveMessage,
+ user: req.user.id,
+ });
+};
+
+module.exports = denyRequest;
diff --git a/api/server/middleware/error.js b/api/server/middleware/error.js
new file mode 100644
index 0000000..5fa3562
--- /dev/null
+++ b/api/server/middleware/error.js
@@ -0,0 +1,110 @@
+const crypto = require('crypto');
+const { logger } = require('@librechat/data-schemas');
+const { parseConvo } = require('librechat-data-provider');
+const { sendEvent, handleError, sanitizeMessageForTransmit } = require('@librechat/api');
+const { saveMessage, getMessages, getConvo } = require('~/models');
+
+/**
+ * Processes an error with provided options, saves the error message and sends a corresponding SSE response
+ * @async
+ * @param {object} req - The request.
+ * @param {object} res - The response.
+ * @param {object} options - The options for handling the error containing message properties.
+ * @param {object} options.user - The user ID.
+ * @param {string} options.sender - The sender of the message.
+ * @param {string} options.conversationId - The conversation ID.
+ * @param {string} options.messageId - The message ID.
+ * @param {string} options.parentMessageId - The parent message ID.
+ * @param {string} options.text - The error message.
+ * @param {boolean} options.shouldSaveMessage - [Optional] Whether the message should be saved. Default is true.
+ * @param {function} callback - [Optional] The callback function to be executed.
+ */
+const sendError = async (req, res, options, callback) => {
+ const {
+ user,
+ sender,
+ conversationId,
+ messageId,
+ parentMessageId,
+ text,
+ shouldSaveMessage,
+ ...rest
+ } = options;
+ const errorMessage = {
+ sender,
+ messageId: messageId ?? crypto.randomUUID(),
+ conversationId,
+ parentMessageId,
+ unfinished: false,
+ error: true,
+ final: true,
+ text,
+ isCreatedByUser: false,
+ ...rest,
+ };
+ if (callback && typeof callback === 'function') {
+ await callback();
+ }
+
+ if (shouldSaveMessage) {
+ await saveMessage(
+ {
+ userId: req?.user?.id,
+ isTemporary: req?.body?.isTemporary,
+ interfaceConfig: req?.config?.interfaceConfig,
+ },
+ { ...errorMessage, user },
+ {
+ context: 'api/server/utils/streamResponse.js - sendError',
+ },
+ );
+ }
+
+ if (!errorMessage.error) {
+ const requestMessage = { messageId: parentMessageId, conversationId };
+ let query = [],
+ convo = {};
+ try {
+ query = await getMessages(requestMessage);
+ convo = await getConvo(user, conversationId);
+ } catch (err) {
+ logger.error('[sendError] Error retrieving conversation data:', err);
+ convo = parseConvo(errorMessage);
+ }
+
+ return sendEvent(res, {
+ final: true,
+ requestMessage: sanitizeMessageForTransmit(query?.[0] ?? requestMessage),
+ responseMessage: errorMessage,
+ conversation: convo,
+ });
+ }
+
+ handleError(res, errorMessage);
+};
+
+/**
+ * Sends the response based on whether headers have been sent or not.
+ * @param {ServerRequest} req - The server response.
+ * @param {Express.Response} res - The server response.
+ * @param {Object} data - The data to be sent.
+ * @param {string} [errorMessage] - The error message, if any.
+ */
+const sendResponse = (req, res, data, errorMessage) => {
+ if (!res.headersSent) {
+ if (errorMessage) {
+ return res.status(500).json({ error: errorMessage });
+ }
+ return res.json(data);
+ }
+
+ if (errorMessage) {
+ return sendError(req, res, { ...data, text: errorMessage });
+ }
+ return sendEvent(res, data);
+};
+
+module.exports = {
+ sendError,
+ sendResponse,
+};
diff --git a/api/server/middleware/index.js b/api/server/middleware/index.js
new file mode 100644
index 0000000..7d9bb9e
--- /dev/null
+++ b/api/server/middleware/index.js
@@ -0,0 +1,56 @@
+const validatePasswordReset = require('./validatePasswordReset');
+const setTwoFactorTempUser = require('./setTwoFactorTempUser');
+const validateRegistration = require('./validateRegistration');
+const buildEndpointOption = require('./buildEndpointOption');
+const validateMessageReq = require('./validateMessageReq');
+const { prepareMessageRequestValidation, sendValidationResponse } = require('./messageValidation');
+const checkDomainAllowed = require('./checkDomainAllowed');
+const requireLocalAuth = require('./requireLocalAuth');
+const canDeleteAccount = require('./canDeleteAccount');
+const accessResources = require('./accessResources');
+const requireLdapAuth = require('./requireLdapAuth');
+const abortMiddleware = require('./abortMiddleware');
+const checkInviteUser = require('./checkInviteUser');
+const requireJwtAuth = require('./requireJwtAuth');
+const { requireRumProxyAuth } = require('./requireJwtAuth');
+const configMiddleware = require('./config/app');
+const validateModel = require('./validateModel');
+const moderateText = require('./moderateText');
+const logHeaders = require('./logHeaders');
+const setHeaders = require('./setHeaders');
+const validate = require('./validate');
+const limiters = require('./limiters');
+const uaParser = require('./uaParser');
+const checkBan = require('./checkBan');
+const noIndex = require('./noIndex');
+const roles = require('./roles');
+
+module.exports = {
+ ...abortMiddleware,
+ ...validate,
+ ...limiters,
+ ...roles,
+ ...accessResources,
+ noIndex,
+ checkBan,
+ uaParser,
+ setHeaders,
+ logHeaders,
+ moderateText,
+ validateModel,
+ requireJwtAuth,
+ requireRumProxyAuth,
+ setTwoFactorTempUser,
+ checkInviteUser,
+ requireLdapAuth,
+ requireLocalAuth,
+ canDeleteAccount,
+ configMiddleware,
+ checkDomainAllowed,
+ validateMessageReq,
+ sendValidationResponse,
+ prepareMessageRequestValidation,
+ buildEndpointOption,
+ validateRegistration,
+ validatePasswordReset,
+};
diff --git a/api/server/middleware/limiters/forkLimiters.js b/api/server/middleware/limiters/forkLimiters.js
new file mode 100644
index 0000000..6d05ced
--- /dev/null
+++ b/api/server/middleware/limiters/forkLimiters.js
@@ -0,0 +1,80 @@
+const rateLimit = require('express-rate-limit');
+const { ViolationTypes } = require('librechat-data-provider');
+const { limiterCache, removePorts } = require('@librechat/api');
+const logViolation = require('~/cache/logViolation');
+
+const getEnvironmentVariables = () => {
+ const FORK_IP_MAX = parseInt(process.env.FORK_IP_MAX) || 30;
+ const FORK_IP_WINDOW = parseInt(process.env.FORK_IP_WINDOW) || 1;
+ const FORK_USER_MAX = parseInt(process.env.FORK_USER_MAX) || 7;
+ const FORK_USER_WINDOW = parseInt(process.env.FORK_USER_WINDOW) || 1;
+ const FORK_VIOLATION_SCORE = process.env.FORK_VIOLATION_SCORE;
+
+ const forkIpWindowMs = FORK_IP_WINDOW * 60 * 1000;
+ const forkIpMax = FORK_IP_MAX;
+ const forkIpWindowInMinutes = forkIpWindowMs / 60000;
+
+ const forkUserWindowMs = FORK_USER_WINDOW * 60 * 1000;
+ const forkUserMax = FORK_USER_MAX;
+ const forkUserWindowInMinutes = forkUserWindowMs / 60000;
+
+ return {
+ forkIpWindowMs,
+ forkIpMax,
+ forkIpWindowInMinutes,
+ forkUserWindowMs,
+ forkUserMax,
+ forkUserWindowInMinutes,
+ forkViolationScore: FORK_VIOLATION_SCORE,
+ };
+};
+
+const createForkHandler = (ip = true) => {
+ const {
+ forkIpMax,
+ forkUserMax,
+ forkViolationScore,
+ forkIpWindowInMinutes,
+ forkUserWindowInMinutes,
+ } = getEnvironmentVariables();
+
+ return async (req, res) => {
+ const type = ViolationTypes.FILE_UPLOAD_LIMIT;
+ const errorMessage = {
+ type,
+ max: ip ? forkIpMax : forkUserMax,
+ limiter: ip ? 'ip' : 'user',
+ windowInMinutes: ip ? forkIpWindowInMinutes : forkUserWindowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, forkViolationScore);
+ res.status(429).json({ message: 'Too many requests. Try again later' });
+ };
+};
+
+const createForkLimiters = () => {
+ const { forkIpWindowMs, forkIpMax, forkUserWindowMs, forkUserMax } = getEnvironmentVariables();
+
+ const ipLimiterOptions = {
+ windowMs: forkIpWindowMs,
+ max: forkIpMax,
+ handler: createForkHandler(),
+ keyGenerator: removePorts,
+ store: limiterCache('fork_ip_limiter'),
+ };
+ const userLimiterOptions = {
+ windowMs: forkUserWindowMs,
+ max: forkUserMax,
+ handler: createForkHandler(false),
+ keyGenerator: function (req) {
+ return req.user?.id;
+ },
+ store: limiterCache('fork_user_limiter'),
+ };
+
+ const forkIpLimiter = rateLimit(ipLimiterOptions);
+ const forkUserLimiter = rateLimit(userLimiterOptions);
+ return { forkIpLimiter, forkUserLimiter };
+};
+
+module.exports = { createForkLimiters };
diff --git a/api/server/middleware/limiters/importLimiters.js b/api/server/middleware/limiters/importLimiters.js
new file mode 100644
index 0000000..22b7013
--- /dev/null
+++ b/api/server/middleware/limiters/importLimiters.js
@@ -0,0 +1,81 @@
+const rateLimit = require('express-rate-limit');
+const { ViolationTypes } = require('librechat-data-provider');
+const { limiterCache, removePorts } = require('@librechat/api');
+const logViolation = require('~/cache/logViolation');
+
+const getEnvironmentVariables = () => {
+ const IMPORT_IP_MAX = parseInt(process.env.IMPORT_IP_MAX) || 100;
+ const IMPORT_IP_WINDOW = parseInt(process.env.IMPORT_IP_WINDOW) || 15;
+ const IMPORT_USER_MAX = parseInt(process.env.IMPORT_USER_MAX) || 50;
+ const IMPORT_USER_WINDOW = parseInt(process.env.IMPORT_USER_WINDOW) || 15;
+ const IMPORT_VIOLATION_SCORE = process.env.IMPORT_VIOLATION_SCORE;
+
+ const importIpWindowMs = IMPORT_IP_WINDOW * 60 * 1000;
+ const importIpMax = IMPORT_IP_MAX;
+ const importIpWindowInMinutes = importIpWindowMs / 60000;
+
+ const importUserWindowMs = IMPORT_USER_WINDOW * 60 * 1000;
+ const importUserMax = IMPORT_USER_MAX;
+ const importUserWindowInMinutes = importUserWindowMs / 60000;
+
+ return {
+ importIpWindowMs,
+ importIpMax,
+ importIpWindowInMinutes,
+ importUserWindowMs,
+ importUserMax,
+ importUserWindowInMinutes,
+ importViolationScore: IMPORT_VIOLATION_SCORE,
+ };
+};
+
+const createImportHandler = (ip = true) => {
+ const {
+ importIpMax,
+ importUserMax,
+ importViolationScore,
+ importIpWindowInMinutes,
+ importUserWindowInMinutes,
+ } = getEnvironmentVariables();
+
+ return async (req, res) => {
+ const type = ViolationTypes.FILE_UPLOAD_LIMIT;
+ const errorMessage = {
+ type,
+ max: ip ? importIpMax : importUserMax,
+ limiter: ip ? 'ip' : 'user',
+ windowInMinutes: ip ? importIpWindowInMinutes : importUserWindowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, importViolationScore);
+ res.status(429).json({ message: 'Too many conversation import requests. Try again later' });
+ };
+};
+
+const createImportLimiters = () => {
+ const { importIpWindowMs, importIpMax, importUserWindowMs, importUserMax } =
+ getEnvironmentVariables();
+
+ const ipLimiterOptions = {
+ windowMs: importIpWindowMs,
+ max: importIpMax,
+ handler: createImportHandler(),
+ keyGenerator: removePorts,
+ store: limiterCache('import_ip_limiter'),
+ };
+ const userLimiterOptions = {
+ windowMs: importUserWindowMs,
+ max: importUserMax,
+ handler: createImportHandler(false),
+ keyGenerator: function (req) {
+ return req.user?.id;
+ },
+ store: limiterCache('import_user_limiter'),
+ };
+
+ const importIpLimiter = rateLimit(ipLimiterOptions);
+ const importUserLimiter = rateLimit(userLimiterOptions);
+ return { importIpLimiter, importUserLimiter };
+};
+
+module.exports = { createImportLimiters };
diff --git a/api/server/middleware/limiters/index.js b/api/server/middleware/limiters/index.js
new file mode 100644
index 0000000..76afc3c
--- /dev/null
+++ b/api/server/middleware/limiters/index.js
@@ -0,0 +1,34 @@
+const createTTSLimiters = require('./ttsLimiters');
+const createSTTLimiters = require('./sttLimiters');
+
+const loginLimiter = require('./loginLimiter');
+const importLimiters = require('./importLimiters');
+const uploadLimiters = require('./uploadLimiters');
+const forkLimiters = require('./forkLimiters');
+const registerLimiter = require('./registerLimiter');
+const toolCallLimiter = require('./toolCallLimiter');
+const messageLimiters = require('./messageLimiters');
+const promptUsageLimiter = require('./promptUsageLimiter');
+const verifyEmailLimiter = require('./verifyEmailLimiter');
+const resetPasswordLimiter = require('./resetPasswordLimiter');
+const twoFactorTempLimiter = require('./twoFactorTempLimiter');
+const verifyEmailSubmissionLimiter = require('./verifyEmailSubmissionLimiter');
+const resetPasswordSubmissionLimiter = require('./resetPasswordSubmissionLimiter');
+
+module.exports = {
+ ...uploadLimiters,
+ ...importLimiters,
+ ...messageLimiters,
+ ...forkLimiters,
+ ...promptUsageLimiter,
+ loginLimiter,
+ registerLimiter,
+ toolCallLimiter,
+ createTTSLimiters,
+ createSTTLimiters,
+ verifyEmailLimiter,
+ resetPasswordLimiter,
+ verifyEmailSubmissionLimiter,
+ resetPasswordSubmissionLimiter,
+ twoFactorTempLimiter,
+};
diff --git a/api/server/middleware/limiters/loginLimiter.js b/api/server/middleware/limiters/loginLimiter.js
new file mode 100644
index 0000000..c178b68
--- /dev/null
+++ b/api/server/middleware/limiters/loginLimiter.js
@@ -0,0 +1,34 @@
+const rateLimit = require('express-rate-limit');
+const { ViolationTypes } = require('librechat-data-provider');
+const { limiterCache, removePorts } = require('@librechat/api');
+const { logViolation } = require('~/cache');
+
+const { LOGIN_WINDOW = 5, LOGIN_MAX = 7, LOGIN_VIOLATION_SCORE: score } = process.env;
+const windowMs = LOGIN_WINDOW * 60 * 1000;
+const max = LOGIN_MAX;
+const windowInMinutes = windowMs / 60000;
+const message = `Too many login attempts, please try again after ${windowInMinutes} minutes.`;
+
+const handler = async (req, res) => {
+ const type = ViolationTypes.LOGINS;
+ const errorMessage = {
+ type,
+ max,
+ windowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, score);
+ return res.status(429).json({ message });
+};
+
+const limiterOptions = {
+ windowMs,
+ max,
+ handler,
+ keyGenerator: removePorts,
+ store: limiterCache('login_limiter'),
+};
+
+const loginLimiter = rateLimit(limiterOptions);
+
+module.exports = loginLimiter;
diff --git a/api/server/middleware/limiters/messageLimiters.js b/api/server/middleware/limiters/messageLimiters.js
new file mode 100644
index 0000000..4f1d720
--- /dev/null
+++ b/api/server/middleware/limiters/messageLimiters.js
@@ -0,0 +1,80 @@
+const rateLimit = require('express-rate-limit');
+const { ViolationTypes } = require('librechat-data-provider');
+const { limiterCache, removePorts } = require('@librechat/api');
+const denyRequest = require('~/server/middleware/denyRequest');
+const { logViolation } = require('~/cache');
+
+const {
+ MESSAGE_IP_MAX = 40,
+ MESSAGE_IP_WINDOW = 1,
+ MESSAGE_USER_MAX = 40,
+ MESSAGE_USER_WINDOW = 1,
+ MESSAGE_VIOLATION_SCORE: score,
+} = process.env;
+
+const ipWindowMs = MESSAGE_IP_WINDOW * 60 * 1000;
+const ipMax = MESSAGE_IP_MAX;
+const ipWindowInMinutes = ipWindowMs / 60000;
+
+const userWindowMs = MESSAGE_USER_WINDOW * 60 * 1000;
+const userMax = MESSAGE_USER_MAX;
+const userWindowInMinutes = userWindowMs / 60000;
+
+/**
+ * Creates either an IP/User message request rate limiter for excessive requests
+ * that properly logs and denies the violation.
+ *
+ * @param {boolean} [ip=true] - Whether to create an IP limiter or a user limiter.
+ * @returns {function} A rate limiter function.
+ *
+ */
+const createHandler = (ip = true) => {
+ return async (req, res) => {
+ const type = ViolationTypes.MESSAGE_LIMIT;
+ const errorMessage = {
+ type,
+ max: ip ? ipMax : userMax,
+ limiter: ip ? 'ip' : 'user',
+ windowInMinutes: ip ? ipWindowInMinutes : userWindowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, score);
+ return await denyRequest(req, res, errorMessage);
+ };
+};
+
+/**
+ * Message request rate limiters
+ */
+const ipLimiterOptions = {
+ windowMs: ipWindowMs,
+ max: ipMax,
+ handler: createHandler(),
+ keyGenerator: removePorts,
+ store: limiterCache('message_ip_limiter'),
+};
+
+const userLimiterOptions = {
+ windowMs: userWindowMs,
+ max: userMax,
+ handler: createHandler(false),
+ keyGenerator: function (req) {
+ return req.user?.id;
+ },
+ store: limiterCache('message_user_limiter'),
+};
+
+/**
+ * Message request rate limiter by IP
+ */
+const messageIpLimiter = rateLimit(ipLimiterOptions);
+
+/**
+ * Message request rate limiter by userId
+ */
+const messageUserLimiter = rateLimit(userLimiterOptions);
+
+module.exports = {
+ messageIpLimiter,
+ messageUserLimiter,
+};
diff --git a/api/server/middleware/limiters/promptUsageLimiter.js b/api/server/middleware/limiters/promptUsageLimiter.js
new file mode 100644
index 0000000..38bdeed
--- /dev/null
+++ b/api/server/middleware/limiters/promptUsageLimiter.js
@@ -0,0 +1,17 @@
+const rateLimit = require('express-rate-limit');
+const { limiterCache } = require('@librechat/api');
+
+const PROMPT_USAGE_WINDOW_MS = 60 * 1000; // 1 minute
+const PROMPT_USAGE_MAX = 30; // 30 usage increments per user per minute
+
+const promptUsageLimiter = rateLimit({
+ windowMs: PROMPT_USAGE_WINDOW_MS,
+ max: PROMPT_USAGE_MAX,
+ handler: (_req, res) => {
+ res.status(429).json({ message: 'Too many prompt usage requests. Try again later' });
+ },
+ keyGenerator: (req) => req.user?.id,
+ store: limiterCache('prompt_usage_limiter'),
+});
+
+module.exports = { promptUsageLimiter };
diff --git a/api/server/middleware/limiters/registerLimiter.js b/api/server/middleware/limiters/registerLimiter.js
new file mode 100644
index 0000000..91ea027
--- /dev/null
+++ b/api/server/middleware/limiters/registerLimiter.js
@@ -0,0 +1,34 @@
+const rateLimit = require('express-rate-limit');
+const { ViolationTypes } = require('librechat-data-provider');
+const { limiterCache, removePorts } = require('@librechat/api');
+const { logViolation } = require('~/cache');
+
+const { REGISTER_WINDOW = 60, REGISTER_MAX = 5, REGISTRATION_VIOLATION_SCORE: score } = process.env;
+const windowMs = REGISTER_WINDOW * 60 * 1000;
+const max = REGISTER_MAX;
+const windowInMinutes = windowMs / 60000;
+const message = `Too many accounts created, please try again after ${windowInMinutes} minutes`;
+
+const handler = async (req, res) => {
+ const type = ViolationTypes.REGISTRATIONS;
+ const errorMessage = {
+ type,
+ max,
+ windowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, score);
+ return res.status(429).json({ message });
+};
+
+const limiterOptions = {
+ windowMs,
+ max,
+ handler,
+ keyGenerator: removePorts,
+ store: limiterCache('register_limiter'),
+};
+
+const registerLimiter = rateLimit(limiterOptions);
+
+module.exports = registerLimiter;
diff --git a/api/server/middleware/limiters/resetPasswordLimiter.js b/api/server/middleware/limiters/resetPasswordLimiter.js
new file mode 100644
index 0000000..7feca47
--- /dev/null
+++ b/api/server/middleware/limiters/resetPasswordLimiter.js
@@ -0,0 +1,38 @@
+const rateLimit = require('express-rate-limit');
+const { ViolationTypes } = require('librechat-data-provider');
+const { limiterCache, removePorts } = require('@librechat/api');
+const { logViolation } = require('~/cache');
+
+const {
+ RESET_PASSWORD_WINDOW = 2,
+ RESET_PASSWORD_MAX = 2,
+ RESET_PASSWORD_VIOLATION_SCORE: score,
+} = process.env;
+const windowMs = RESET_PASSWORD_WINDOW * 60 * 1000;
+const max = RESET_PASSWORD_MAX;
+const windowInMinutes = windowMs / 60000;
+const message = `Too many attempts, please try again after ${windowInMinutes} minute(s)`;
+
+const handler = async (req, res) => {
+ const type = ViolationTypes.RESET_PASSWORD_LIMIT;
+ const errorMessage = {
+ type,
+ max,
+ windowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, score);
+ return res.status(429).json({ message });
+};
+
+const limiterOptions = {
+ windowMs,
+ max,
+ handler,
+ keyGenerator: removePorts,
+ store: limiterCache('reset_password_limiter'),
+};
+
+const resetPasswordLimiter = rateLimit(limiterOptions);
+
+module.exports = resetPasswordLimiter;
diff --git a/api/server/middleware/limiters/resetPasswordSubmissionLimiter.js b/api/server/middleware/limiters/resetPasswordSubmissionLimiter.js
new file mode 100644
index 0000000..4eb3bb2
--- /dev/null
+++ b/api/server/middleware/limiters/resetPasswordSubmissionLimiter.js
@@ -0,0 +1,39 @@
+const rateLimit = require('express-rate-limit');
+const { ViolationTypes } = require('librechat-data-provider');
+const { limiterCache, removePorts } = require('@librechat/api');
+const { logViolation } = require('~/cache');
+
+const {
+ RESET_PASSWORD_SUBMISSION_WINDOW = process.env.RESET_PASSWORD_WINDOW ?? 2,
+ RESET_PASSWORD_SUBMISSION_MAX = process.env.RESET_PASSWORD_MAX ?? 2,
+ RESET_PASSWORD_SUBMISSION_VIOLATION_SCORE: score,
+} = process.env;
+const windowMs = RESET_PASSWORD_SUBMISSION_WINDOW * 60 * 1000;
+const max = RESET_PASSWORD_SUBMISSION_MAX;
+const windowInMinutes = windowMs / 60000;
+const message = `Too many attempts, please try again after ${windowInMinutes} minute(s)`;
+
+const handler = async (req, res) => {
+ const type = ViolationTypes.RESET_PASSWORD_LIMIT;
+ const errorMessage = {
+ type,
+ max,
+ windowInMinutes,
+ limiter: 'submission',
+ };
+
+ await logViolation(req, res, type, errorMessage, score);
+ return res.status(429).json({ message });
+};
+
+const limiterOptions = {
+ windowMs,
+ max,
+ handler,
+ keyGenerator: removePorts,
+ store: limiterCache('reset_password_submission_limiter'),
+};
+
+const resetPasswordSubmissionLimiter = rateLimit(limiterOptions);
+
+module.exports = resetPasswordSubmissionLimiter;
diff --git a/api/server/middleware/limiters/sttLimiters.js b/api/server/middleware/limiters/sttLimiters.js
new file mode 100644
index 0000000..ded9040
--- /dev/null
+++ b/api/server/middleware/limiters/sttLimiters.js
@@ -0,0 +1,77 @@
+const rateLimit = require('express-rate-limit');
+const { ViolationTypes } = require('librechat-data-provider');
+const { limiterCache, removePorts } = require('@librechat/api');
+const logViolation = require('~/cache/logViolation');
+
+const getEnvironmentVariables = () => {
+ const STT_IP_MAX = parseInt(process.env.STT_IP_MAX) || 100;
+ const STT_IP_WINDOW = parseInt(process.env.STT_IP_WINDOW) || 1;
+ const STT_USER_MAX = parseInt(process.env.STT_USER_MAX) || 50;
+ const STT_USER_WINDOW = parseInt(process.env.STT_USER_WINDOW) || 1;
+ const STT_VIOLATION_SCORE = process.env.STT_VIOLATION_SCORE;
+
+ const sttIpWindowMs = STT_IP_WINDOW * 60 * 1000;
+ const sttIpMax = STT_IP_MAX;
+ const sttIpWindowInMinutes = sttIpWindowMs / 60000;
+
+ const sttUserWindowMs = STT_USER_WINDOW * 60 * 1000;
+ const sttUserMax = STT_USER_MAX;
+ const sttUserWindowInMinutes = sttUserWindowMs / 60000;
+
+ return {
+ sttIpWindowMs,
+ sttIpMax,
+ sttIpWindowInMinutes,
+ sttUserWindowMs,
+ sttUserMax,
+ sttUserWindowInMinutes,
+ sttViolationScore: STT_VIOLATION_SCORE,
+ };
+};
+
+const createSTTHandler = (ip = true) => {
+ const { sttIpMax, sttIpWindowInMinutes, sttUserMax, sttUserWindowInMinutes, sttViolationScore } =
+ getEnvironmentVariables();
+
+ return async (req, res) => {
+ const type = ViolationTypes.STT_LIMIT;
+ const errorMessage = {
+ type,
+ max: ip ? sttIpMax : sttUserMax,
+ limiter: ip ? 'ip' : 'user',
+ windowInMinutes: ip ? sttIpWindowInMinutes : sttUserWindowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, sttViolationScore);
+ res.status(429).json({ message: 'Too many STT requests. Try again later' });
+ };
+};
+
+const createSTTLimiters = () => {
+ const { sttIpWindowMs, sttIpMax, sttUserWindowMs, sttUserMax } = getEnvironmentVariables();
+
+ const ipLimiterOptions = {
+ windowMs: sttIpWindowMs,
+ max: sttIpMax,
+ handler: createSTTHandler(),
+ keyGenerator: removePorts,
+ store: limiterCache('stt_ip_limiter'),
+ };
+
+ const userLimiterOptions = {
+ windowMs: sttUserWindowMs,
+ max: sttUserMax,
+ handler: createSTTHandler(false),
+ keyGenerator: function (req) {
+ return req.user?.id;
+ },
+ store: limiterCache('stt_user_limiter'),
+ };
+
+ const sttIpLimiter = rateLimit(ipLimiterOptions);
+ const sttUserLimiter = rateLimit(userLimiterOptions);
+
+ return { sttIpLimiter, sttUserLimiter };
+};
+
+module.exports = createSTTLimiters;
diff --git a/api/server/middleware/limiters/toolCallLimiter.js b/api/server/middleware/limiters/toolCallLimiter.js
new file mode 100644
index 0000000..56dbfff
--- /dev/null
+++ b/api/server/middleware/limiters/toolCallLimiter.js
@@ -0,0 +1,33 @@
+const rateLimit = require('express-rate-limit');
+const { limiterCache } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const logViolation = require('~/cache/logViolation');
+
+const { TOOL_CALL_VIOLATION_SCORE: score } = process.env;
+
+const handler = async (req, res) => {
+ const type = ViolationTypes.TOOL_CALL_LIMIT;
+ const errorMessage = {
+ type,
+ max: 1,
+ limiter: 'user',
+ windowInMinutes: 1,
+ };
+
+ await logViolation(req, res, type, errorMessage, score);
+ res.status(429).json({ message: 'Too many tool call requests. Try again later' });
+};
+
+const limiterOptions = {
+ windowMs: 1000,
+ max: 1,
+ handler,
+ keyGenerator: function (req) {
+ return req.user?.id;
+ },
+ store: limiterCache('tool_call_limiter'),
+};
+
+const toolCallLimiter = rateLimit(limiterOptions);
+
+module.exports = toolCallLimiter;
diff --git a/api/server/middleware/limiters/ttsLimiters.js b/api/server/middleware/limiters/ttsLimiters.js
new file mode 100644
index 0000000..7ded475
--- /dev/null
+++ b/api/server/middleware/limiters/ttsLimiters.js
@@ -0,0 +1,77 @@
+const rateLimit = require('express-rate-limit');
+const { ViolationTypes } = require('librechat-data-provider');
+const { limiterCache, removePorts } = require('@librechat/api');
+const logViolation = require('~/cache/logViolation');
+
+const getEnvironmentVariables = () => {
+ const TTS_IP_MAX = parseInt(process.env.TTS_IP_MAX) || 100;
+ const TTS_IP_WINDOW = parseInt(process.env.TTS_IP_WINDOW) || 1;
+ const TTS_USER_MAX = parseInt(process.env.TTS_USER_MAX) || 50;
+ const TTS_USER_WINDOW = parseInt(process.env.TTS_USER_WINDOW) || 1;
+ const TTS_VIOLATION_SCORE = process.env.TTS_VIOLATION_SCORE;
+
+ const ttsIpWindowMs = TTS_IP_WINDOW * 60 * 1000;
+ const ttsIpMax = TTS_IP_MAX;
+ const ttsIpWindowInMinutes = ttsIpWindowMs / 60000;
+
+ const ttsUserWindowMs = TTS_USER_WINDOW * 60 * 1000;
+ const ttsUserMax = TTS_USER_MAX;
+ const ttsUserWindowInMinutes = ttsUserWindowMs / 60000;
+
+ return {
+ ttsIpWindowMs,
+ ttsIpMax,
+ ttsIpWindowInMinutes,
+ ttsUserWindowMs,
+ ttsUserMax,
+ ttsUserWindowInMinutes,
+ ttsViolationScore: TTS_VIOLATION_SCORE,
+ };
+};
+
+const createTTSHandler = (ip = true) => {
+ const { ttsIpMax, ttsIpWindowInMinutes, ttsUserMax, ttsUserWindowInMinutes, ttsViolationScore } =
+ getEnvironmentVariables();
+
+ return async (req, res) => {
+ const type = ViolationTypes.TTS_LIMIT;
+ const errorMessage = {
+ type,
+ max: ip ? ttsIpMax : ttsUserMax,
+ limiter: ip ? 'ip' : 'user',
+ windowInMinutes: ip ? ttsIpWindowInMinutes : ttsUserWindowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, ttsViolationScore);
+ res.status(429).json({ message: 'Too many TTS requests. Try again later' });
+ };
+};
+
+const createTTSLimiters = () => {
+ const { ttsIpWindowMs, ttsIpMax, ttsUserWindowMs, ttsUserMax } = getEnvironmentVariables();
+
+ const ipLimiterOptions = {
+ windowMs: ttsIpWindowMs,
+ max: ttsIpMax,
+ handler: createTTSHandler(),
+ keyGenerator: removePorts,
+ store: limiterCache('tts_ip_limiter'),
+ };
+
+ const userLimiterOptions = {
+ windowMs: ttsUserWindowMs,
+ max: ttsUserMax,
+ handler: createTTSHandler(false),
+ keyGenerator: function (req) {
+ return req.user?.id;
+ },
+ store: limiterCache('tts_user_limiter'),
+ };
+
+ const ttsIpLimiter = rateLimit(ipLimiterOptions);
+ const ttsUserLimiter = rateLimit(userLimiterOptions);
+
+ return { ttsIpLimiter, ttsUserLimiter };
+};
+
+module.exports = createTTSLimiters;
diff --git a/api/server/middleware/limiters/twoFactorTempLimiter.js b/api/server/middleware/limiters/twoFactorTempLimiter.js
new file mode 100644
index 0000000..97d0861
--- /dev/null
+++ b/api/server/middleware/limiters/twoFactorTempLimiter.js
@@ -0,0 +1,101 @@
+const jwt = require('jsonwebtoken');
+const { createHash } = require('crypto');
+const rateLimit = require('express-rate-limit');
+const { ViolationTypes } = require('librechat-data-provider');
+const { limiterCache, removePorts } = require('@librechat/api');
+const { logViolation } = require('~/cache');
+
+const {
+ LOGIN_WINDOW = 5,
+ LOGIN_MAX = 7,
+ LOGIN_VIOLATION_SCORE,
+ TWO_FACTOR_TEMP_WINDOW = LOGIN_WINDOW,
+ TWO_FACTOR_TEMP_MAX = LOGIN_MAX,
+ TWO_FACTOR_TEMP_VIOLATION_SCORE,
+} = process.env;
+const windowMs = TWO_FACTOR_TEMP_WINDOW * 60 * 1000;
+const max = TWO_FACTOR_TEMP_MAX;
+const score = TWO_FACTOR_TEMP_VIOLATION_SCORE ?? LOGIN_VIOLATION_SCORE;
+const windowInMinutes = windowMs / 60000;
+const message = `Too many verification attempts, please try again after ${windowInMinutes} minutes.`;
+
+const hashLimiterKey = (value) => createHash('sha256').update(value).digest('hex');
+
+const getUserLimiterKey = (req) => {
+ const userId = req.user?.id ?? req.user?._id;
+ if (userId) {
+ return `user:${userId.toString()}`;
+ }
+
+ const tempToken = req.body?.tempToken;
+ if (typeof tempToken === 'string' && tempToken) {
+ return `temp:${hashLimiterKey(tempToken)}`;
+ }
+
+ const ip = removePorts(req);
+ return ip ? `ip:${ip}` : 'ip:unknown';
+};
+
+const getTempTokenUserId = (tempToken) => {
+ if (!tempToken) {
+ return null;
+ }
+
+ try {
+ const payload = jwt.verify(tempToken, process.env.JWT_SECRET);
+ return payload?.userId ?? null;
+ } catch {
+ return null;
+ }
+};
+
+const createHandler = (limiter) => async (req, res) => {
+ const type = ViolationTypes.LOGINS;
+ const errorMessage = {
+ type,
+ max,
+ limiter,
+ windowInMinutes,
+ };
+
+ const userId = getTempTokenUserId(req.body?.tempToken);
+ if (userId && !req.user) {
+ req.user = { id: userId };
+ } else if (userId && !req.user.id && !req.user._id) {
+ req.user.id = userId;
+ }
+
+ await logViolation(req, res, type, errorMessage, score);
+ return res.status(429).json({ message });
+};
+
+const ipLimiterOptions = {
+ windowMs,
+ max,
+ handler: createHandler('ip'),
+ keyGenerator: removePorts,
+ store: limiterCache('two_factor_temp_limiter'),
+};
+
+const userLimiterOptions = {
+ windowMs,
+ max,
+ handler: createHandler('user'),
+ keyGenerator: getUserLimiterKey,
+ store: limiterCache('two_factor_temp_user_limiter'),
+};
+
+const twoFactorTempIpLimiter = rateLimit(ipLimiterOptions);
+const twoFactorTempUserLimiter = rateLimit(userLimiterOptions);
+
+const twoFactorTempLimiter = (req, res, next) => {
+ twoFactorTempIpLimiter(req, res, (err) => {
+ if (err) {
+ return next(err);
+ }
+
+ return twoFactorTempUserLimiter(req, res, next);
+ });
+};
+
+module.exports = twoFactorTempLimiter;
diff --git a/api/server/middleware/limiters/twoFactorTempLimiter.test.js b/api/server/middleware/limiters/twoFactorTempLimiter.test.js
new file mode 100644
index 0000000..37b06c7
--- /dev/null
+++ b/api/server/middleware/limiters/twoFactorTempLimiter.test.js
@@ -0,0 +1,111 @@
+const jwt = require('jsonwebtoken');
+const express = require('express');
+const request = require('supertest');
+
+const originalEnv = process.env;
+const jwtSecret = 'test-two-factor-secret';
+
+const createToken = (userId) =>
+ jwt.sign({ userId, twoFAPending: true }, jwtSecret, { expiresIn: '5m' });
+
+const createApp = () => {
+ jest.resetModules();
+ process.env = {
+ ...originalEnv,
+ JWT_SECRET: jwtSecret,
+ LOGIN_MAX: '2',
+ LOGIN_WINDOW: '5',
+ TWO_FACTOR_TEMP_MAX: '2',
+ TWO_FACTOR_TEMP_WINDOW: '5',
+ };
+
+ jest.doMock('@librechat/api', () => ({
+ limiterCache: jest.fn(() => undefined),
+ removePorts: (req) => req?.['ip'],
+ }));
+ jest.doMock('~/cache', () => ({
+ logViolation: jest.fn().mockResolvedValue(undefined),
+ }));
+
+ const setTwoFactorTempUser = require('../setTwoFactorTempUser');
+ const twoFactorTempLimiter = require('./twoFactorTempLimiter');
+ const { logViolation } = require('~/cache');
+
+ const app = express();
+ app.set('trust proxy', 1);
+ app.use(express.json());
+ app.post('/verify', setTwoFactorTempUser, twoFactorTempLimiter, (req, res) =>
+ res.status(204).end(),
+ );
+
+ return { app, logViolation };
+};
+
+describe('twoFactorTempLimiter', () => {
+ afterEach(() => {
+ jest.dontMock('@librechat/api');
+ jest.dontMock('~/cache');
+ process.env = originalEnv;
+ });
+
+ it('limits a valid temp-token user across rotating source IPs', async () => {
+ const { app, logViolation } = createApp();
+ const tempToken = createToken('user-1');
+
+ await request(app)
+ .post('/verify')
+ .set('X-Forwarded-For', '203.0.113.1')
+ .send({ tempToken, token: '000000' })
+ .expect(204);
+ await request(app)
+ .post('/verify')
+ .set('X-Forwarded-For', '203.0.113.2')
+ .send({ tempToken, token: '000001' })
+ .expect(204);
+
+ const response = await request(app)
+ .post('/verify')
+ .set('X-Forwarded-For', '203.0.113.3')
+ .send({ tempToken, token: '000002' })
+ .expect(429);
+
+ expect(response.body).toEqual({
+ message: 'Too many verification attempts, please try again after 5 minutes.',
+ });
+ expect(logViolation).toHaveBeenCalledTimes(1);
+ expect(logViolation.mock.calls[0][0].user).toEqual({ id: 'user-1' });
+ expect(logViolation.mock.calls[0][3]).toMatchObject({
+ limiter: 'user',
+ max: '2',
+ windowInMinutes: 5,
+ });
+ });
+
+ it('keeps the existing source IP limit before the user limit', async () => {
+ const { app, logViolation } = createApp();
+
+ await request(app)
+ .post('/verify')
+ .set('X-Forwarded-For', '198.51.100.1')
+ .send({ tempToken: createToken('user-a'), token: '000000' })
+ .expect(204);
+ await request(app)
+ .post('/verify')
+ .set('X-Forwarded-For', '198.51.100.1')
+ .send({ tempToken: createToken('user-b'), token: '000001' })
+ .expect(204);
+
+ await request(app)
+ .post('/verify')
+ .set('X-Forwarded-For', '198.51.100.1')
+ .send({ tempToken: createToken('user-c'), token: '000002' })
+ .expect(429);
+
+ expect(logViolation).toHaveBeenCalledTimes(1);
+ expect(logViolation.mock.calls[0][3]).toMatchObject({
+ limiter: 'ip',
+ max: '2',
+ windowInMinutes: 5,
+ });
+ });
+});
diff --git a/api/server/middleware/limiters/uploadLimiters.js b/api/server/middleware/limiters/uploadLimiters.js
new file mode 100644
index 0000000..8c878cf
--- /dev/null
+++ b/api/server/middleware/limiters/uploadLimiters.js
@@ -0,0 +1,85 @@
+const rateLimit = require('express-rate-limit');
+const { ViolationTypes } = require('librechat-data-provider');
+const { limiterCache, removePorts } = require('@librechat/api');
+const logViolation = require('~/cache/logViolation');
+
+const getEnvironmentVariables = () => {
+ const FILE_UPLOAD_IP_MAX = parseInt(process.env.FILE_UPLOAD_IP_MAX) || 100;
+ const FILE_UPLOAD_IP_WINDOW = parseInt(process.env.FILE_UPLOAD_IP_WINDOW) || 15;
+ const FILE_UPLOAD_USER_MAX = parseInt(process.env.FILE_UPLOAD_USER_MAX) || 50;
+ const FILE_UPLOAD_USER_WINDOW = parseInt(process.env.FILE_UPLOAD_USER_WINDOW) || 15;
+ const FILE_UPLOAD_VIOLATION_SCORE = process.env.FILE_UPLOAD_VIOLATION_SCORE;
+
+ const fileUploadIpWindowMs = FILE_UPLOAD_IP_WINDOW * 60 * 1000;
+ const fileUploadIpMax = FILE_UPLOAD_IP_MAX;
+ const fileUploadIpWindowInMinutes = fileUploadIpWindowMs / 60000;
+
+ const fileUploadUserWindowMs = FILE_UPLOAD_USER_WINDOW * 60 * 1000;
+ const fileUploadUserMax = FILE_UPLOAD_USER_MAX;
+ const fileUploadUserWindowInMinutes = fileUploadUserWindowMs / 60000;
+
+ return {
+ fileUploadIpWindowMs,
+ fileUploadIpMax,
+ fileUploadIpWindowInMinutes,
+ fileUploadUserWindowMs,
+ fileUploadUserMax,
+ fileUploadUserWindowInMinutes,
+ fileUploadViolationScore: FILE_UPLOAD_VIOLATION_SCORE,
+ };
+};
+
+const createFileUploadHandler = (ip = true) => {
+ const {
+ fileUploadIpMax,
+ fileUploadIpWindowInMinutes,
+ fileUploadUserMax,
+ fileUploadUserWindowInMinutes,
+ fileUploadViolationScore,
+ } = getEnvironmentVariables();
+
+ return async (req, res) => {
+ const type = ViolationTypes.FILE_UPLOAD_LIMIT;
+ const errorMessage = {
+ type,
+ max: ip ? fileUploadIpMax : fileUploadUserMax,
+ limiter: ip ? 'ip' : 'user',
+ windowInMinutes: ip ? fileUploadIpWindowInMinutes : fileUploadUserWindowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, fileUploadViolationScore);
+ res.status(429).json({ message: 'Too many file upload requests. Try again later' });
+ };
+};
+
+const createFileLimiters = () => {
+ const { fileUploadIpWindowMs, fileUploadIpMax, fileUploadUserWindowMs, fileUploadUserMax } =
+ getEnvironmentVariables();
+
+ const ipLimiterOptions = {
+ windowMs: fileUploadIpWindowMs,
+ max: fileUploadIpMax,
+ handler: createFileUploadHandler(),
+ keyGenerator: removePorts,
+ store: limiterCache('file_upload_ip_limiter'),
+ };
+
+ const userLimiterOptions = {
+ windowMs: fileUploadUserWindowMs,
+ max: fileUploadUserMax,
+ handler: createFileUploadHandler(false),
+ keyGenerator: function (req) {
+ return req.user?.id;
+ },
+ store: limiterCache('file_upload_user_limiter'),
+ };
+
+ const fileUploadIpLimiter = rateLimit(ipLimiterOptions);
+ const fileUploadUserLimiter = rateLimit(userLimiterOptions);
+
+ return { fileUploadIpLimiter, fileUploadUserLimiter };
+};
+
+module.exports = {
+ createFileLimiters,
+};
diff --git a/api/server/middleware/limiters/verifyEmailLimiter.js b/api/server/middleware/limiters/verifyEmailLimiter.js
new file mode 100644
index 0000000..5844686
--- /dev/null
+++ b/api/server/middleware/limiters/verifyEmailLimiter.js
@@ -0,0 +1,38 @@
+const rateLimit = require('express-rate-limit');
+const { ViolationTypes } = require('librechat-data-provider');
+const { limiterCache, removePorts } = require('@librechat/api');
+const { logViolation } = require('~/cache');
+
+const {
+ VERIFY_EMAIL_WINDOW = 2,
+ VERIFY_EMAIL_MAX = 2,
+ VERIFY_EMAIL_VIOLATION_SCORE: score,
+} = process.env;
+const windowMs = VERIFY_EMAIL_WINDOW * 60 * 1000;
+const max = VERIFY_EMAIL_MAX;
+const windowInMinutes = windowMs / 60000;
+const message = `Too many attempts, please try again after ${windowInMinutes} minute(s)`;
+
+const handler = async (req, res) => {
+ const type = ViolationTypes.VERIFY_EMAIL_LIMIT;
+ const errorMessage = {
+ type,
+ max,
+ windowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, score);
+ return res.status(429).json({ message });
+};
+
+const limiterOptions = {
+ windowMs,
+ max,
+ handler,
+ keyGenerator: removePorts,
+ store: limiterCache('verify_email_limiter'),
+};
+
+const verifyEmailLimiter = rateLimit(limiterOptions);
+
+module.exports = verifyEmailLimiter;
diff --git a/api/server/middleware/limiters/verifyEmailSubmissionLimiter.js b/api/server/middleware/limiters/verifyEmailSubmissionLimiter.js
new file mode 100644
index 0000000..e3a1f8c
--- /dev/null
+++ b/api/server/middleware/limiters/verifyEmailSubmissionLimiter.js
@@ -0,0 +1,39 @@
+const rateLimit = require('express-rate-limit');
+const { ViolationTypes } = require('librechat-data-provider');
+const { limiterCache, removePorts } = require('@librechat/api');
+const { logViolation } = require('~/cache');
+
+const {
+ VERIFY_EMAIL_SUBMISSION_WINDOW = process.env.VERIFY_EMAIL_WINDOW ?? 2,
+ VERIFY_EMAIL_SUBMISSION_MAX = process.env.VERIFY_EMAIL_MAX ?? 2,
+ VERIFY_EMAIL_SUBMISSION_VIOLATION_SCORE: score,
+} = process.env;
+const windowMs = VERIFY_EMAIL_SUBMISSION_WINDOW * 60 * 1000;
+const max = VERIFY_EMAIL_SUBMISSION_MAX;
+const windowInMinutes = windowMs / 60000;
+const message = `Too many attempts, please try again after ${windowInMinutes} minute(s)`;
+
+const handler = async (req, res) => {
+ const type = ViolationTypes.VERIFY_EMAIL_LIMIT;
+ const errorMessage = {
+ type,
+ max,
+ windowInMinutes,
+ limiter: 'submission',
+ };
+
+ await logViolation(req, res, type, errorMessage, score);
+ return res.status(429).json({ message });
+};
+
+const limiterOptions = {
+ windowMs,
+ max,
+ handler,
+ keyGenerator: removePorts,
+ store: limiterCache('verify_email_submission_limiter'),
+};
+
+const verifyEmailSubmissionLimiter = rateLimit(limiterOptions);
+
+module.exports = verifyEmailSubmissionLimiter;
diff --git a/api/server/middleware/logHeaders.js b/api/server/middleware/logHeaders.js
new file mode 100644
index 0000000..3985427
--- /dev/null
+++ b/api/server/middleware/logHeaders.js
@@ -0,0 +1,32 @@
+const { logger } = require('@librechat/data-schemas');
+
+/**
+ * Middleware to log Forwarded Headers
+ * @function
+ * @param {ServerRequest} req - Express request object containing user information.
+ * @param {ServerResponse} res - Express response object.
+ * @param {import('express').NextFunction} next - Next middleware function.
+ * @throws {Error} Throws an error if the user exceeds the concurrent request limit.
+ */
+const logHeaders = (req, res, next) => {
+ try {
+ const forwardedHeaders = {};
+ if (req.headers['x-forwarded-for']) {
+ forwardedHeaders['x-forwarded-for'] = req.headers['x-forwarded-for'];
+ }
+ if (req.headers['x-forwarded-host']) {
+ forwardedHeaders['x-forwarded-host'] = req.headers['x-forwarded-host'];
+ }
+ if (req.headers['x-forwarded-proto']) {
+ forwardedHeaders['x-forwarded-proto'] = req.headers['x-forwarded-proto'];
+ }
+ if (Object.keys(forwardedHeaders).length > 0) {
+ logger.debug('X-Forwarded headers detected in OAuth request:', forwardedHeaders);
+ }
+ } catch (error) {
+ logger.error('Error logging X-Forwarded headers:', error);
+ }
+ next();
+};
+
+module.exports = logHeaders;
diff --git a/api/server/middleware/messageValidation.js b/api/server/middleware/messageValidation.js
new file mode 100644
index 0000000..a7047db
--- /dev/null
+++ b/api/server/middleware/messageValidation.js
@@ -0,0 +1,14 @@
+const {
+ GenerationJobManager,
+ createMessageRequestMiddleware,
+ isPendingActionStale,
+} = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const { getConvo } = require('~/models');
+
+module.exports = createMessageRequestMiddleware({
+ getConvo,
+ getJob: (conversationId) => GenerationJobManager.getJob(conversationId),
+ isPendingActionStale,
+ logger,
+});
diff --git a/api/server/middleware/moderateText.js b/api/server/middleware/moderateText.js
new file mode 100644
index 0000000..3356f75
--- /dev/null
+++ b/api/server/middleware/moderateText.js
@@ -0,0 +1,100 @@
+const axios = require('axios');
+const { isEnabled, getReferencedQuotes, mergeQuotedText } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const { ErrorTypes } = require('librechat-data-provider');
+const denyRequest = require('./denyRequest');
+
+async function moderateText(req, res, next) {
+ if (!isEnabled(process.env.OPENAI_MODERATION)) {
+ return next();
+ }
+ try {
+ const { text } = req.body;
+
+ /**
+ * Moderate the typed text, each quoted excerpt, and the merged blockquote+text
+ * exactly as the model receives it. Quotes are normalized via
+ * `getReferencedQuotes` first (matching `BaseClient`); moderating the merged
+ * string also covers content split across a quote and the typed body. The
+ * moderation API accepts an array of inputs.
+ *
+ * `answer` covers the HITL resume payload (POST /agents/chat/resume) for an
+ * ask-user question — the user's free-form text — so it's moderated like a typed
+ * message. A tool-approval resume carries no user text and is skipped below.
+ */
+ let safeText = '';
+ if (typeof text === 'string') {
+ safeText = text;
+ } else if (typeof req.body.answer === 'string') {
+ safeText = req.body.answer;
+ }
+ const inputs = [];
+ if (safeText.length > 0) {
+ inputs.push(safeText);
+ }
+ const quotes = getReferencedQuotes(req.body.quotes);
+ if (quotes != null) {
+ inputs.push(...quotes);
+ inputs.push(mergeQuotedText(safeText, quotes));
+ }
+ // A tool-approval resume can carry user-authored text in `decisions[]`: the
+ // `respond` substitute result, a `reject` reason, and `edit`ed tool arguments —
+ // moderate all of them like typed text (edited args stringified).
+ if (Array.isArray(req.body.decisions)) {
+ for (const decision of req.body.decisions) {
+ if (typeof decision?.responseText === 'string' && decision.responseText.length > 0) {
+ inputs.push(decision.responseText);
+ }
+ if (typeof decision?.reason === 'string' && decision.reason.length > 0) {
+ inputs.push(decision.reason);
+ }
+ if (decision?.editedArguments != null) {
+ try {
+ const edited = JSON.stringify(decision.editedArguments);
+ if (typeof edited === 'string' && edited.length > 0) {
+ inputs.push(edited);
+ }
+ } catch {
+ /* ignore unstringifiable edited args */
+ }
+ }
+ }
+ }
+ // Nothing to moderate (e.g. a tool-approval resume with no `respond` text) —
+ // don't post an empty/undefined `input`, which the moderation API rejects and which
+ // would otherwise deny the request.
+ if (inputs.length === 0) {
+ return next();
+ }
+ const input = inputs.length > 1 ? inputs : inputs[0];
+
+ const response = await axios.post(
+ process.env.OPENAI_MODERATION_REVERSE_PROXY || 'https://api.openai.com/v1/moderations',
+ {
+ input,
+ },
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${process.env.OPENAI_MODERATION_API_KEY}`,
+ },
+ },
+ );
+
+ const results = response.data.results;
+ const flagged = results.some((result) => result.flagged);
+
+ if (flagged) {
+ const type = ErrorTypes.MODERATION;
+ const errorMessage = { type };
+ return await denyRequest(req, res, errorMessage);
+ }
+ } catch (error) {
+ logger.error('Error in moderateText:', error);
+ const errorMessage = 'error in moderation check';
+ return await denyRequest(req, res, errorMessage);
+ }
+ next();
+}
+
+module.exports = moderateText;
diff --git a/api/server/middleware/noIndex.js b/api/server/middleware/noIndex.js
new file mode 100644
index 0000000..c4d7b55
--- /dev/null
+++ b/api/server/middleware/noIndex.js
@@ -0,0 +1,11 @@
+const noIndex = (req, res, next) => {
+ const shouldNoIndex = process.env.NO_INDEX ? process.env.NO_INDEX === 'true' : true;
+
+ if (shouldNoIndex) {
+ res.setHeader('X-Robots-Tag', 'noindex');
+ }
+
+ next();
+};
+
+module.exports = noIndex;
diff --git a/api/server/middleware/optionalJwtAuth.js b/api/server/middleware/optionalJwtAuth.js
new file mode 100644
index 0000000..8535794
--- /dev/null
+++ b/api/server/middleware/optionalJwtAuth.js
@@ -0,0 +1,35 @@
+const cookies = require('cookie');
+const passport = require('passport');
+const { isEnabled, tenantContextMiddleware } = require('@librechat/api');
+
+const hasPassportStrategy = (strategy) =>
+ typeof passport._strategy === 'function' && passport._strategy(strategy) != null;
+
+// This middleware does not require authentication,
+// but if the user is authenticated, it will set the user object
+// and establish tenant ALS context.
+const optionalJwtAuth = (req, res, next) => {
+ const cookieHeader = req.headers.cookie;
+ const tokenProvider = cookieHeader ? cookies.parse(cookieHeader).token_provider : null;
+ const useOpenIdJwt =
+ tokenProvider === 'openid' &&
+ isEnabled(process.env.OPENID_REUSE_TOKENS) &&
+ hasPassportStrategy('openidJwt');
+ const callback = (err, user) => {
+ if (err) {
+ return next(err);
+ }
+ if (user) {
+ req.user = user;
+ req.authStrategy = useOpenIdJwt ? 'openidJwt' : 'jwt';
+ return tenantContextMiddleware(req, res, next);
+ }
+ next();
+ };
+ if (useOpenIdJwt) {
+ return passport.authenticate('openidJwt', { session: false }, callback)(req, res, next);
+ }
+ passport.authenticate('jwt', { session: false }, callback)(req, res, next);
+};
+
+module.exports = optionalJwtAuth;
diff --git a/api/server/middleware/optionalShareFileAuth.js b/api/server/middleware/optionalShareFileAuth.js
new file mode 100644
index 0000000..bebf087
--- /dev/null
+++ b/api/server/middleware/optionalShareFileAuth.js
@@ -0,0 +1,88 @@
+const cookie = require('cookie');
+const jwt = require('jsonwebtoken');
+const { isEnabled } = require('@librechat/api');
+const { logger, runAsSystem } = require('@librechat/data-schemas');
+const { SystemRoles } = require('librechat-data-provider');
+const { getUserById, findSession } = require('~/models');
+
+const verifySignedUserId = (token) => {
+ try {
+ const payload = jwt.verify(token, process.env.JWT_REFRESH_SECRET);
+ return typeof payload?.id === 'string' ? payload.id : null;
+ } catch {
+ return null;
+ }
+};
+
+const getRefreshTokenUserId = async (token) => {
+ const userId = verifySignedUserId(token);
+ if (!userId) {
+ return null;
+ }
+
+ const session = await runAsSystem(() => findSession({ userId, refreshToken: token }));
+ return session ? userId : null;
+};
+
+const getOpenIdUserId = (parsed, req) => {
+ if (parsed.token_provider !== 'openid' || !isEnabled(process.env.OPENID_REUSE_TOKENS)) {
+ return null;
+ }
+
+ const sessionRefreshToken = req.session?.openidTokens?.refreshToken;
+ if (!parsed.refreshToken || parsed.refreshToken !== sessionRefreshToken) {
+ return null;
+ }
+
+ return verifySignedUserId(parsed.openid_user_id);
+};
+
+/**
+ * Fallback auth for share file routes that are hit by ` `/anchor requests,
+ * which can't carry the bearer access token. Resolves the viewer from the
+ * `refreshToken` cookie (or an active OpenID session plus signed `openid_user_id`
+ * cookie) so non-public shared links can authorize the viewer's ACL. Never
+ * blocks: on any failure it leaves `req.user` unset and lets
+ * `canAccessSharedLink` decide (public access, 401, or 403).
+ */
+const optionalShareFileAuth = async (req, res, next) => {
+ if (req.user) {
+ return next();
+ }
+
+ try {
+ const cookieHeader = req.headers.cookie;
+ if (!cookieHeader) {
+ return next();
+ }
+
+ const parsed = cookie.parse(cookieHeader);
+ const userId =
+ getOpenIdUserId(parsed, req) ||
+ (parsed.refreshToken ? await getRefreshTokenUserId(parsed.refreshToken) : null);
+ if (!userId) {
+ return next();
+ }
+
+ // Resolve in system context: this runs before canAccessSharedLink establishes
+ // the share tenant, so under strict tenant isolation a tenant-scoped User
+ // query would otherwise throw. The viewer's id comes from verified, active
+ // cookie auth; the share's tenant-scoped ACL check still gates access.
+ const user = await runAsSystem(() =>
+ getUserById(userId, '-password -__v -totpSecret -backupCodes'),
+ );
+ if (user) {
+ user.id = user._id.toString();
+ if (!user.role) {
+ user.role = SystemRoles.USER;
+ }
+ req.user = user;
+ }
+ } catch (error) {
+ logger.warn('[optionalShareFileAuth] cookie auth failed:', error?.message);
+ }
+
+ return next();
+};
+
+module.exports = optionalShareFileAuth;
diff --git a/api/server/middleware/optionalShareFileAuth.spec.js b/api/server/middleware/optionalShareFileAuth.spec.js
new file mode 100644
index 0000000..ffc0cf5
--- /dev/null
+++ b/api/server/middleware/optionalShareFileAuth.spec.js
@@ -0,0 +1,136 @@
+const mockVerify = jest.fn();
+const mockGetUserById = jest.fn();
+const mockFindSession = jest.fn();
+const mockRunAsSystem = jest.fn((fn) => fn());
+
+jest.mock('jsonwebtoken', () => ({ verify: (...args) => mockVerify(...args) }));
+jest.mock('@librechat/api', () => ({ isEnabled: (v) => v === 'true' || v === true }), {
+ virtual: true,
+});
+jest.mock(
+ '@librechat/data-schemas',
+ () => ({
+ logger: { warn: jest.fn(), error: jest.fn() },
+ runAsSystem: (...args) => mockRunAsSystem(...args),
+ }),
+ { virtual: true },
+);
+jest.mock('librechat-data-provider', () => ({ SystemRoles: { USER: 'USER' } }), {
+ virtual: true,
+});
+jest.mock('~/models', () => ({
+ getUserById: (...args) => mockGetUserById(...args),
+ findSession: (...args) => mockFindSession(...args),
+}));
+
+const optionalShareFileAuth = require('./optionalShareFileAuth');
+
+const run = async (req) => {
+ const next = jest.fn();
+ await optionalShareFileAuth(req, {}, next);
+ return next;
+};
+
+describe('optionalShareFileAuth', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ process.env.JWT_REFRESH_SECRET = 'test-secret';
+ });
+
+ it('short-circuits when a bearer user is already set (no cookie work)', async () => {
+ const req = { user: { id: 'u1' }, headers: { cookie: 'refreshToken=x' } };
+ const next = await run(req);
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(mockVerify).not.toHaveBeenCalled();
+ expect(mockGetUserById).not.toHaveBeenCalled();
+ expect(mockFindSession).not.toHaveBeenCalled();
+ });
+
+ it('resolves the viewer from a valid refreshToken cookie with a live session', async () => {
+ mockVerify.mockReturnValue({ id: 'viewer-1' });
+ mockFindSession.mockResolvedValue({ _id: 'session-1' });
+ mockGetUserById.mockResolvedValue({ _id: 'viewer-1', role: 'USER' });
+ const req = { headers: { cookie: 'refreshToken=good.jwt' } };
+ const next = await run(req);
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(mockVerify).toHaveBeenCalledWith('good.jwt', 'test-secret');
+ expect(mockFindSession).toHaveBeenCalledWith({ userId: 'viewer-1', refreshToken: 'good.jwt' });
+ expect(mockRunAsSystem).toHaveBeenCalledTimes(2);
+ expect(req.user).toMatchObject({ id: 'viewer-1', role: 'USER' });
+ });
+
+ it('defaults the role to USER when the record has none', async () => {
+ mockVerify.mockReturnValue({ id: 'viewer-2' });
+ mockFindSession.mockResolvedValue({ _id: 'session-2' });
+ mockGetUserById.mockResolvedValue({ _id: 'viewer-2' });
+ const req = { headers: { cookie: 'refreshToken=good.jwt' } };
+ await run(req);
+ expect(req.user.role).toBe('USER');
+ });
+
+ it('leaves req.user unset when there is no cookie', async () => {
+ const req = { headers: {} };
+ const next = await run(req);
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(req.user).toBeUndefined();
+ expect(mockGetUserById).not.toHaveBeenCalled();
+ });
+
+ it('leaves req.user unset when the refresh token has no live session', async () => {
+ mockVerify.mockReturnValue({ id: 'viewer-3' });
+ mockFindSession.mockResolvedValue(null);
+ const req = { headers: { cookie: 'refreshToken=revoked.jwt' } };
+ const next = await run(req);
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(req.user).toBeUndefined();
+ expect(mockFindSession).toHaveBeenCalledWith({
+ userId: 'viewer-3',
+ refreshToken: 'revoked.jwt',
+ });
+ expect(mockRunAsSystem).toHaveBeenCalledTimes(1);
+ expect(mockGetUserById).not.toHaveBeenCalled();
+ });
+
+ it('leaves req.user unset when the token is invalid', async () => {
+ mockVerify.mockImplementation(() => {
+ throw new Error('bad token');
+ });
+ const req = { headers: { cookie: 'refreshToken=bad' } };
+ const next = await run(req);
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(req.user).toBeUndefined();
+ expect(mockGetUserById).not.toHaveBeenCalled();
+ });
+
+ it('uses the signed openid_user_id cookie only for active OpenID-reuse sessions', async () => {
+ process.env.OPENID_REUSE_TOKENS = 'true';
+ mockVerify.mockReturnValue({ id: 'oidc-1' });
+ mockGetUserById.mockResolvedValue({ _id: 'oidc-1', role: 'USER' });
+ const req = {
+ headers: {
+ cookie: 'token_provider=openid; refreshToken=stored-refresh; openid_user_id=signed.jwt',
+ },
+ session: { openidTokens: { refreshToken: 'stored-refresh' } },
+ };
+ await run(req);
+ expect(mockVerify).toHaveBeenCalledWith('signed.jwt', 'test-secret');
+ expect(mockFindSession).not.toHaveBeenCalled();
+ expect(req.user).toMatchObject({ id: 'oidc-1' });
+ delete process.env.OPENID_REUSE_TOKENS;
+ });
+
+ it('leaves req.user unset for OpenID-reuse cookies without an active matching session', async () => {
+ process.env.OPENID_REUSE_TOKENS = 'true';
+ mockVerify.mockReturnValue({ id: 'oidc-2' });
+ const req = {
+ headers: {
+ cookie: 'token_provider=openid; refreshToken=stale-refresh; openid_user_id=signed.jwt',
+ },
+ session: { openidTokens: { refreshToken: 'current-refresh' } },
+ };
+ await run(req);
+ expect(req.user).toBeUndefined();
+ expect(mockGetUserById).not.toHaveBeenCalled();
+ delete process.env.OPENID_REUSE_TOKENS;
+ });
+});
diff --git a/api/server/middleware/requireJwtAuth.js b/api/server/middleware/requireJwtAuth.js
new file mode 100644
index 0000000..9c4d1ca
--- /dev/null
+++ b/api/server/middleware/requireJwtAuth.js
@@ -0,0 +1,239 @@
+const cookies = require('cookie');
+const jwt = require('jsonwebtoken');
+const passport = require('passport');
+const { logger } = require('@librechat/data-schemas');
+const {
+ isEnabled,
+ tenantContextMiddleware,
+ getAuthFailureReason,
+ getAuthFailureErrorName,
+ buildSafeAuthLogContext,
+ formatAuthLogMessage,
+ maybeRefreshCloudFrontAuthCookiesMiddleware,
+ recordRumProxyRequest,
+} = require('@librechat/api');
+
+const hasPassportStrategy = (strategy) =>
+ typeof passport._strategy === 'function' && passport._strategy(strategy) != null;
+
+const getValidOpenIdReuseUserId = (parsedCookies) => {
+ const openidUserId = parsedCookies.openid_user_id;
+ if (!openidUserId || !process.env.JWT_REFRESH_SECRET) {
+ return null;
+ }
+
+ try {
+ const payload = jwt.verify(openidUserId, process.env.JWT_REFRESH_SECRET);
+ return typeof payload === 'object' && payload != null && typeof payload.id === 'string'
+ ? payload.id
+ : null;
+ } catch {
+ return null;
+ }
+};
+
+const getAuthenticatedUserId = (user) => user?.id?.toString?.() ?? user?._id?.toString?.();
+const refreshCloudFrontCookies =
+ maybeRefreshCloudFrontAuthCookiesMiddleware ?? ((_req, _res, next) => next());
+
+const getAuthStrategies = (req) => {
+ const cookieHeader = req.headers.cookie;
+ const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {};
+ const tokenProvider = parsedCookies.token_provider;
+ const openidReuseEnabled = isEnabled(process.env.OPENID_REUSE_TOKENS);
+ const openidJwtAvailable = openidReuseEnabled && hasPassportStrategy('openidJwt');
+ const openIdReuseUserId = getValidOpenIdReuseUserId(parsedCookies);
+ const useOpenIdJwt =
+ tokenProvider === 'openid' && openidJwtAvailable && openIdReuseUserId != null;
+
+ return {
+ tokenProvider,
+ openidReuseEnabled,
+ openidJwtAvailable,
+ openIdReuseUserId,
+ strategies: useOpenIdJwt ? ['openidJwt', 'jwt'] : ['jwt'],
+ };
+};
+
+const dropRumTelemetry = (res) => {
+ if (!res.headersSent) {
+ res.status(204).end();
+ }
+};
+
+// Keep in sync with packages/api/src/rum/proxy.ts; auth drops are recorded before proxy code runs.
+const getRumProxyEndpoint = (req) => {
+ if (req.path === '/v1/traces') {
+ return 'traces';
+ }
+ if (req.path === '/v1/logs') {
+ return 'logs';
+ }
+ return 'unknown';
+};
+
+const isOpenIdReuseUser = (strategy, user, openIdReuseUserId) =>
+ strategy !== 'openidJwt' || getAuthenticatedUserId(user) === openIdReuseUserId;
+
+/**
+ * Custom Middleware to handle JWT authentication, with support for OpenID token reuse.
+ * Switches between JWT and OpenID authentication based on cookies and environment settings.
+ *
+ * After successful authentication (req.user populated), automatically chains into
+ * `tenantContextMiddleware` to propagate request context into AsyncLocalStorage
+ * for downstream Mongoose tenant isolation and structured logging.
+ */
+const requireJwtAuth = (req, res, next) => {
+ const { tokenProvider, openidReuseEnabled, openidJwtAvailable, openIdReuseUserId, strategies } =
+ getAuthStrategies(req);
+ const authLogState = {
+ tokenProvider,
+ openidReuseEnabled,
+ openidJwtAvailable,
+ hasOpenIdReuseUserId: openIdReuseUserId != null,
+ };
+ let primaryFailureReason;
+ let primaryFailureErrorName;
+ let fallbackAttempted = false;
+
+ const logOpenIdFallbackAttempt = ({ fallbackStrategy, reason, errorName, status }) => {
+ primaryFailureReason = reason;
+ primaryFailureErrorName = errorName;
+ fallbackAttempted = true;
+ const message = '[requireJwtAuth] OpenID JWT auth failed; trying fallback';
+ const context = buildSafeAuthLogContext(req, authLogState, {
+ primary_strategy: 'openidJwt',
+ fallback_strategy: fallbackStrategy,
+ fallback_attempted: true,
+ reason,
+ error_name: errorName,
+ status,
+ });
+ logger.debug(formatAuthLogMessage(message, context), context);
+ };
+
+ const logAuthenticationFailure = ({ strategy, info, status, err }) => {
+ const message = '[requireJwtAuth] Authentication failed after all strategies';
+ const context = buildSafeAuthLogContext(req, authLogState, {
+ primary_strategy: strategies[0],
+ fallback_strategy: strategies[1],
+ fallback_attempted: fallbackAttempted,
+ fallback_succeeded: false,
+ attempted_strategies: strategies,
+ final_strategy: strategy,
+ reason: getAuthFailureReason(err, info),
+ error_name: getAuthFailureErrorName(err, info),
+ status: status || 401,
+ });
+ const log = fallbackAttempted ? logger.warn : logger.debug;
+ log.call(logger, formatAuthLogMessage(message, context), context);
+ };
+
+ const logFallbackSuccess = (strategy) => {
+ if (!fallbackAttempted || strategy !== 'jwt') {
+ return;
+ }
+ const message = '[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure';
+ const context = buildSafeAuthLogContext(req, authLogState, {
+ auth_strategy: 'jwt',
+ primary_strategy: 'openidJwt',
+ fallback_strategy: 'jwt',
+ fallback_attempted: true,
+ fallback_succeeded: true,
+ primary_failure_reason: primaryFailureReason,
+ reason: primaryFailureReason,
+ error_name: primaryFailureErrorName,
+ });
+ logger.debug(formatAuthLogMessage(message, context), context);
+ };
+
+ const authenticateWithStrategy = (index) => {
+ const strategy = strategies[index];
+ passport.authenticate(strategy, { session: false }, (err, user, info, status) => {
+ if (err) {
+ return next(err);
+ }
+ if (!user) {
+ if (index + 1 < strategies.length) {
+ logOpenIdFallbackAttempt({
+ fallbackStrategy: strategies[index + 1],
+ reason: getAuthFailureReason(err, info),
+ errorName: getAuthFailureErrorName(err, info),
+ status: status || 401,
+ });
+ return authenticateWithStrategy(index + 1);
+ }
+ logAuthenticationFailure({ strategy, info, status, err });
+ return res.status(status || 401).json({
+ message: info?.message || 'Unauthorized',
+ });
+ }
+ if (strategy === 'openidJwt' && getAuthenticatedUserId(user) !== openIdReuseUserId) {
+ if (index + 1 < strategies.length) {
+ logOpenIdFallbackAttempt({
+ fallbackStrategy: strategies[index + 1],
+ reason: 'openid user-id mismatch',
+ status: 401,
+ });
+ return authenticateWithStrategy(index + 1);
+ }
+ logAuthenticationFailure({ strategy, info, status: 401, err });
+ return res.status(401).json({ message: 'Unauthorized' });
+ }
+ req.user = user;
+ req.authStrategy = strategy;
+ logFallbackSuccess(strategy);
+ tenantContextMiddleware(req, res, (tenantErr) => {
+ if (tenantErr) {
+ return next(tenantErr);
+ }
+ refreshCloudFrontCookies(req, res, next);
+ });
+ })(req, res, next);
+ };
+
+ authenticateWithStrategy(0);
+};
+
+const requireRumProxyAuth = (req, res, next) => {
+ const { openIdReuseUserId, strategies } = getAuthStrategies(req);
+ const endpoint = getRumProxyEndpoint(req);
+ let authErrorSeen = false;
+
+ const dropTelemetry = () => {
+ recordRumProxyRequest(endpoint, authErrorSeen ? 'auth_error' : 'auth_drop');
+ dropRumTelemetry(res);
+ };
+
+ const finishAuthentication = (strategy, user) => {
+ req.user = user;
+ req.authStrategy = strategy;
+ next();
+ };
+
+ let nextStrategyIndex = 0;
+ const tryNextStrategy = () => {
+ const strategy = strategies[nextStrategyIndex];
+ nextStrategyIndex += 1;
+
+ if (!strategy) {
+ dropTelemetry();
+ return;
+ }
+
+ passport.authenticate(strategy, { session: false }, (err, user) => {
+ authErrorSeen = authErrorSeen || err != null;
+ if (err || !user || !isOpenIdReuseUser(strategy, user, openIdReuseUserId)) {
+ tryNextStrategy();
+ return;
+ }
+
+ finishAuthentication(strategy, user);
+ })(req, res, next);
+ };
+
+ tryNextStrategy();
+};
+
+module.exports = requireJwtAuth;
+module.exports.requireRumProxyAuth = requireRumProxyAuth;
diff --git a/api/server/middleware/requireLdapAuth.js b/api/server/middleware/requireLdapAuth.js
new file mode 100644
index 0000000..fc9b158
--- /dev/null
+++ b/api/server/middleware/requireLdapAuth.js
@@ -0,0 +1,22 @@
+const passport = require('passport');
+
+const requireLdapAuth = (req, res, next) => {
+ passport.authenticate('ldapauth', (err, user, info) => {
+ if (err) {
+ console.log({
+ title: '(requireLdapAuth) Error at passport.authenticate',
+ parameters: [{ name: 'error', value: err }],
+ });
+ return next(err);
+ }
+ if (!user) {
+ console.log({
+ title: '(requireLdapAuth) Error: No user',
+ });
+ return res.status(404).send(info);
+ }
+ req.user = user;
+ next();
+ })(req, res, next);
+};
+module.exports = requireLdapAuth;
diff --git a/api/server/middleware/requireLocalAuth.js b/api/server/middleware/requireLocalAuth.js
new file mode 100644
index 0000000..e82388f
--- /dev/null
+++ b/api/server/middleware/requireLocalAuth.js
@@ -0,0 +1,23 @@
+const passport = require('passport');
+const { logger } = require('@librechat/data-schemas');
+
+const requireLocalAuth = (req, res, next) => {
+ passport.authenticate('local', (err, user, info) => {
+ if (err) {
+ logger.error('[requireLocalAuth] Error at passport.authenticate:', err);
+ return next(err);
+ }
+ if (!user) {
+ logger.debug('[requireLocalAuth] Error: No user');
+ return res.status(404).send(info);
+ }
+ if (info && info.message) {
+ logger.debug('[requireLocalAuth] Error: ' + info.message);
+ return res.status(422).send({ message: info.message });
+ }
+ req.user = user;
+ next();
+ })(req, res, next);
+};
+
+module.exports = requireLocalAuth;
diff --git a/api/server/middleware/roles/access.spec.js b/api/server/middleware/roles/access.spec.js
new file mode 100644
index 0000000..16fb6df
--- /dev/null
+++ b/api/server/middleware/roles/access.spec.js
@@ -0,0 +1,370 @@
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { checkAccess, generateCheckAccess } = require('@librechat/api');
+const { PermissionTypes, Permissions } = require('librechat-data-provider');
+const { getRoleByName } = require('~/models');
+const { Role } = require('~/db/models');
+
+// Mock the logger from @librechat/data-schemas
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ logger: {
+ warn: jest.fn(),
+ error: jest.fn(),
+ info: jest.fn(),
+ debug: jest.fn(),
+ },
+}));
+
+// Mock the cache to use a simple in-memory implementation
+const mockCache = new Map();
+jest.mock('~/cache/getLogStores', () => {
+ return jest.fn(() => ({
+ get: jest.fn(async (key) => mockCache.get(key)),
+ set: jest.fn(async (key, value) => mockCache.set(key, value)),
+ clear: jest.fn(async () => mockCache.clear()),
+ }));
+});
+
+describe('Access Middleware', () => {
+ let mongoServer;
+ let req, res, next;
+
+ 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 mongoose.connection.dropDatabase();
+ mockCache.clear(); // Clear the cache between tests
+
+ // Create test roles
+ await Role.create({
+ name: 'user',
+ permissions: {
+ [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
+ [PermissionTypes.PROMPTS]: {
+ [Permissions.USE]: true,
+ [Permissions.CREATE]: true,
+ [Permissions.SHARE]: true,
+ },
+ [PermissionTypes.MEMORIES]: {
+ [Permissions.USE]: true,
+ [Permissions.CREATE]: true,
+ [Permissions.UPDATE]: true,
+ [Permissions.READ]: true,
+ [Permissions.OPT_OUT]: true,
+ },
+ [PermissionTypes.AGENTS]: {
+ [Permissions.USE]: true,
+ [Permissions.CREATE]: false,
+ [Permissions.SHARE]: false,
+ },
+ [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
+ [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
+ [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
+ [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
+ },
+ });
+
+ await Role.create({
+ name: 'admin',
+ permissions: {
+ [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
+ [PermissionTypes.PROMPTS]: {
+ [Permissions.USE]: true,
+ [Permissions.CREATE]: true,
+ [Permissions.SHARE]: true,
+ },
+ [PermissionTypes.MEMORIES]: {
+ [Permissions.USE]: true,
+ [Permissions.CREATE]: true,
+ [Permissions.UPDATE]: true,
+ [Permissions.READ]: true,
+ [Permissions.OPT_OUT]: true,
+ },
+ [PermissionTypes.AGENTS]: {
+ [Permissions.USE]: true,
+ [Permissions.CREATE]: true,
+ [Permissions.SHARE]: true,
+ },
+ [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
+ [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
+ [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
+ [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
+ },
+ });
+
+ // Create limited role with no AGENTS permissions
+ await Role.create({
+ name: 'limited',
+ permissions: {
+ // Explicitly set AGENTS permissions to false
+ [PermissionTypes.AGENTS]: {
+ [Permissions.USE]: false,
+ [Permissions.CREATE]: false,
+ [Permissions.SHARE]: false,
+ },
+ // Has permissions for other types
+ [PermissionTypes.PROMPTS]: {
+ [Permissions.USE]: true,
+ },
+ },
+ });
+
+ req = {
+ user: { id: 'user123', role: 'user' },
+ body: {},
+ originalUrl: '/test',
+ };
+ res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+ next = jest.fn();
+ jest.clearAllMocks();
+ });
+
+ describe('checkAccess', () => {
+ test('should return false if user is not provided', async () => {
+ const result = await checkAccess({
+ user: null,
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ expect(result).toBe(false);
+ });
+
+ test('should return true if user has required permission', async () => {
+ const result = await checkAccess({
+ req: {},
+ user: { id: 'user123', role: 'user' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ expect(result).toBe(true);
+ });
+
+ test('should return false if user lacks required permission', async () => {
+ const result = await checkAccess({
+ req: {},
+ user: { id: 'user123', role: 'user' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.CREATE],
+ getRoleByName,
+ });
+ expect(result).toBe(false);
+ });
+
+ test('should return false if user has only some of multiple permissions', async () => {
+ // User has USE but not CREATE, so should fail when checking for both
+ const result = await checkAccess({
+ req: {},
+ user: { id: 'user123', role: 'user' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.CREATE, Permissions.USE],
+ getRoleByName,
+ });
+ expect(result).toBe(false);
+ });
+
+ test('should return true if user has all of multiple permissions', async () => {
+ // Admin has both USE and CREATE
+ const result = await checkAccess({
+ req: {},
+ user: { id: 'admin123', role: 'admin' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.CREATE, Permissions.USE],
+ getRoleByName,
+ });
+ expect(result).toBe(true);
+ });
+
+ test('should check body properties when permission is not directly granted', async () => {
+ const req = { body: { id: 'agent123' } };
+ const result = await checkAccess({
+ req,
+ user: { id: 'user123', role: 'user' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.UPDATE],
+ bodyProps: {
+ [Permissions.UPDATE]: ['id'],
+ },
+ checkObject: req.body,
+ getRoleByName,
+ });
+ expect(result).toBe(true);
+ });
+
+ test('should return false if role is not found', async () => {
+ const result = await checkAccess({
+ req: {},
+ user: { id: 'user123', role: 'nonexistent' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ expect(result).toBe(false);
+ });
+
+ test('should return false if role has no permissions for the requested type', async () => {
+ const result = await checkAccess({
+ req: {},
+ user: { id: 'user123', role: 'limited' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ expect(result).toBe(false);
+ });
+
+ test('should handle admin role with all permissions', async () => {
+ const createResult = await checkAccess({
+ req: {},
+ user: { id: 'admin123', role: 'admin' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.CREATE],
+ getRoleByName,
+ });
+ expect(createResult).toBe(true);
+
+ const shareResult = await checkAccess({
+ req: {},
+ user: { id: 'admin123', role: 'admin' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.SHARE],
+ getRoleByName,
+ });
+ expect(shareResult).toBe(true);
+ });
+ });
+
+ describe('generateCheckAccess', () => {
+ test('should call next() when user has required permission', async () => {
+ const middleware = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should return 403 when user lacks permission', async () => {
+ const middleware = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.CREATE],
+ getRoleByName,
+ });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
+ });
+
+ test('should check body properties when configured', async () => {
+ req.body = { agentId: 'agent123', description: 'test' };
+
+ const bodyProps = {
+ [Permissions.CREATE]: ['agentId'],
+ };
+
+ const middleware = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.CREATE],
+ bodyProps,
+ getRoleByName,
+ });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should handle database errors gracefully', async () => {
+ // Mock getRoleByName to throw an error
+ const mockGetRoleByName = jest
+ .fn()
+ .mockRejectedValue(new Error('Database connection failed'));
+
+ const middleware = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName: mockGetRoleByName,
+ });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(500);
+ expect(res.json).toHaveBeenCalledWith({
+ message: expect.stringContaining('Server error:'),
+ });
+ });
+
+ test('should work with multiple permission types', async () => {
+ req.user.role = 'admin';
+
+ const middleware = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE, Permissions.CREATE, Permissions.SHARE],
+ getRoleByName,
+ });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle missing user gracefully', async () => {
+ req.user = null;
+
+ const middleware = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
+ });
+
+ test('should handle role with no AGENTS permissions', async () => {
+ await Role.create({
+ name: 'noaccess',
+ permissions: {
+ // Explicitly set AGENTS with all permissions false
+ [PermissionTypes.AGENTS]: {
+ [Permissions.USE]: false,
+ [Permissions.CREATE]: false,
+ [Permissions.SHARE]: false,
+ },
+ },
+ });
+ req.user.role = 'noaccess';
+
+ const middleware = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
+ });
+ });
+});
diff --git a/api/server/middleware/roles/admin.js b/api/server/middleware/roles/admin.js
new file mode 100644
index 0000000..3cb93fa
--- /dev/null
+++ b/api/server/middleware/roles/admin.js
@@ -0,0 +1,14 @@
+const { SystemRoles } = require('librechat-data-provider');
+
+function checkAdmin(req, res, next) {
+ try {
+ if (req.user.role !== SystemRoles.ADMIN) {
+ return res.status(403).json({ message: 'Forbidden' });
+ }
+ next();
+ } catch (error) {
+ res.status(500).json({ message: 'Internal Server Error' });
+ }
+}
+
+module.exports = checkAdmin;
diff --git a/api/server/middleware/roles/capabilities.js b/api/server/middleware/roles/capabilities.js
new file mode 100644
index 0000000..6f2aa43
--- /dev/null
+++ b/api/server/middleware/roles/capabilities.js
@@ -0,0 +1,14 @@
+const { generateCapabilityCheck, capabilityContextMiddleware } = require('@librechat/api');
+const { getUserPrincipals, hasCapabilityForPrincipals } = require('~/models');
+
+const { hasCapability, requireCapability, hasConfigCapability } = generateCapabilityCheck({
+ getUserPrincipals,
+ hasCapabilityForPrincipals,
+});
+
+module.exports = {
+ hasCapability,
+ requireCapability,
+ hasConfigCapability,
+ capabilityContextMiddleware,
+};
diff --git a/api/server/middleware/roles/index.js b/api/server/middleware/roles/index.js
new file mode 100644
index 0000000..f97d4b7
--- /dev/null
+++ b/api/server/middleware/roles/index.js
@@ -0,0 +1,17 @@
+/**
+ * NOTE: hasCapability, requireCapability, hasConfigCapability, and
+ * capabilityContextMiddleware are intentionally NOT re-exported here.
+ *
+ * capabilities.js depends on ~/models, and the middleware barrel
+ * (middleware/index.js) is frequently required by modules that are
+ * themselves loaded while the barrel is still initialising — creating
+ * a circular-require that silently returns an empty exports object.
+ *
+ * Always import capability helpers directly:
+ * require('~/server/middleware/roles/capabilities')
+ */
+const checkAdmin = require('./admin');
+
+module.exports = {
+ checkAdmin,
+};
diff --git a/api/server/middleware/setHeaders.js b/api/server/middleware/setHeaders.js
new file mode 100644
index 0000000..c1b58e2
--- /dev/null
+++ b/api/server/middleware/setHeaders.js
@@ -0,0 +1,12 @@
+function setHeaders(req, res, next) {
+ res.writeHead(200, {
+ Connection: 'keep-alive',
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache, no-transform',
+ 'Access-Control-Allow-Origin': '*',
+ 'X-Accel-Buffering': 'no',
+ });
+ next();
+}
+
+module.exports = setHeaders;
diff --git a/api/server/middleware/setTwoFactorTempUser.js b/api/server/middleware/setTwoFactorTempUser.js
new file mode 100644
index 0000000..facbbcb
--- /dev/null
+++ b/api/server/middleware/setTwoFactorTempUser.js
@@ -0,0 +1,25 @@
+const jwt = require('jsonwebtoken');
+
+const setTwoFactorTempUser = (req, _res, next) => {
+ if (req.user?.id || req.user?._id) {
+ return next();
+ }
+
+ const { tempToken } = req.body ?? {};
+ if (!tempToken) {
+ return next();
+ }
+
+ try {
+ const payload = jwt.verify(tempToken, process.env.JWT_SECRET);
+ if (payload?.userId) {
+ req.user = { id: payload.userId };
+ }
+ } catch {
+ return next();
+ }
+
+ return next();
+};
+
+module.exports = setTwoFactorTempUser;
diff --git a/api/server/middleware/spec/validateImages.spec.js b/api/server/middleware/spec/validateImages.spec.js
new file mode 100644
index 0000000..ebf5eaf
--- /dev/null
+++ b/api/server/middleware/spec/validateImages.spec.js
@@ -0,0 +1,474 @@
+const jwt = require('jsonwebtoken');
+const createValidateImageRequest = require('~/server/middleware/validateImageRequest');
+
+// Mock only isEnabled, keep getBasePath real so it reads process.env.DOMAIN_CLIENT
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ isEnabled: jest.fn(),
+}));
+
+const { isEnabled } = require('@librechat/api');
+
+describe('validateImageRequest middleware', () => {
+ let req, res, next, validateImageRequest;
+ const validObjectId = '65cfb246f7ecadb8b1e8036b';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ req = {
+ headers: {},
+ originalUrl: '',
+ };
+ res = {
+ status: jest.fn().mockReturnThis(),
+ send: jest.fn(),
+ };
+ next = jest.fn();
+ process.env.JWT_REFRESH_SECRET = 'test-secret';
+ process.env.OPENID_REUSE_TOKENS = 'false';
+ delete process.env.DOMAIN_CLIENT; // Clear for tests without basePath
+
+ // Default: OpenID token reuse disabled
+ isEnabled.mockReturnValue(false);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('Factory function', () => {
+ test('should return a pass-through middleware if secureImageLinks is false', async () => {
+ const middleware = createValidateImageRequest(false);
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should return validation middleware if secureImageLinks is true', async () => {
+ validateImageRequest = createValidateImageRequest(true);
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.send).toHaveBeenCalledWith('Unauthorized');
+ });
+ });
+
+ describe('Standard LibreChat token flow', () => {
+ beforeEach(() => {
+ validateImageRequest = createValidateImageRequest(true);
+ });
+
+ test('should return 401 if refresh token is not provided', async () => {
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.send).toHaveBeenCalledWith('Unauthorized');
+ });
+
+ test('should return 403 if refresh token is invalid', async () => {
+ req.headers.cookie = 'refreshToken=invalid-token';
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should return 403 if refresh token is expired', async () => {
+ const expiredToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${expiredToken}`;
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should call next() for valid image path', async () => {
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/example.jpg`;
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should return 403 for invalid image path', async () => {
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/example.jpg'; // Different ObjectId
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should allow agent avatar pattern for any valid ObjectId', async () => {
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-avatar-12345.png';
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should prevent file traversal attempts', async () => {
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+
+ const traversalAttempts = [
+ `/images/${validObjectId}/../../../etc/passwd`,
+ `/images/${validObjectId}/..%2F..%2F..%2Fetc%2Fpasswd`,
+ `/images/${validObjectId}/image.jpg/../../../etc/passwd`,
+ `/images/${validObjectId}/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd`,
+ ];
+
+ for (const attempt of traversalAttempts) {
+ req.originalUrl = attempt;
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ jest.clearAllMocks();
+ // Reset mocks for next iteration
+ res.status = jest.fn().mockReturnThis();
+ res.send = jest.fn();
+ }
+ });
+
+ test('should handle URL encoded characters in valid paths', async () => {
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/image%20with%20spaces.jpg`;
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+ });
+
+ describe('OpenID token flow', () => {
+ beforeEach(() => {
+ validateImageRequest = createValidateImageRequest(true);
+ // Enable OpenID token reuse
+ isEnabled.mockReturnValue(true);
+ process.env.OPENID_REUSE_TOKENS = 'true';
+ });
+
+ test('should return 403 if no OpenID user ID cookie when token_provider is openid', async () => {
+ req.headers.cookie = 'refreshToken=dummy-token; token_provider=openid';
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should validate JWT-signed user ID for OpenID flow', async () => {
+ const signedUserId = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
+ req.originalUrl = `/images/${validObjectId}/example.jpg`;
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should return 403 for invalid JWT-signed user ID', async () => {
+ req.headers.cookie =
+ 'refreshToken=dummy-token; token_provider=openid; openid_user_id=invalid-jwt';
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should return 403 for expired JWT-signed user ID', async () => {
+ const expiredSignedUserId = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${expiredSignedUserId}`;
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should validate image path against JWT-signed user ID', async () => {
+ const signedUserId = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ const differentObjectId = '65cfb246f7ecadb8b1e8036c';
+ req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
+ req.originalUrl = `/images/${differentObjectId}/example.jpg`;
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should allow agent avatars in OpenID flow', async () => {
+ const signedUserId = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
+ req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-avatar-12345.png';
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+ });
+
+ describe('Security edge cases', () => {
+ let validToken;
+
+ beforeEach(() => {
+ validateImageRequest = createValidateImageRequest(true);
+ validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ });
+
+ test('should handle very long image filenames', async () => {
+ const longFilename = 'a'.repeat(1000) + '.jpg';
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/${longFilename}`;
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle URLs with maximum practical length', async () => {
+ // Most browsers support URLs up to ~2000 characters
+ const longFilename = 'x'.repeat(1900) + '.jpg';
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/${longFilename}`;
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should accept URLs just under the 2048 limit', async () => {
+ // Create a URL exactly 2047 characters long
+ const baseLength = `/images/${validObjectId}/`.length + '.jpg'.length;
+ const filenameLength = 2047 - baseLength;
+ const filename = 'a'.repeat(filenameLength) + '.jpg';
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/${filename}`;
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle malformed URL encoding gracefully', async () => {
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/test%ZZinvalid.jpg`;
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should reject URLs with null bytes', async () => {
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/test\x00.jpg`;
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should handle URLs with repeated slashes', async () => {
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}//test.jpg`;
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should reject extremely long URLs as potential DoS', async () => {
+ // Create a URL longer than 2048 characters
+ const baseLength = `/images/${validObjectId}/`.length + '.jpg'.length;
+ const filenameLength = 2049 - baseLength; // Ensure total length exceeds 2048
+ const extremelyLongFilename = 'x'.repeat(filenameLength) + '.jpg';
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/${extremelyLongFilename}`;
+ // Verify our test URL is actually too long
+ expect(req.originalUrl.length).toBeGreaterThan(2048);
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+ });
+
+ describe('basePath functionality', () => {
+ let originalDomainClient;
+
+ beforeEach(() => {
+ originalDomainClient = process.env.DOMAIN_CLIENT;
+ });
+
+ afterEach(() => {
+ process.env.DOMAIN_CLIENT = originalDomainClient;
+ });
+
+ test('should validate image paths with base path', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should validate agent avatar paths with base path', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/librechat/images/${validObjectId}/agent-avatar.png`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should reject image paths without base path when DOMAIN_CLIENT is set', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/test.jpg`;
+
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should handle empty base path (root deployment)', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/test.jpg`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle missing DOMAIN_CLIENT', async () => {
+ delete process.env.DOMAIN_CLIENT;
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/test.jpg`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle nested subdirectories in base path', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/apps/librechat/images/${validObjectId}/test.jpg`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should prevent path traversal with base path', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/librechat/images/${validObjectId}/../../../etc/passwd`;
+
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should handle URLs with query parameters and base path', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/librechat/images/${validObjectId}/test.jpg?version=1`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle URLs with fragments and base path', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/librechat/images/${validObjectId}/test.jpg#section`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle HTTPS URLs with base path', async () => {
+ process.env.DOMAIN_CLIENT = 'https://example.com/librechat';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle invalid DOMAIN_CLIENT gracefully', async () => {
+ process.env.DOMAIN_CLIENT = 'not-a-valid-url';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/test.jpg`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle OpenID flow with base path', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
+ process.env.OPENID_REUSE_TOKENS = 'true';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}; token_provider=openid; openid_user_id=${validToken}`;
+ req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/api/server/middleware/uaParser.js b/api/server/middleware/uaParser.js
new file mode 100644
index 0000000..d7d38f1
--- /dev/null
+++ b/api/server/middleware/uaParser.js
@@ -0,0 +1,32 @@
+const uap = require('ua-parser-js');
+const { ViolationTypes } = require('librechat-data-provider');
+const { handleError } = require('@librechat/api');
+const { logViolation } = require('../../cache');
+
+/**
+ * Middleware to parse User-Agent header and check if it's from a recognized browser.
+ * If the User-Agent is not recognized as a browser, logs a violation and sends an error response.
+ *
+ * @function
+ * @async
+ * @param {Object} req - Express request object.
+ * @param {Object} res - Express response object.
+ * @param {Function} next - Express next middleware function.
+ * @returns {void} Sends an error response if the User-Agent is not recognized as a browser.
+ *
+ * @example
+ * app.use(uaParser);
+ */
+async function uaParser(req, res, next) {
+ const { NON_BROWSER_VIOLATION_SCORE: score = 20 } = process.env;
+ const ua = uap(req.headers['user-agent']);
+
+ if (!ua.browser.name) {
+ const type = ViolationTypes.NON_BROWSER;
+ await logViolation(req, res, type, { type }, score);
+ return handleError(res, { message: 'Illegal request' });
+ }
+ next();
+}
+
+module.exports = uaParser;
diff --git a/api/server/middleware/validate/convoAccess.js b/api/server/middleware/validate/convoAccess.js
new file mode 100644
index 0000000..ef1eea8
--- /dev/null
+++ b/api/server/middleware/validate/convoAccess.js
@@ -0,0 +1,82 @@
+const { isEnabled } = require('@librechat/api');
+const { Constants, ViolationTypes, Time } = require('librechat-data-provider');
+const denyRequest = require('~/server/middleware/denyRequest');
+const { logViolation, getLogStores } = require('~/cache');
+const { searchConversation } = require('~/models');
+
+const { USE_REDIS, CONVO_ACCESS_VIOLATION_SCORE: score = 0 } = process.env ?? {};
+
+/**
+ * Helper function to get conversationId from different request body structures.
+ * @param {Object} body - The request body.
+ * @returns {string|undefined} The conversationId.
+ */
+const getConversationId = (body) => {
+ return body.conversationId ?? body.arg?.conversationId;
+};
+
+/**
+ * Middleware to validate user's authorization for a conversation.
+ *
+ * This middleware checks if a user has the right to access a specific conversation.
+ * If the user doesn't have access, an error is returned. If the conversation doesn't exist,
+ * a not found error is returned. If the access is valid, the middleware allows the request to proceed.
+ * If the `cache` store is not available, the middleware will skip its logic.
+ *
+ * @function
+ * @param {ServerRequest} req - Express request object containing user information.
+ * @param {Express.Response} res - Express response object.
+ * @param {function} next - Express next middleware function.
+ * @throws {Error} Throws an error if the user doesn't have access to the conversation.
+ */
+const validateConvoAccess = async (req, res, next) => {
+ const namespace = ViolationTypes.CONVO_ACCESS;
+ const cache = getLogStores(namespace);
+
+ const conversationId = getConversationId(req.body);
+
+ if (!conversationId || conversationId === Constants.NEW_CONVO) {
+ return next();
+ }
+
+ const userId = req.user?.id ?? req.user?._id ?? '';
+ const type = ViolationTypes.CONVO_ACCESS;
+ const key = `${isEnabled(USE_REDIS) ? namespace : ''}:${userId}:${conversationId}`;
+
+ try {
+ if (cache) {
+ const cachedAccess = await cache.get(key);
+ if (cachedAccess === 'authorized') {
+ return next();
+ }
+ }
+
+ const conversation = await searchConversation(conversationId);
+
+ if (!conversation) {
+ return next();
+ }
+
+ if (conversation.user !== userId) {
+ const errorMessage = {
+ type,
+ error: 'User not authorized for this conversation',
+ };
+
+ if (cache) {
+ await logViolation(req, res, type, errorMessage, score);
+ }
+ return await denyRequest(req, res, errorMessage);
+ }
+
+ if (cache) {
+ await cache.set(key, 'authorized', Time.TEN_MINUTES);
+ }
+ next();
+ } catch (error) {
+ console.error('Error validating conversation access:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+};
+
+module.exports = validateConvoAccess;
diff --git a/api/server/middleware/validate/index.js b/api/server/middleware/validate/index.js
new file mode 100644
index 0000000..ce476e7
--- /dev/null
+++ b/api/server/middleware/validate/index.js
@@ -0,0 +1,4 @@
+const validateConvoAccess = require('./convoAccess');
+module.exports = {
+ validateConvoAccess,
+};
diff --git a/api/server/middleware/validateImageRequest.js b/api/server/middleware/validateImageRequest.js
new file mode 100644
index 0000000..4d954d0
--- /dev/null
+++ b/api/server/middleware/validateImageRequest.js
@@ -0,0 +1,162 @@
+const cookies = require('cookie');
+const jwt = require('jsonwebtoken');
+const { logger } = require('@librechat/data-schemas');
+const { isEnabled, getBasePath } = require('@librechat/api');
+
+const OBJECT_ID_LENGTH = 24;
+const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i;
+
+/**
+ * Validates if a string is a valid MongoDB ObjectId
+ * @param {string} id - String to validate
+ * @returns {boolean} - Whether string is a valid ObjectId format
+ */
+function isValidObjectId(id) {
+ if (typeof id !== 'string') {
+ return false;
+ }
+ if (id.length !== OBJECT_ID_LENGTH) {
+ return false;
+ }
+ return OBJECT_ID_PATTERN.test(id);
+}
+
+/**
+ * Validates a LibreChat refresh token
+ * @param {string} refreshToken - The refresh token to validate
+ * @returns {{valid: boolean, userId?: string, error?: string}} - Validation result
+ */
+function validateToken(refreshToken) {
+ try {
+ const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
+
+ if (!isValidObjectId(payload.id)) {
+ return { valid: false, error: 'Invalid User ID' };
+ }
+
+ const currentTimeInSeconds = Math.floor(Date.now() / 1000);
+ if (payload.exp < currentTimeInSeconds) {
+ return { valid: false, error: 'Refresh token expired' };
+ }
+
+ return { valid: true, userId: payload.id };
+ } catch (err) {
+ logger.warn('[validateToken]', err);
+ return { valid: false, error: 'Invalid token' };
+ }
+}
+
+/**
+ * Factory to create the `validateImageRequest` middleware with configured secureImageLinks
+ * @param {boolean} [secureImageLinks] - Whether secure image links are enabled
+ */
+function createValidateImageRequest(secureImageLinks) {
+ if (!secureImageLinks) {
+ return (_req, _res, next) => next();
+ }
+ /**
+ * Middleware to validate image request.
+ * Supports both LibreChat refresh tokens and OpenID JWT tokens.
+ * Must be set by `secureImageLinks` via custom config file.
+ */
+ return async function validateImageRequest(req, res, next) {
+ try {
+ const cookieHeader = req.headers.cookie;
+ if (!cookieHeader) {
+ logger.warn('[validateImageRequest] No cookies provided');
+ return res.status(401).send('Unauthorized');
+ }
+
+ const parsedCookies = cookies.parse(cookieHeader);
+ const tokenProvider = parsedCookies.token_provider;
+ let userIdForPath;
+
+ if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
+ /** For OpenID users with OPENID_REUSE_TOKENS, use openid_user_id cookie */
+ const openidUserId = parsedCookies.openid_user_id;
+ if (!openidUserId) {
+ logger.warn('[validateImageRequest] No OpenID user ID cookie found');
+ return res.status(403).send('Access Denied');
+ }
+
+ const validationResult = validateToken(openidUserId);
+ if (!validationResult.valid) {
+ logger.warn(`[validateImageRequest] ${validationResult.error}`);
+ return res.status(403).send('Access Denied');
+ }
+ userIdForPath = validationResult.userId;
+ } else {
+ /**
+ * For non-OpenID users (or OpenID without REUSE_TOKENS), use refreshToken from cookies.
+ * These users authenticate via setAuthTokens() which stores refreshToken in cookies.
+ */
+ const refreshToken = parsedCookies.refreshToken;
+
+ if (!refreshToken) {
+ logger.warn('[validateImageRequest] Token not provided');
+ return res.status(401).send('Unauthorized');
+ }
+
+ const validationResult = validateToken(refreshToken);
+ if (!validationResult.valid) {
+ logger.warn(`[validateImageRequest] ${validationResult.error}`);
+ return res.status(403).send('Access Denied');
+ }
+ userIdForPath = validationResult.userId;
+ }
+
+ if (!userIdForPath) {
+ logger.warn('[validateImageRequest] No user ID available for path validation');
+ return res.status(403).send('Access Denied');
+ }
+
+ const MAX_URL_LENGTH = 2048;
+ if (req.originalUrl.length > MAX_URL_LENGTH) {
+ logger.warn('[validateImageRequest] URL too long');
+ return res.status(403).send('Access Denied');
+ }
+
+ if (req.originalUrl.includes('\x00')) {
+ logger.warn('[validateImageRequest] URL contains null byte');
+ return res.status(403).send('Access Denied');
+ }
+
+ let fullPath;
+ try {
+ fullPath = decodeURIComponent(req.originalUrl);
+ } catch {
+ logger.warn('[validateImageRequest] Invalid URL encoding');
+ return res.status(403).send('Access Denied');
+ }
+
+ const basePath = getBasePath();
+ const imagesPath = `${basePath}/images`;
+
+ const agentAvatarPattern = new RegExp(
+ `^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[a-f0-9]{24}/agent-[^/]*$`,
+ );
+ if (agentAvatarPattern.test(fullPath)) {
+ logger.debug('[validateImageRequest] Image request validated');
+ return next();
+ }
+
+ const escapedUserId = userIdForPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const pathPattern = new RegExp(
+ `^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${escapedUserId}/[^/]+$`,
+ );
+
+ if (pathPattern.test(fullPath)) {
+ logger.debug('[validateImageRequest] Image request validated');
+ next();
+ } else {
+ logger.warn('[validateImageRequest] Invalid image path');
+ res.status(403).send('Access Denied');
+ }
+ } catch (error) {
+ logger.error('[validateImageRequest] Error:', error);
+ res.status(500).send('Internal Server Error');
+ }
+ };
+}
+
+module.exports = createValidateImageRequest;
diff --git a/api/server/middleware/validateMessageReq.js b/api/server/middleware/validateMessageReq.js
new file mode 100644
index 0000000..9e20671
--- /dev/null
+++ b/api/server/middleware/validateMessageReq.js
@@ -0,0 +1,3 @@
+const { validateMessageReq } = require('./messageValidation');
+
+module.exports = validateMessageReq;
diff --git a/api/server/middleware/validateModel.js b/api/server/middleware/validateModel.js
new file mode 100644
index 0000000..71a931f
--- /dev/null
+++ b/api/server/middleware/validateModel.js
@@ -0,0 +1,68 @@
+const { handleError } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const { getModelsConfig } = require('~/server/controllers/ModelController');
+const { getEndpointsConfig } = require('~/server/services/Config');
+const { logViolation } = require('~/cache');
+
+const MAX_MODEL_STRING_LENGTH = 256;
+const MODEL_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.:/@+-]*$/;
+
+/**
+ * Validates the model of the request.
+ *
+ * @async
+ * @param {ServerRequest} req - The Express request object.
+ * @param {Express.Response} res - The Express response object.
+ * @param {Function} next - The Express next function.
+ */
+const validateModel = async (req, res, next) => {
+ const { endpoint } = req.body;
+ const rawModel = req.body.model;
+
+ if (!rawModel || typeof rawModel !== 'string') {
+ return handleError(res, { text: 'Model not provided' });
+ }
+
+ const model = rawModel.trim();
+ if (!model || model.length > MAX_MODEL_STRING_LENGTH || !MODEL_PATTERN.test(model)) {
+ return handleError(res, { text: 'Invalid model identifier' });
+ }
+
+ req.body.model = model;
+
+ const endpointsConfig = await getEndpointsConfig(req);
+ const endpointConfig = endpointsConfig?.[endpoint];
+
+ if (endpointConfig?.userProvide) {
+ return next();
+ }
+
+ const modelsConfig = await getModelsConfig(req);
+
+ if (!modelsConfig) {
+ return handleError(res, { text: 'Models not loaded' });
+ }
+
+ const availableModels = modelsConfig[endpoint];
+ if (!availableModels) {
+ return handleError(res, { text: 'Endpoint models not loaded' });
+ }
+
+ let validModel = !!availableModels.find((availableModel) => availableModel === model);
+
+ if (validModel) {
+ return next();
+ }
+
+ const { ILLEGAL_MODEL_REQ_SCORE: score = 1 } = process.env ?? {};
+
+ const type = ViolationTypes.ILLEGAL_MODEL_REQUEST;
+ const errorMessage = {
+ type,
+ };
+
+ await logViolation(req, res, type, errorMessage, score);
+ return handleError(res, { text: 'Illegal model request' });
+};
+
+module.exports = validateModel;
diff --git a/api/server/middleware/validatePasswordReset.js b/api/server/middleware/validatePasswordReset.js
new file mode 100644
index 0000000..b4acd66
--- /dev/null
+++ b/api/server/middleware/validatePasswordReset.js
@@ -0,0 +1,13 @@
+const { isEnabled } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+
+function validatePasswordReset(req, res, next) {
+ if (isEnabled(process.env.ALLOW_PASSWORD_RESET)) {
+ next();
+ } else {
+ logger.warn(`Password reset attempt while not allowed. IP: ${req.ip}`);
+ res.status(403).send('Password reset is not allowed.');
+ }
+}
+
+module.exports = validatePasswordReset;
diff --git a/api/server/middleware/validateRegistration.js b/api/server/middleware/validateRegistration.js
new file mode 100644
index 0000000..79b5cac
--- /dev/null
+++ b/api/server/middleware/validateRegistration.js
@@ -0,0 +1,17 @@
+const { isEnabled } = require('@librechat/api');
+
+function validateRegistration(req, res, next) {
+ if (req.invite) {
+ return next();
+ }
+
+ if (isEnabled(process.env.ALLOW_REGISTRATION)) {
+ next();
+ } else {
+ return res.status(403).json({
+ message: 'Registration is not allowed.',
+ });
+ }
+}
+
+module.exports = validateRegistration;
diff --git a/api/server/routes/__test-utils__/convos-route-mocks.js b/api/server/routes/__test-utils__/convos-route-mocks.js
new file mode 100644
index 0000000..769d2b6
--- /dev/null
+++ b/api/server/routes/__test-utils__/convos-route-mocks.js
@@ -0,0 +1,102 @@
+module.exports = {
+ agents: () => ({ sleep: jest.fn() }),
+
+ api: (overrides = {}) => ({
+ isEnabled: jest.fn(),
+ resolveImportMaxFileSize: jest.fn(() => 262144000),
+ createAxiosInstance: jest.fn(() => ({
+ get: jest.fn(),
+ post: jest.fn(),
+ put: jest.fn(),
+ delete: jest.fn(),
+ })),
+ logAxiosError: jest.fn(),
+ restoreTenantContextFromReq: jest.fn((req, res, next) => next()),
+ deleteConvoSharedLinksWithCleanup: jest.fn(),
+ deleteAllSharedLinksWithCleanup: jest.fn(),
+ deleteAgentCheckpoints: jest.fn(),
+ ...overrides,
+ }),
+
+ dataSchemas: () => ({
+ logger: {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+ createModels: jest.fn(() => ({
+ User: {},
+ Conversation: {},
+ Message: {},
+ SharedLink: {},
+ })),
+ }),
+
+ dataProvider: (overrides = {}) => ({
+ CacheKeys: { GEN_TITLE: 'GEN_TITLE' },
+ EModelEndpoint: {
+ azureAssistants: 'azureAssistants',
+ assistants: 'assistants',
+ },
+ ...overrides,
+ }),
+
+ conversationModel: () => ({
+ getConvosByCursor: jest.fn(),
+ getConvo: jest.fn(),
+ deleteConvos: jest.fn(),
+ saveConvo: jest.fn(),
+ }),
+
+ toolCallModel: () => ({ deleteToolCalls: jest.fn() }),
+
+ sharedModels: () => ({
+ getConvosByCursor: jest.fn(),
+ getConvo: jest.fn(),
+ deleteConvos: jest.fn(),
+ saveConvo: jest.fn(),
+ deleteAllSharedLinks: jest.fn(),
+ deleteConvoSharedLink: jest.fn(),
+ deleteToolCalls: jest.fn(),
+ }),
+
+ requireJwtAuth: () => (req, res, next) => next(),
+
+ middlewarePassthrough: () => ({
+ createImportLimiters: jest.fn(() => ({
+ importIpLimiter: (req, res, next) => next(),
+ importUserLimiter: (req, res, next) => next(),
+ })),
+ createForkLimiters: jest.fn(() => ({
+ forkIpLimiter: (req, res, next) => next(),
+ forkUserLimiter: (req, res, next) => next(),
+ })),
+ configMiddleware: (req, res, next) => next(),
+ validateConvoAccess: (req, res, next) => next(),
+ }),
+
+ forkUtils: () => ({
+ forkConversation: jest.fn(),
+ duplicateConversation: jest.fn(),
+ }),
+
+ importUtils: () => ({ importConversations: jest.fn() }),
+
+ logStores: () => jest.fn(),
+
+ multerSetup: () => ({
+ storage: {},
+ importFileFilter: jest.fn(),
+ }),
+
+ multerLib: () =>
+ jest.fn(() => ({
+ single: jest.fn(() => (req, res, next) => {
+ req.file = { path: '/tmp/test-file.json' };
+ next();
+ }),
+ })),
+
+ assistantEndpoint: () => ({ initializeClient: jest.fn() }),
+};
diff --git a/api/server/routes/__tests__/config.rum.spec.js b/api/server/routes/__tests__/config.rum.spec.js
new file mode 100644
index 0000000..2b0a5b2
--- /dev/null
+++ b/api/server/routes/__tests__/config.rum.spec.js
@@ -0,0 +1,194 @@
+jest.mock('~/cache/getLogStores');
+
+const mockGetAppConfig = jest.fn();
+jest.mock('~/server/services/Config/app', () => ({
+ getAppConfig: (...args) => mockGetAppConfig(...args),
+}));
+
+jest.mock('~/server/services/Config/ldap', () => ({
+ getLdapConfig: jest.fn(() => null),
+}));
+
+jest.mock('~/server/middleware/roles/capabilities', () => ({
+ hasCapability: jest.fn(),
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ getTenantId: jest.fn(() => undefined),
+}));
+
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ getCloudFrontConfig: jest.fn(() => null),
+}));
+
+const request = require('supertest');
+const express = require('express');
+const configRoute = require('../config');
+
+function createApp(user) {
+ const app = express();
+ app.disable('x-powered-by');
+ if (user) {
+ app.use((req, _res, next) => {
+ req.user = user;
+ next();
+ });
+ }
+ app.use('/api/config', configRoute);
+ return app;
+}
+
+const baseAppConfig = {
+ registration: { socialLogins: ['google', 'github'] },
+ interfaceConfig: { modelSelect: true },
+ turnstileConfig: { siteKey: 'test-key' },
+ modelSpecs: { list: [{ name: 'test-spec' }] },
+};
+
+const mockUser = {
+ id: 'user123',
+ role: 'USER',
+ tenantId: undefined,
+};
+
+afterEach(() => {
+ jest.resetAllMocks();
+ delete process.env.RUM_ENABLED;
+ delete process.env.RUM_PROVIDER;
+ delete process.env.RUM_URL;
+ delete process.env.RUM_PROXY_TARGET_URL;
+ delete process.env.RUM_SERVICE_NAME;
+ delete process.env.RUM_AUTH_MODE;
+ delete process.env.RUM_PUBLIC_TOKEN;
+ delete process.env.RUM_TRACE_PROPAGATION_TARGETS;
+ delete process.env.RUM_CONSOLE_CAPTURE;
+ delete process.env.RUM_DISABLE_REPLAY;
+ delete process.env.RUM_ADVANCED_NETWORK_CAPTURE;
+ delete process.env.RUM_SAMPLE_RATE;
+ delete process.env.RUM_ENVIRONMENT;
+});
+
+describe('GET /api/config RUM config', () => {
+ it('includes public-token RUM config when enabled with valid env', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ process.env.RUM_ENABLED = 'true';
+ process.env.RUM_URL = 'https://rum.example.com';
+ process.env.RUM_PUBLIC_TOKEN = 'public-token';
+ process.env.RUM_TRACE_PROPAGATION_TARGETS =
+ 'https://app.example.com,https://api.openai.com,*,http://api.example.com';
+ process.env.RUM_SAMPLE_RATE = '0.25';
+ process.env.RUM_ENVIRONMENT = 'test';
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.rum).toEqual({
+ provider: 'hyperdx',
+ enabled: true,
+ url: 'https://rum.example.com',
+ serviceName: 'librechat-web',
+ authMode: 'publicToken',
+ publicToken: 'public-token',
+ tracePropagationTargets: ['https://app.example.com', 'https://api.openai.com'],
+ consoleCapture: false,
+ disableReplay: true,
+ advancedNetworkCapture: false,
+ sampleRate: 0.25,
+ environment: 'test',
+ });
+ });
+
+ it('omits malformed RUM config', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ process.env.RUM_ENABLED = 'true';
+ process.env.RUM_URL = 'not a url';
+ process.env.RUM_PUBLIC_TOKEN = 'public-token';
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body).not.toHaveProperty('rum');
+ });
+
+ it('includes proxy RUM config when enabled with valid env', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ process.env.RUM_ENABLED = 'true';
+ process.env.RUM_AUTH_MODE = 'proxy';
+ process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318';
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.rum).toEqual({
+ provider: 'hyperdx',
+ enabled: true,
+ url: '/api/rum',
+ serviceName: 'librechat-web',
+ authMode: 'proxy',
+ consoleCapture: false,
+ disableReplay: true,
+ advancedNetworkCapture: false,
+ });
+ });
+
+ it('omits proxy RUM config without a target collector URL', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ process.env.RUM_ENABLED = 'true';
+ process.env.RUM_AUTH_MODE = 'proxy';
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body).not.toHaveProperty('rum');
+ });
+
+ it('omits RUM config when the URL contains credentials', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ process.env.RUM_ENABLED = 'true';
+ process.env.RUM_URL = 'https://user:password@rum.example.com';
+ process.env.RUM_PUBLIC_TOKEN = 'public-token';
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body).not.toHaveProperty('rum');
+ });
+
+ it('allows IPv6 localhost HTTP RUM URLs in public-token mode', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ process.env.RUM_ENABLED = 'true';
+ process.env.RUM_URL = 'http://[::1]:4318';
+ process.env.RUM_PUBLIC_TOKEN = 'public-token';
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.rum?.url).toBe('http://[::1]:4318');
+ });
+
+ it('omits unsupported userJwt RUM config for authenticated users', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ process.env.RUM_ENABLED = 'true';
+ process.env.RUM_URL = 'https://rum.example.com';
+ process.env.RUM_AUTH_MODE = 'userJwt';
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body).not.toHaveProperty('rum');
+ });
+
+ it('omits unsupported userJwt RUM config for unauthenticated users', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ process.env.RUM_ENABLED = 'true';
+ process.env.RUM_URL = 'https://rum.example.com';
+ process.env.RUM_AUTH_MODE = 'userJwt';
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body).not.toHaveProperty('rum');
+ });
+});
diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js
new file mode 100644
index 0000000..369f968
--- /dev/null
+++ b/api/server/routes/__tests__/config.spec.js
@@ -0,0 +1,624 @@
+jest.mock('~/cache/getLogStores');
+
+const mockGetAppConfig = jest.fn();
+jest.mock('~/server/services/Config/app', () => ({
+ getAppConfig: (...args) => mockGetAppConfig(...args),
+}));
+
+jest.mock('~/server/services/Config/ldap', () => ({
+ getLdapConfig: jest.fn(() => null),
+}));
+
+const mockHasCapability = jest.fn();
+jest.mock('~/server/middleware/roles/capabilities', () => ({
+ hasCapability: (...args) => mockHasCapability(...args),
+}));
+
+const mockGetTenantId = jest.fn(() => undefined);
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ getTenantId: (...args) => mockGetTenantId(...args),
+}));
+
+const mockGetCloudFrontConfig = jest.fn(() => null);
+const mockResolveBuildInfo = jest.fn(() => ({
+ commit: null,
+ commitShort: null,
+ branch: null,
+ buildDate: null,
+}));
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ getCloudFrontConfig: (...args) => mockGetCloudFrontConfig(...args),
+ resolveBuildInfo: (...args) => mockResolveBuildInfo(...args),
+}));
+
+const request = require('supertest');
+const express = require('express');
+const configRoute = require('../config');
+
+function createApp(user) {
+ const app = express();
+ app.disable('x-powered-by');
+ if (user) {
+ app.use((req, _res, next) => {
+ req.user = user;
+ next();
+ });
+ }
+ app.use('/api/config', configRoute);
+ return app;
+}
+
+const baseAppConfig = {
+ registration: { socialLogins: ['google', 'github'] },
+ interfaceConfig: {
+ privacyPolicy: { externalUrl: 'https://example.com/privacy' },
+ termsOfService: { externalUrl: 'https://example.com/tos' },
+ modelSelect: true,
+ },
+ turnstileConfig: { siteKey: 'test-key' },
+ modelSpecs: { list: [{ name: 'test-spec' }] },
+ webSearch: { searchProvider: 'tavily' },
+};
+
+const mockUser = {
+ id: 'user123',
+ role: 'USER',
+ tenantId: undefined,
+};
+
+afterEach(() => {
+ jest.resetAllMocks();
+ mockResolveBuildInfo.mockReturnValue({
+ commit: null,
+ commitShort: null,
+ branch: null,
+ buildDate: null,
+ });
+ delete process.env.APP_TITLE;
+ delete process.env.CHECK_BALANCE;
+ delete process.env.START_BALANCE;
+ delete process.env.SANDPACK_BUNDLER_URL;
+ delete process.env.SANDPACK_STATIC_BUNDLER_URL;
+ delete process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES;
+ delete process.env.ALLOW_REGISTRATION;
+ delete process.env.ALLOW_SOCIAL_LOGIN;
+ delete process.env.ALLOW_PASSWORD_RESET;
+ delete process.env.DOMAIN_SERVER;
+ delete process.env.GOOGLE_CLIENT_ID;
+ delete process.env.GOOGLE_CLIENT_SECRET;
+ delete process.env.OPENID_CLIENT_ID;
+ delete process.env.OPENID_CLIENT_SECRET;
+ delete process.env.OPENID_ISSUER;
+ delete process.env.OPENID_SESSION_SECRET;
+ delete process.env.GITHUB_CLIENT_ID;
+ delete process.env.GITHUB_CLIENT_SECRET;
+ delete process.env.DISCORD_CLIENT_ID;
+ delete process.env.DISCORD_CLIENT_SECRET;
+ delete process.env.SAML_ENTRY_POINT;
+ delete process.env.SAML_ISSUER;
+ delete process.env.SAML_CERT;
+ delete process.env.SAML_SESSION_SECRET;
+ delete process.env.ALLOW_ACCOUNT_DELETION;
+ delete process.env.ANALYTICS_GTM_ID;
+ delete process.env.CUSTOM_FOOTER;
+ delete process.env.HELP_AND_FAQ_URL;
+});
+
+describe('GET /api/config', () => {
+ describe('unauthenticated (no req.user)', () => {
+ it('should call getAppConfig with baseOnly when no tenant context', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ mockGetTenantId.mockReturnValue(undefined);
+ const app = createApp(null);
+
+ await request(app).get('/api/config');
+
+ expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true });
+ });
+
+ it('should call getAppConfig with tenantId when tenant context is present', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ mockGetTenantId.mockReturnValue('tenant-abc');
+ const app = createApp(null);
+
+ await request(app).get('/api/config');
+
+ expect(mockGetAppConfig).toHaveBeenCalledWith({ tenantId: 'tenant-abc' });
+ });
+
+ it('should map tenant-scoped config fields in unauthenticated response', async () => {
+ const tenantConfig = {
+ ...baseAppConfig,
+ registration: { socialLogins: ['saml'] },
+ turnstileConfig: { siteKey: 'tenant-key' },
+ };
+ mockGetAppConfig.mockResolvedValue(tenantConfig);
+ mockGetTenantId.mockReturnValue('tenant-abc');
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.socialLogins).toEqual(['saml']);
+ expect(response.body.turnstile).toEqual({ siteKey: 'tenant-key' });
+ expect(response.body).not.toHaveProperty('modelSpecs');
+ });
+
+ it('should return minimal payload without authenticated-only fields', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body).not.toHaveProperty('modelSpecs');
+ expect(response.body).not.toHaveProperty('balance');
+ expect(response.body).not.toHaveProperty('webSearch');
+ expect(response.body).not.toHaveProperty('bundlerURL');
+ expect(response.body).not.toHaveProperty('staticBundlerURL');
+ expect(response.body).not.toHaveProperty('sharePointFilePickerEnabled');
+ expect(response.body).not.toHaveProperty('sharePointBaseUrl');
+ expect(response.body).not.toHaveProperty('sharePointPickerGraphScope');
+ expect(response.body).not.toHaveProperty('sharePointPickerSharePointScope');
+ expect(response.body).not.toHaveProperty('conversationImportMaxFileSize');
+ });
+
+ it('should strip authenticated-only informational fields from unauthenticated response (#12688)', async () => {
+ process.env.ANALYTICS_GTM_ID = 'GTM-XYZ';
+ process.env.CUSTOM_FOOTER = 'internal footer text';
+ process.env.HELP_AND_FAQ_URL = 'https://internal.example.com/faq';
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body).not.toHaveProperty('showBirthdayIcon');
+ expect(response.body).not.toHaveProperty('helpAndFaqURL');
+ expect(response.body).not.toHaveProperty('sharedLinksEnabled');
+ expect(response.body).not.toHaveProperty('publicSharedLinksEnabled');
+ expect(response.body).not.toHaveProperty('analyticsGtmId');
+ expect(response.body).not.toHaveProperty('openidReuseTokens');
+ expect(response.body).not.toHaveProperty('allowAccountDeletion');
+ expect(response.body).not.toHaveProperty('customFooter');
+ });
+
+ it('should not include share-only fields when share context is requested', async () => {
+ process.env.ANALYTICS_GTM_ID = 'GTM-XYZ';
+ process.env.CUSTOM_FOOTER = 'public footer text';
+ process.env.HELP_AND_FAQ_URL = 'https://internal.example.com/faq';
+ process.env.SANDPACK_BUNDLER_URL = 'https://bundler.test';
+ process.env.SANDPACK_STATIC_BUNDLER_URL = 'https://static-bundler.test';
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config?context=share');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body).not.toHaveProperty('analyticsGtmId');
+ expect(response.body).not.toHaveProperty('customFooter');
+ expect(response.body).not.toHaveProperty('bundlerURL');
+ expect(response.body).not.toHaveProperty('staticBundlerURL');
+ expect(response.body).not.toHaveProperty('helpAndFaqURL');
+ expect(response.body).not.toHaveProperty('allowAccountDeletion');
+ });
+
+ it('should include socialLogins and turnstile from base config', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.socialLogins).toEqual(['google', 'github']);
+ expect(response.body.turnstile).toEqual({ siteKey: 'test-key' });
+ });
+
+ it('should include only privacyPolicy and termsOfService from interface config', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.interface).toEqual({
+ privacyPolicy: { externalUrl: 'https://example.com/privacy' },
+ termsOfService: { externalUrl: 'https://example.com/tos' },
+ });
+ expect(response.body.interface).not.toHaveProperty('modelSelect');
+ });
+
+ it('should not include interface if no privacyPolicy or termsOfService', async () => {
+ mockGetAppConfig.mockResolvedValue({
+ ...baseAppConfig,
+ interfaceConfig: { modelSelect: true },
+ });
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body).not.toHaveProperty('interface');
+ });
+
+ it('should include shared env var fields', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ process.env.APP_TITLE = 'Test App';
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.appTitle).toBe('Test App');
+ expect(response.body).toHaveProperty('emailLoginEnabled');
+ expect(response.body).toHaveProperty('serverDomain');
+ });
+
+ it('should omit CloudFront cookie refresh from unauthenticated response (#12688)', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ mockGetCloudFrontConfig.mockReturnValue({
+ domain: 'https://cdn.example.com',
+ imageSigning: 'cookies',
+ cookieDomain: '.example.com',
+ privateKey: 'test-private-key',
+ keyPairId: 'K123ABC',
+ });
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body).not.toHaveProperty('cloudFront');
+ });
+
+ it('should return 500 when getAppConfig throws', async () => {
+ mockGetAppConfig.mockRejectedValue(new Error('Config service failure'));
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.statusCode).toBe(500);
+ expect(response.body).toHaveProperty('error');
+ });
+ });
+
+ describe('authenticated (req.user exists)', () => {
+ it('should call getAppConfig with role, userId, and tenantId', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ mockGetTenantId.mockReturnValue('fallback-tenant');
+ const app = createApp(mockUser);
+
+ await request(app).get('/api/config');
+
+ expect(mockGetAppConfig).toHaveBeenCalledWith({
+ role: 'USER',
+ userId: 'user123',
+ idOnTheSource: undefined,
+ tenantId: 'fallback-tenant',
+ });
+ });
+
+ it('should prefer user tenantId over getTenantId fallback', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ mockGetTenantId.mockReturnValue('fallback-tenant');
+ const app = createApp({ ...mockUser, tenantId: 'user-tenant' });
+
+ await request(app).get('/api/config');
+
+ expect(mockGetAppConfig).toHaveBeenCalledWith({
+ role: 'USER',
+ userId: 'user123',
+ idOnTheSource: undefined,
+ tenantId: 'user-tenant',
+ });
+ });
+
+ it('should include modelSpecs, balance, and webSearch', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ process.env.CHECK_BALANCE = 'true';
+ process.env.START_BALANCE = '10000';
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.modelSpecs).toEqual({ list: [{ name: 'test-spec' }] });
+ expect(response.body.balance).toEqual({ enabled: true, startBalance: 10000 });
+ expect(response.body.webSearch).toEqual({ searchProvider: 'tavily' });
+ });
+
+ it('should strip private prompt fields from model spec presets', async () => {
+ mockGetAppConfig.mockResolvedValue({
+ ...baseAppConfig,
+ modelSpecs: {
+ enforce: false,
+ prioritize: true,
+ list: [
+ {
+ name: 'guarded-spec',
+ label: 'Guarded Spec',
+ skills: ['private-skill'],
+ preset: {
+ endpoint: 'openAI',
+ model: 'gpt-4o',
+ promptPrefix: 'private prompt prefix',
+ instructions: 'private assistant instructions',
+ additional_instructions: 'private additional instructions',
+ system: 'private bedrock system',
+ context: 'private context',
+ examples: [{ input: { content: 'a' }, output: { content: 'b' } }],
+ greeting: 'Hello',
+ },
+ },
+ ],
+ },
+ });
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.modelSpecs.list[0].preset).toEqual({
+ endpoint: 'openAI',
+ model: 'gpt-4o',
+ greeting: 'Hello',
+ });
+ expect(response.body.modelSpecs.list[0]).not.toHaveProperty('skills');
+ });
+
+ it('should include full interface config', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.interface).toEqual(baseAppConfig.interfaceConfig);
+ });
+
+ it('should include authenticated-only env var fields', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ process.env.SANDPACK_BUNDLER_URL = 'https://bundler.test';
+ process.env.SANDPACK_STATIC_BUNDLER_URL = 'https://static-bundler.test';
+ process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES = '5000000';
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.bundlerURL).toBe('https://bundler.test');
+ expect(response.body.staticBundlerURL).toBe('https://static-bundler.test');
+ expect(response.body.conversationImportMaxFileSize).toBe(5000000);
+ });
+
+ it('should include post-login informational fields', async () => {
+ process.env.ANALYTICS_GTM_ID = 'GTM-XYZ';
+ process.env.CUSTOM_FOOTER = 'authenticated footer text';
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body).toHaveProperty('helpAndFaqURL');
+ expect(response.body).toHaveProperty('sharedLinksEnabled');
+ expect(response.body).toHaveProperty('publicSharedLinksEnabled');
+ expect(response.body).toHaveProperty('showBirthdayIcon');
+ expect(response.body).toHaveProperty('openidReuseTokens');
+ expect(response.body.analyticsGtmId).toBe('GTM-XYZ');
+ expect(response.body.customFooter).toBe('authenticated footer text');
+ });
+
+ it('should advertise CloudFront cookie refresh when signed-cookie mode is active', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ mockGetCloudFrontConfig.mockReturnValue({
+ domain: 'https://cdn.example.com',
+ imageSigning: 'cookies',
+ cookieDomain: '.example.com',
+ privateKey: 'test-private-key',
+ keyPairId: 'K123ABC',
+ });
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.cloudFront).toEqual({
+ cookieRefresh: {
+ endpoint: '/api/auth/cloudfront/refresh',
+ domain: 'https://cdn.example.com',
+ },
+ });
+ });
+
+ it('should omit CloudFront cookie refresh when signed-cookie mode is inactive', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ mockGetCloudFrontConfig.mockReturnValue({
+ domain: 'https://cdn.example.com',
+ imageSigning: 'url',
+ });
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body).not.toHaveProperty('cloudFront');
+ });
+
+ it('should omit CloudFront cookie refresh when cookie mode cannot mint cookies', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ mockGetCloudFrontConfig.mockReturnValue({
+ domain: 'https://cdn.example.com',
+ imageSigning: 'cookies',
+ });
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body).not.toHaveProperty('cloudFront');
+ });
+
+ it('should merge per-user balance override into config', async () => {
+ mockGetAppConfig.mockResolvedValue({
+ ...baseAppConfig,
+ balance: {
+ enabled: true,
+ startBalance: 50000,
+ },
+ });
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.balance).toEqual(
+ expect.objectContaining({
+ enabled: true,
+ startBalance: 50000,
+ }),
+ );
+ });
+
+ it('should set allowAccountDeletion to false for authenticated users without ACCESS_ADMIN', async () => {
+ process.env.ALLOW_ACCOUNT_DELETION = 'false';
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ mockHasCapability.mockResolvedValue(false);
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.allowAccountDeletion).toBe(false);
+ expect(mockHasCapability).toHaveBeenCalled();
+ });
+
+ it('should override allowAccountDeletion to true for users with ACCESS_ADMIN capability', async () => {
+ process.env.ALLOW_ACCOUNT_DELETION = 'false';
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ mockHasCapability.mockResolvedValue(true);
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.allowAccountDeletion).toBe(true);
+ expect(mockHasCapability).toHaveBeenCalled();
+ });
+
+ it('should not call hasCapability when allowAccountDeletion is already true', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.allowAccountDeletion).toBe(true);
+ expect(mockHasCapability).not.toHaveBeenCalled();
+ });
+
+ it('should return 500 when getAppConfig throws', async () => {
+ mockGetAppConfig.mockRejectedValue(new Error('Config service failure'));
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.statusCode).toBe(500);
+ expect(response.body).toHaveProperty('error');
+ });
+ });
+
+ describe('buildInfo payload', () => {
+ const populatedBuildInfo = {
+ commit: 'abcdef1234567890abcdef1234567890abcdef12',
+ commitShort: 'abcdef1',
+ branch: 'dev',
+ buildDate: '2026-04-20T12:00:00Z',
+ };
+
+ it('includes buildInfo in authenticated response when interface flag is not explicitly disabled', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ mockResolveBuildInfo.mockReturnValue(populatedBuildInfo);
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.buildInfo).toEqual(populatedBuildInfo);
+ });
+
+ it('omits buildInfo when interface.buildInfo is false', async () => {
+ mockGetAppConfig.mockResolvedValue({
+ ...baseAppConfig,
+ interfaceConfig: { ...baseAppConfig.interfaceConfig, buildInfo: false },
+ });
+ mockResolveBuildInfo.mockReturnValue(populatedBuildInfo);
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body).not.toHaveProperty('buildInfo');
+ });
+
+ it('omits buildInfo when all resolver fields are null', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ mockResolveBuildInfo.mockReturnValue({
+ commit: null,
+ commitShort: null,
+ branch: null,
+ buildDate: null,
+ });
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body).not.toHaveProperty('buildInfo');
+ });
+
+ it('includes buildInfo in unauthenticated response when flag is not disabled', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ mockResolveBuildInfo.mockReturnValue(populatedBuildInfo);
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.buildInfo).toEqual(populatedBuildInfo);
+ });
+
+ it('omits buildInfo in unauthenticated response when interface.buildInfo is false', async () => {
+ mockGetAppConfig.mockResolvedValue({
+ ...baseAppConfig,
+ interfaceConfig: { ...baseAppConfig.interfaceConfig, buildInfo: false },
+ });
+ mockResolveBuildInfo.mockReturnValue(populatedBuildInfo);
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body).not.toHaveProperty('buildInfo');
+ });
+
+ it('propagates interface.buildInfo=false in unauthenticated response so clients can hide About tab', async () => {
+ mockGetAppConfig.mockResolvedValue({
+ ...baseAppConfig,
+ interfaceConfig: { ...baseAppConfig.interfaceConfig, buildInfo: false },
+ });
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.interface).toBeDefined();
+ expect(response.body.interface.buildInfo).toBe(false);
+ });
+
+ it('does not add interface.buildInfo=true to unauthenticated response (default stays implicit)', async () => {
+ mockGetAppConfig.mockResolvedValue({
+ ...baseAppConfig,
+ interfaceConfig: { privacyPolicy: { externalUrl: 'https://x' }, buildInfo: true },
+ });
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.interface).toBeDefined();
+ expect(response.body.interface).not.toHaveProperty('buildInfo');
+ });
+
+ it('includes interface block with only buildInfo=false when nothing else is set', async () => {
+ mockGetAppConfig.mockResolvedValue({
+ ...baseAppConfig,
+ interfaceConfig: { buildInfo: false },
+ });
+ const app = createApp(null);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.interface).toEqual({ buildInfo: false });
+ });
+ });
+});
diff --git a/api/server/routes/__tests__/convos-duplicate-ratelimit.spec.js b/api/server/routes/__tests__/convos-duplicate-ratelimit.spec.js
new file mode 100644
index 0000000..a75c11c
--- /dev/null
+++ b/api/server/routes/__tests__/convos-duplicate-ratelimit.spec.js
@@ -0,0 +1,137 @@
+const express = require('express');
+const request = require('supertest');
+
+const MOCKS = '../__test-utils__/convos-route-mocks';
+
+jest.mock('@librechat/agents', () => require(MOCKS).agents());
+jest.mock('@librechat/api', () => require(MOCKS).api({ limiterCache: jest.fn(() => undefined) }));
+jest.mock('@librechat/data-schemas', () => require(MOCKS).dataSchemas());
+jest.mock('librechat-data-provider', () =>
+ require(MOCKS).dataProvider({ ViolationTypes: { FILE_UPLOAD_LIMIT: 'file_upload_limit' } }),
+);
+
+jest.mock('~/cache/logViolation', () => jest.fn().mockResolvedValue(undefined));
+jest.mock('~/cache/getLogStores', () => require(MOCKS).logStores());
+jest.mock('~/models', () => ({
+ ...require(MOCKS).sharedModels(),
+ ...require(MOCKS).conversationModel(),
+ ...require(MOCKS).toolCallModel(),
+}));
+jest.mock('~/server/middleware/requireJwtAuth', () => require(MOCKS).requireJwtAuth());
+
+jest.mock('~/server/middleware', () => {
+ const { createForkLimiters } = jest.requireActual('~/server/middleware/limiters/forkLimiters');
+ return {
+ createImportLimiters: jest.fn(() => ({
+ importIpLimiter: (req, res, next) => next(),
+ importUserLimiter: (req, res, next) => next(),
+ })),
+ createForkLimiters,
+ configMiddleware: (req, res, next) => next(),
+ validateConvoAccess: (req, res, next) => next(),
+ };
+});
+
+jest.mock('~/server/utils/import/fork', () => require(MOCKS).forkUtils());
+jest.mock('~/server/utils/import', () => require(MOCKS).importUtils());
+jest.mock('~/server/routes/files/multer', () => require(MOCKS).multerSetup());
+jest.mock('multer', () => require(MOCKS).multerLib());
+jest.mock('~/server/services/Endpoints/azureAssistants', () => require(MOCKS).assistantEndpoint());
+jest.mock('~/server/services/Endpoints/assistants', () => require(MOCKS).assistantEndpoint());
+
+describe('POST /api/convos/duplicate - Rate Limiting', () => {
+ let app;
+ let duplicateConversation;
+ const savedEnv = {};
+
+ beforeAll(() => {
+ savedEnv.FORK_USER_MAX = process.env.FORK_USER_MAX;
+ savedEnv.FORK_USER_WINDOW = process.env.FORK_USER_WINDOW;
+ savedEnv.FORK_IP_MAX = process.env.FORK_IP_MAX;
+ savedEnv.FORK_IP_WINDOW = process.env.FORK_IP_WINDOW;
+ });
+
+ afterAll(() => {
+ for (const key of Object.keys(savedEnv)) {
+ if (savedEnv[key] === undefined) {
+ delete process.env[key];
+ } else {
+ process.env[key] = savedEnv[key];
+ }
+ }
+ });
+
+ const setupApp = () => {
+ jest.clearAllMocks();
+ jest.isolateModules(() => {
+ const convosRouter = require('../convos');
+ ({ duplicateConversation } = require('~/server/utils/import/fork'));
+
+ app = express();
+ app.use(express.json());
+ app.use((req, res, next) => {
+ req.user = { id: 'rate-limit-test-user' };
+ next();
+ });
+ app.use('/api/convos', convosRouter);
+ });
+
+ duplicateConversation.mockResolvedValue({
+ conversation: { conversationId: 'duplicated-conv' },
+ });
+ };
+
+ describe('user limit', () => {
+ beforeEach(() => {
+ process.env.FORK_USER_MAX = '2';
+ process.env.FORK_USER_WINDOW = '1';
+ process.env.FORK_IP_MAX = '100';
+ process.env.FORK_IP_WINDOW = '1';
+ setupApp();
+ });
+
+ it('should return 429 after exceeding the user rate limit', async () => {
+ const userMax = parseInt(process.env.FORK_USER_MAX, 10);
+
+ for (let i = 0; i < userMax; i++) {
+ const res = await request(app)
+ .post('/api/convos/duplicate')
+ .send({ conversationId: 'conv-123' });
+ expect(res.status).toBe(201);
+ }
+
+ const res = await request(app)
+ .post('/api/convos/duplicate')
+ .send({ conversationId: 'conv-123' });
+ expect(res.status).toBe(429);
+ expect(res.body.message).toMatch(/too many/i);
+ });
+ });
+
+ describe('IP limit', () => {
+ beforeEach(() => {
+ process.env.FORK_USER_MAX = '100';
+ process.env.FORK_USER_WINDOW = '1';
+ process.env.FORK_IP_MAX = '2';
+ process.env.FORK_IP_WINDOW = '1';
+ setupApp();
+ });
+
+ it('should return 429 after exceeding the IP rate limit', async () => {
+ const ipMax = parseInt(process.env.FORK_IP_MAX, 10);
+
+ for (let i = 0; i < ipMax; i++) {
+ const res = await request(app)
+ .post('/api/convos/duplicate')
+ .send({ conversationId: 'conv-123' });
+ expect(res.status).toBe(201);
+ }
+
+ const res = await request(app)
+ .post('/api/convos/duplicate')
+ .send({ conversationId: 'conv-123' });
+ expect(res.status).toBe(429);
+ expect(res.body.message).toMatch(/too many/i);
+ });
+ });
+});
diff --git a/api/server/routes/__tests__/convos-import.spec.js b/api/server/routes/__tests__/convos-import.spec.js
new file mode 100644
index 0000000..c4ea139
--- /dev/null
+++ b/api/server/routes/__tests__/convos-import.spec.js
@@ -0,0 +1,98 @@
+const express = require('express');
+const request = require('supertest');
+const multer = require('multer');
+
+const importFileFilter = (req, file, cb) => {
+ if (file.mimetype === 'application/json') {
+ cb(null, true);
+ } else {
+ cb(new Error('Only JSON files are allowed'), false);
+ }
+};
+
+/** Proxy app that mirrors the production multer + error-handling pattern */
+function createImportApp(fileSize) {
+ const app = express();
+ const upload = multer({
+ storage: multer.memoryStorage(),
+ fileFilter: importFileFilter,
+ limits: { fileSize },
+ });
+ const uploadSingle = upload.single('file');
+
+ function handleUpload(req, res, next) {
+ uploadSingle(req, res, (err) => {
+ if (err && err.code === 'LIMIT_FILE_SIZE') {
+ return res.status(413).json({ message: 'File exceeds the maximum allowed size' });
+ }
+ if (err) {
+ return next(err);
+ }
+ next();
+ });
+ }
+
+ app.post('/import', handleUpload, (req, res) => {
+ res.status(201).json({ message: 'success', size: req.file.size });
+ });
+
+ app.use((err, _req, res, _next) => {
+ res.status(400).json({ error: err.message });
+ });
+
+ return app;
+}
+
+describe('Conversation Import - Multer File Size Limits', () => {
+ describe('multer rejects files exceeding the configured limit', () => {
+ it('returns 413 for files larger than the limit', async () => {
+ const limit = 1024;
+ const app = createImportApp(limit);
+ const oversized = Buffer.alloc(limit + 512, 'x');
+
+ const res = await request(app)
+ .post('/import')
+ .attach('file', oversized, { filename: 'import.json', contentType: 'application/json' });
+
+ expect(res.status).toBe(413);
+ expect(res.body.message).toBe('File exceeds the maximum allowed size');
+ });
+
+ it('accepts files within the limit', async () => {
+ const limit = 4096;
+ const app = createImportApp(limit);
+ const valid = Buffer.from(JSON.stringify({ title: 'test' }));
+
+ const res = await request(app)
+ .post('/import')
+ .attach('file', valid, { filename: 'import.json', contentType: 'application/json' });
+
+ expect(res.status).toBe(201);
+ expect(res.body.message).toBe('success');
+ });
+
+ it('rejects at the exact boundary (limit + 1 byte)', async () => {
+ const limit = 512;
+ const app = createImportApp(limit);
+ const boundary = Buffer.alloc(limit + 1, 'a');
+
+ const res = await request(app)
+ .post('/import')
+ .attach('file', boundary, { filename: 'import.json', contentType: 'application/json' });
+
+ expect(res.status).toBe(413);
+ });
+
+ it('accepts a file just under the limit', async () => {
+ const limit = 512;
+ const app = createImportApp(limit);
+ const underLimit = Buffer.alloc(limit - 1, 'b');
+
+ const res = await request(app)
+ .post('/import')
+ .attach('file', underLimit, { filename: 'import.json', contentType: 'application/json' });
+
+ expect(res.status).toBe(201);
+ });
+ });
+});
diff --git a/api/server/routes/__tests__/convos.spec.js b/api/server/routes/__tests__/convos.spec.js
new file mode 100644
index 0000000..6c78287
--- /dev/null
+++ b/api/server/routes/__tests__/convos.spec.js
@@ -0,0 +1,692 @@
+const express = require('express');
+const request = require('supertest');
+
+const MOCKS = '../__test-utils__/convos-route-mocks';
+
+jest.mock('@librechat/agents', () => require(MOCKS).agents());
+jest.mock('@librechat/api', () => require(MOCKS).api());
+jest.mock('@librechat/data-schemas', () => require(MOCKS).dataSchemas());
+jest.mock('librechat-data-provider', () => require(MOCKS).dataProvider());
+jest.mock('~/models', () => require(MOCKS).sharedModels());
+jest.mock('~/server/middleware/requireJwtAuth', () => require(MOCKS).requireJwtAuth());
+jest.mock('~/server/middleware', () => require(MOCKS).middlewarePassthrough());
+jest.mock('~/server/utils/import/fork', () => require(MOCKS).forkUtils());
+jest.mock('~/server/utils/import', () => require(MOCKS).importUtils());
+jest.mock('~/cache/getLogStores', () => require(MOCKS).logStores());
+jest.mock('~/server/routes/files/multer', () => require(MOCKS).multerSetup());
+jest.mock('multer', () => require(MOCKS).multerLib());
+jest.mock('~/server/services/Endpoints/azureAssistants', () => require(MOCKS).assistantEndpoint());
+jest.mock('~/server/services/Endpoints/assistants', () => require(MOCKS).assistantEndpoint());
+
+describe('Convos Routes', () => {
+ let app;
+ let convosRouter;
+ const { deleteToolCalls, deleteConvos, saveConvo } = require('~/models');
+ const {
+ deleteAgentCheckpoints,
+ deleteAllSharedLinksWithCleanup,
+ deleteConvoSharedLinksWithCleanup,
+ } = require('@librechat/api');
+
+ beforeAll(() => {
+ convosRouter = require('../convos');
+
+ app = express();
+ app.use(express.json());
+
+ /** Mock authenticated user */
+ app.use((req, res, next) => {
+ req.user = { id: 'test-user-123' };
+ next();
+ });
+
+ app.use('/api/convos', convosRouter);
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('DELETE /all', () => {
+ it('prunes the deleted conversations’ agent checkpoints (bulk, ids from deleteConvos)', async () => {
+ // HITL: a paused conversation's durable checkpoint must not outlive the conversation.
+ const conversationIds = ['conv-a', 'conv-b'];
+ deleteConvos.mockResolvedValue({ deletedCount: 2, conversationIds });
+ deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
+ deleteAllSharedLinksWithCleanup.mockResolvedValue({ deletedCount: 0 });
+
+ const response = await request(app).delete('/api/convos/all');
+
+ expect(response.status).toBe(201);
+ expect(deleteAgentCheckpoints).toHaveBeenCalledTimes(1);
+ expect(deleteAgentCheckpoints.mock.calls[0][0]).toEqual(conversationIds);
+ });
+
+ it('should delete all conversations, tool calls, and shared links for a user', async () => {
+ const mockDbResponse = {
+ deletedCount: 5,
+ message: 'All conversations deleted successfully',
+ };
+
+ deleteConvos.mockResolvedValue(mockDbResponse);
+ deleteToolCalls.mockResolvedValue({ deletedCount: 10 });
+ deleteAllSharedLinksWithCleanup.mockResolvedValue({
+ message: 'All shared links deleted successfully',
+ deletedCount: 3,
+ });
+
+ const response = await request(app).delete('/api/convos/all');
+
+ expect(response.status).toBe(201);
+ expect(response.body).toEqual(mockDbResponse);
+
+ /** Verify deleteConvos was called with correct userId */
+ expect(deleteConvos).toHaveBeenCalledWith('test-user-123', {});
+ expect(deleteConvos).toHaveBeenCalledTimes(1);
+
+ /** Verify deleteToolCalls was called with correct userId */
+ expect(deleteToolCalls).toHaveBeenCalledWith('test-user-123');
+ expect(deleteToolCalls).toHaveBeenCalledTimes(1);
+
+ /** Verify deleteAllSharedLinksWithCleanup was called with correct userId */
+ expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-123');
+ expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call deleteAllSharedLinksWithCleanup even when no conversations exist', async () => {
+ const mockDbResponse = {
+ deletedCount: 0,
+ message: 'No conversations to delete',
+ };
+
+ deleteConvos.mockResolvedValue(mockDbResponse);
+ deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
+ deleteAllSharedLinksWithCleanup.mockResolvedValue({
+ message: 'All shared links deleted successfully',
+ deletedCount: 0,
+ });
+
+ const response = await request(app).delete('/api/convos/all');
+
+ expect(response.status).toBe(201);
+ expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-123');
+ });
+
+ it('should return 500 if deleteConvos fails', async () => {
+ const errorMessage = 'Database connection error';
+ deleteConvos.mockRejectedValue(new Error(errorMessage));
+
+ const response = await request(app).delete('/api/convos/all');
+
+ expect(response.status).toBe(500);
+ expect(response.text).toBe('Error clearing conversations');
+
+ /** Verify error was logged */
+ const { logger } = require('@librechat/data-schemas');
+ expect(logger.error).toHaveBeenCalledWith('Error clearing conversations', expect.any(Error));
+ });
+
+ it('should return 500 if deleteToolCalls fails', async () => {
+ deleteConvos.mockResolvedValue({ deletedCount: 5 });
+ deleteToolCalls.mockRejectedValue(new Error('Tool calls deletion failed'));
+
+ const response = await request(app).delete('/api/convos/all');
+
+ expect(response.status).toBe(500);
+ expect(response.text).toBe('Error clearing conversations');
+ });
+
+ it('should return 500 if deleteAllSharedLinksWithCleanup fails', async () => {
+ deleteConvos.mockResolvedValue({ deletedCount: 5 });
+ deleteToolCalls.mockResolvedValue({ deletedCount: 10 });
+ deleteAllSharedLinksWithCleanup.mockRejectedValue(new Error('Shared links deletion failed'));
+
+ const response = await request(app).delete('/api/convos/all');
+
+ expect(response.status).toBe(500);
+ expect(response.text).toBe('Error clearing conversations');
+ });
+
+ it('should handle multiple users independently', async () => {
+ /** First user */
+ deleteConvos.mockResolvedValue({ deletedCount: 3 });
+ deleteToolCalls.mockResolvedValue({ deletedCount: 5 });
+ deleteAllSharedLinksWithCleanup.mockResolvedValue({ deletedCount: 2 });
+
+ let response = await request(app).delete('/api/convos/all');
+
+ expect(response.status).toBe(201);
+ expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-123');
+
+ jest.clearAllMocks();
+
+ /** Second user (simulate different user by modifying middleware) */
+ const app2 = express();
+ app2.use(express.json());
+ app2.use((req, res, next) => {
+ req.user = { id: 'test-user-456' };
+ next();
+ });
+ app2.use('/api/convos', require('../convos'));
+
+ deleteConvos.mockResolvedValue({ deletedCount: 7 });
+ deleteToolCalls.mockResolvedValue({ deletedCount: 12 });
+ deleteAllSharedLinksWithCleanup.mockResolvedValue({ deletedCount: 4 });
+
+ response = await request(app2).delete('/api/convos/all');
+
+ expect(response.status).toBe(201);
+ expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-456');
+ });
+
+ it('should execute deletions in correct sequence', async () => {
+ const executionOrder = [];
+
+ deleteConvos.mockImplementation(() => {
+ executionOrder.push('deleteConvos');
+ return Promise.resolve({ deletedCount: 5 });
+ });
+
+ deleteToolCalls.mockImplementation(() => {
+ executionOrder.push('deleteToolCalls');
+ return Promise.resolve({ deletedCount: 10 });
+ });
+
+ deleteAllSharedLinksWithCleanup.mockImplementation(() => {
+ executionOrder.push('deleteAllSharedLinksWithCleanup');
+ return Promise.resolve({ deletedCount: 3 });
+ });
+
+ await request(app).delete('/api/convos/all');
+
+ /** Verify all three functions were called */
+ expect(executionOrder).toEqual([
+ 'deleteConvos',
+ 'deleteToolCalls',
+ 'deleteAllSharedLinksWithCleanup',
+ ]);
+ });
+
+ it('should maintain data integrity by cleaning up shared links when conversations are deleted', async () => {
+ /** This test ensures that orphaned shared links are prevented */
+ const mockConvosDeleted = { deletedCount: 10 };
+ const mockToolCallsDeleted = { deletedCount: 15 };
+ const mockSharedLinksDeleted = {
+ message: 'All shared links deleted successfully',
+ deletedCount: 8,
+ };
+
+ deleteConvos.mockResolvedValue(mockConvosDeleted);
+ deleteToolCalls.mockResolvedValue(mockToolCallsDeleted);
+ deleteAllSharedLinksWithCleanup.mockResolvedValue(mockSharedLinksDeleted);
+
+ const response = await request(app).delete('/api/convos/all');
+
+ expect(response.status).toBe(201);
+
+ /** Verify that shared links cleanup was called for the same user */
+ expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-123');
+
+ /** Verify no shared links remain for deleted conversations */
+ expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledAfter(deleteConvos);
+ });
+ });
+
+ describe('DELETE /', () => {
+ it('should delete a single conversation, tool calls, and associated shared links', async () => {
+ const mockConversationId = 'conv-123';
+ const mockDbResponse = {
+ deletedCount: 1,
+ message: 'Conversation deleted successfully',
+ };
+
+ deleteConvos.mockResolvedValue(mockDbResponse);
+ deleteToolCalls.mockResolvedValue({ deletedCount: 3 });
+ deleteConvoSharedLinksWithCleanup.mockResolvedValue({
+ message: 'Shared links deleted successfully',
+ deletedCount: 1,
+ });
+
+ const response = await request(app)
+ .delete('/api/convos')
+ .send({
+ arg: {
+ conversationId: mockConversationId,
+ },
+ });
+
+ expect(response.status).toBe(201);
+ expect(response.body).toEqual(mockDbResponse);
+
+ /** Verify deleteConvos was called with correct parameters */
+ expect(deleteConvos).toHaveBeenCalledWith('test-user-123', {
+ conversationId: mockConversationId,
+ });
+
+ /** Verify deleteToolCalls was called */
+ expect(deleteToolCalls).toHaveBeenCalledWith('test-user-123', mockConversationId);
+
+ /** Verify deleteConvoSharedLinksWithCleanup was called */
+ expect(deleteConvoSharedLinksWithCleanup).toHaveBeenCalledWith(
+ 'test-user-123',
+ mockConversationId,
+ );
+ });
+
+ it('should not call deleteConvoSharedLinksWithCleanup when no conversationId provided', async () => {
+ deleteConvos.mockResolvedValue({ deletedCount: 0 });
+ deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
+
+ const response = await request(app)
+ .delete('/api/convos')
+ .send({
+ arg: {
+ source: 'button',
+ },
+ });
+
+ expect(response.status).toBe(200);
+ expect(deleteConvoSharedLinksWithCleanup).not.toHaveBeenCalled();
+ });
+
+ it('should handle deletion of conversation without shared links', async () => {
+ const mockConversationId = 'conv-no-shares';
+
+ deleteConvos.mockResolvedValue({ deletedCount: 1 });
+ deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
+ deleteConvoSharedLinksWithCleanup.mockResolvedValue({
+ message: 'Shared links deleted successfully',
+ deletedCount: 0,
+ });
+
+ const response = await request(app)
+ .delete('/api/convos')
+ .send({
+ arg: {
+ conversationId: mockConversationId,
+ },
+ });
+
+ expect(response.status).toBe(201);
+ expect(deleteConvoSharedLinksWithCleanup).toHaveBeenCalledWith(
+ 'test-user-123',
+ mockConversationId,
+ );
+ });
+
+ it('should return 400 when no parameters provided', async () => {
+ const response = await request(app).delete('/api/convos').send({
+ arg: {},
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'no parameters provided' });
+ expect(deleteConvos).not.toHaveBeenCalled();
+ expect(deleteConvoSharedLinksWithCleanup).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 when request body is empty (DoS prevention)', async () => {
+ const response = await request(app).delete('/api/convos').send({});
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'no parameters provided' });
+ expect(deleteConvos).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 when arg is null (DoS prevention)', async () => {
+ const response = await request(app).delete('/api/convos').send({ arg: null });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'no parameters provided' });
+ expect(deleteConvos).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 when arg is undefined (DoS prevention)', async () => {
+ const response = await request(app).delete('/api/convos').send({ arg: undefined });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'no parameters provided' });
+ expect(deleteConvos).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 when request body is null (DoS prevention)', async () => {
+ const response = await request(app)
+ .delete('/api/convos')
+ .set('Content-Type', 'application/json')
+ .send('null');
+
+ expect(response.status).toBe(400);
+ expect(deleteConvos).not.toHaveBeenCalled();
+ });
+
+ it('should return 500 if deleteConvoSharedLinksWithCleanup fails', async () => {
+ const mockConversationId = 'conv-error';
+
+ deleteConvos.mockResolvedValue({ deletedCount: 1 });
+ deleteToolCalls.mockResolvedValue({ deletedCount: 2 });
+ deleteConvoSharedLinksWithCleanup.mockRejectedValue(
+ new Error('Failed to delete shared links'),
+ );
+
+ const response = await request(app)
+ .delete('/api/convos')
+ .send({
+ arg: {
+ conversationId: mockConversationId,
+ },
+ });
+
+ expect(response.status).toBe(500);
+ expect(response.text).toBe('Error clearing conversations');
+ });
+
+ it('should execute deletions in correct sequence for single conversation', async () => {
+ const mockConversationId = 'conv-sequence';
+ const executionOrder = [];
+
+ deleteConvos.mockImplementation(() => {
+ executionOrder.push('deleteConvos');
+ return Promise.resolve({ deletedCount: 1 });
+ });
+
+ deleteToolCalls.mockImplementation(() => {
+ executionOrder.push('deleteToolCalls');
+ return Promise.resolve({ deletedCount: 2 });
+ });
+
+ deleteConvoSharedLinksWithCleanup.mockImplementation(() => {
+ executionOrder.push('deleteConvoSharedLinksWithCleanup');
+ return Promise.resolve({ deletedCount: 1 });
+ });
+
+ await request(app)
+ .delete('/api/convos')
+ .send({
+ arg: {
+ conversationId: mockConversationId,
+ },
+ });
+
+ expect(executionOrder).toEqual([
+ 'deleteConvos',
+ 'deleteToolCalls',
+ 'deleteConvoSharedLinksWithCleanup',
+ ]);
+ });
+
+ it('should prevent orphaned shared links when deleting single conversation', async () => {
+ const mockConversationId = 'conv-with-shares';
+
+ deleteConvos.mockResolvedValue({ deletedCount: 1 });
+ deleteToolCalls.mockResolvedValue({ deletedCount: 4 });
+ deleteConvoSharedLinksWithCleanup.mockResolvedValue({
+ message: 'Shared links deleted successfully',
+ deletedCount: 2,
+ });
+
+ const response = await request(app)
+ .delete('/api/convos')
+ .send({
+ arg: {
+ conversationId: mockConversationId,
+ },
+ });
+
+ expect(response.status).toBe(201);
+
+ /** Verify shared links were deleted for the specific conversation */
+ expect(deleteConvoSharedLinksWithCleanup).toHaveBeenCalledWith(
+ 'test-user-123',
+ mockConversationId,
+ );
+
+ /** Verify it was called after the conversation was deleted */
+ expect(deleteConvoSharedLinksWithCleanup).toHaveBeenCalledAfter(deleteConvos);
+ });
+ });
+
+ describe('POST /archive', () => {
+ it('should archive a conversation successfully', async () => {
+ const mockConversationId = 'conv-123';
+ const mockArchivedConvo = {
+ conversationId: mockConversationId,
+ title: 'Test Conversation',
+ isArchived: true,
+ user: 'test-user-123',
+ };
+
+ saveConvo.mockResolvedValue(mockArchivedConvo);
+
+ const response = await request(app)
+ .post('/api/convos/archive')
+ .send({
+ arg: {
+ conversationId: mockConversationId,
+ isArchived: true,
+ },
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual(mockArchivedConvo);
+ expect(saveConvo).toHaveBeenCalledWith(
+ expect.objectContaining({ userId: 'test-user-123' }),
+ { conversationId: mockConversationId, isArchived: true },
+ { context: `POST /api/convos/archive ${mockConversationId}` },
+ );
+ });
+
+ it('should unarchive a conversation successfully', async () => {
+ const mockConversationId = 'conv-456';
+ const mockUnarchivedConvo = {
+ conversationId: mockConversationId,
+ title: 'Unarchived Conversation',
+ isArchived: false,
+ user: 'test-user-123',
+ };
+
+ saveConvo.mockResolvedValue(mockUnarchivedConvo);
+
+ const response = await request(app)
+ .post('/api/convos/archive')
+ .send({
+ arg: {
+ conversationId: mockConversationId,
+ isArchived: false,
+ },
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual(mockUnarchivedConvo);
+ expect(saveConvo).toHaveBeenCalledWith(
+ expect.objectContaining({ userId: 'test-user-123' }),
+ { conversationId: mockConversationId, isArchived: false },
+ { context: `POST /api/convos/archive ${mockConversationId}` },
+ );
+ });
+
+ it('should return 400 when conversationId is missing', async () => {
+ const response = await request(app)
+ .post('/api/convos/archive')
+ .send({
+ arg: {
+ isArchived: true,
+ },
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'conversationId is required' });
+ expect(saveConvo).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 when isArchived is not a boolean', async () => {
+ const response = await request(app)
+ .post('/api/convos/archive')
+ .send({
+ arg: {
+ conversationId: 'conv-123',
+ isArchived: 'true',
+ },
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'isArchived must be a boolean' });
+ expect(saveConvo).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 when isArchived is undefined', async () => {
+ const response = await request(app)
+ .post('/api/convos/archive')
+ .send({
+ arg: {
+ conversationId: 'conv-123',
+ },
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'isArchived must be a boolean' });
+ expect(saveConvo).not.toHaveBeenCalled();
+ });
+
+ it('should return 500 when saveConvo fails', async () => {
+ const mockConversationId = 'conv-error';
+ saveConvo.mockRejectedValue(new Error('Database error'));
+
+ const response = await request(app)
+ .post('/api/convos/archive')
+ .send({
+ arg: {
+ conversationId: mockConversationId,
+ isArchived: true,
+ },
+ });
+
+ expect(response.status).toBe(500);
+ expect(response.text).toBe('Error archiving conversation');
+
+ const { logger } = require('@librechat/data-schemas');
+ expect(logger.error).toHaveBeenCalledWith('Error archiving conversation', expect.any(Error));
+ });
+
+ it('should handle empty arg object', async () => {
+ const response = await request(app).post('/api/convos/archive').send({
+ arg: {},
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'conversationId is required' });
+ });
+ });
+
+ describe('POST /convos/pin', () => {
+ const mockConversationId = 'conv-123';
+
+ it('should pin a conversation', async () => {
+ const mockPinnedConvo = { conversationId: mockConversationId, pinned: true };
+ saveConvo.mockResolvedValue(mockPinnedConvo);
+
+ const response = await request(app).post('/api/convos/pin').send({ arg: mockPinnedConvo });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual(mockPinnedConvo);
+ expect(saveConvo).toHaveBeenCalledWith(
+ { userId: 'test-user-123' },
+ { conversationId: mockConversationId, pinned: true },
+ { context: `POST /api/convos/pin ${mockConversationId}` },
+ );
+ });
+
+ it('should unpin a conversation', async () => {
+ const mockUnpinnedConvo = { conversationId: mockConversationId, pinned: false };
+ saveConvo.mockResolvedValue(mockUnpinnedConvo);
+
+ const response = await request(app).post('/api/convos/pin').send({ arg: mockUnpinnedConvo });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual(mockUnpinnedConvo);
+ expect(saveConvo).toHaveBeenCalledWith(
+ { userId: 'test-user-123' },
+ { conversationId: mockConversationId, pinned: false },
+ { context: `POST /api/convos/pin ${mockConversationId}` },
+ );
+ });
+
+ it('should return 400 when conversationId is missing', async () => {
+ const response = await request(app)
+ .post('/api/convos/pin')
+ .send({ arg: { pinned: true } });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'conversationId is required' });
+ expect(saveConvo).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 when pinned is not a boolean', async () => {
+ const response = await request(app)
+ .post('/api/convos/pin')
+ .send({ arg: { conversationId: mockConversationId, pinned: 'yes' } });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'pinned must be a boolean' });
+ expect(saveConvo).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 when pinned is missing', async () => {
+ const response = await request(app)
+ .post('/api/convos/pin')
+ .send({ arg: { conversationId: mockConversationId } });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'pinned is required' });
+ expect(saveConvo).not.toHaveBeenCalled();
+ });
+
+ it('should return 500 when saveConvo fails', async () => {
+ saveConvo.mockRejectedValue(new Error('Database error'));
+
+ const response = await request(app)
+ .post('/api/convos/pin')
+ .send({ arg: { conversationId: mockConversationId, pinned: true } });
+
+ expect(response.status).toBe(500);
+ });
+ });
+});
+
+/**
+ * Custom Jest matcher to verify function call order
+ */
+expect.extend({
+ toHaveBeenCalledAfter(received, other) {
+ const receivedCalls = received.mock.invocationCallOrder;
+ const otherCalls = other.mock.invocationCallOrder;
+
+ if (receivedCalls.length === 0) {
+ return {
+ pass: false,
+ message: () =>
+ `Expected ${received.getMockName()} to have been called after ${other.getMockName()}, but ${received.getMockName()} was never called`,
+ };
+ }
+
+ if (otherCalls.length === 0) {
+ return {
+ pass: false,
+ message: () =>
+ `Expected ${received.getMockName()} to have been called after ${other.getMockName()}, but ${other.getMockName()} was never called`,
+ };
+ }
+
+ const lastReceivedCall = receivedCalls[receivedCalls.length - 1];
+ const firstOtherCall = otherCalls[0];
+
+ const pass = lastReceivedCall > firstOtherCall;
+
+ return {
+ pass,
+ message: () =>
+ pass
+ ? `Expected ${received.getMockName()} not to have been called after ${other.getMockName()}`
+ : `Expected ${received.getMockName()} to have been called after ${other.getMockName()}`,
+ };
+ },
+});
diff --git a/api/server/routes/__tests__/grants.spec.js b/api/server/routes/__tests__/grants.spec.js
new file mode 100644
index 0000000..c7b5b6b
--- /dev/null
+++ b/api/server/routes/__tests__/grants.spec.js
@@ -0,0 +1,185 @@
+const express = require('express');
+const request = require('supertest');
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { createModels, createMethods } = require('@librechat/data-schemas');
+const { PrincipalType, SystemRoles } = require('librechat-data-provider');
+
+/**
+ * Integration test for the admin grants routes.
+ *
+ * Validates the full Express wiring: route registration → middleware →
+ * handler → real MongoDB. Auth middleware is injected (matching the repo
+ * pattern in keys.spec.js) so we can control the caller identity without
+ * a real JWT, while the handler DI deps use real DB methods.
+ */
+
+jest.mock('~/server/middleware', () => ({
+ requireJwtAuth: (_req, _res, next) => next(),
+}));
+
+jest.mock('~/server/middleware/roles/capabilities', () => ({
+ requireCapability: () => (_req, _res, next) => next(),
+}));
+
+let mongoServer;
+let db;
+
+beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ await mongoose.connect(mongoServer.getUri());
+ createModels(mongoose);
+ db = createMethods(mongoose);
+ await db.seedSystemGrants();
+ await db.initializeRoles();
+ await db.seedDefaultRoles();
+});
+
+afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+});
+
+afterEach(async () => {
+ const SystemGrant = mongoose.models.SystemGrant;
+ // Clean non-seed grants (keep admin seed)
+ await SystemGrant.deleteMany({
+ $or: [
+ { principalId: { $ne: SystemRoles.ADMIN } },
+ { principalType: { $ne: PrincipalType.ROLE } },
+ ],
+ });
+});
+
+function createApp(user) {
+ const { createAdminGrantsHandlers, getCachedPrincipals } = require('@librechat/api');
+
+ const handlers = createAdminGrantsHandlers({
+ listGrants: db.listGrants,
+ countGrants: db.countGrants,
+ getCapabilitiesForPrincipal: db.getCapabilitiesForPrincipal,
+ getCapabilitiesForPrincipals: db.getCapabilitiesForPrincipals,
+ grantCapability: db.grantCapability,
+ revokeCapability: db.revokeCapability,
+ getUserPrincipals: db.getUserPrincipals,
+ hasCapabilityForPrincipals: db.hasCapabilityForPrincipals,
+ getHeldCapabilities: db.getHeldCapabilities,
+ getCachedPrincipals,
+ checkRoleExists: async (name) => (await db.getRoleByName(name)) != null,
+ });
+
+ const app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ req.user = user;
+ next();
+ });
+
+ const router = express.Router();
+ router.get('/', handlers.listGrants);
+ router.get('/effective', handlers.getEffectiveCapabilities);
+ router.get('/:principalType/:principalId', handlers.getPrincipalGrants);
+ router.post('/', handlers.assignGrant);
+ router.delete('/:principalType/:principalId/:capability', handlers.revokeGrant);
+ app.use('/api/admin/grants', router);
+
+ return app;
+}
+
+describe('Admin Grants Routes — Integration', () => {
+ const adminUserId = new mongoose.Types.ObjectId();
+ const adminUser = {
+ _id: adminUserId,
+ id: adminUserId.toString(),
+ role: SystemRoles.ADMIN,
+ };
+
+ it('GET / returns seeded admin grants', async () => {
+ const app = createApp(adminUser);
+ const res = await request(app).get('/api/admin/grants').expect(200);
+
+ expect(res.body).toHaveProperty('grants');
+ expect(res.body).toHaveProperty('total');
+ expect(res.body.grants.length).toBeGreaterThan(0);
+ // Seeded grants are for the ADMIN role
+ expect(res.body.grants[0].principalType).toBe(PrincipalType.ROLE);
+ });
+
+ it('GET /effective returns capabilities for admin', async () => {
+ const app = createApp(adminUser);
+ const res = await request(app).get('/api/admin/grants/effective').expect(200);
+
+ expect(res.body).toHaveProperty('capabilities');
+ expect(res.body.capabilities).toContain('access:admin');
+ expect(res.body.capabilities).toContain('manage:roles');
+ });
+
+ it('POST / assigns a grant and DELETE / revokes it', async () => {
+ const app = createApp(adminUser);
+
+ // Assign
+ const assignRes = await request(app)
+ .post('/api/admin/grants')
+ .send({
+ principalType: PrincipalType.ROLE,
+ principalId: SystemRoles.USER,
+ capability: 'read:users',
+ })
+ .expect(201);
+
+ expect(assignRes.body.grant).toMatchObject({
+ principalType: PrincipalType.ROLE,
+ principalId: SystemRoles.USER,
+ capability: 'read:users',
+ });
+
+ // Verify via GET
+ const getRes = await request(app)
+ .get(`/api/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}`)
+ .expect(200);
+
+ expect(getRes.body.grants.some((g) => g.capability === 'read:users')).toBe(true);
+
+ // Revoke
+ await request(app)
+ .delete(`/api/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}/read:users`)
+ .expect(200);
+
+ // Verify revoked
+ const afterRes = await request(app)
+ .get(`/api/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}`)
+ .expect(200);
+
+ expect(afterRes.body.grants.some((g) => g.capability === 'read:users')).toBe(false);
+ });
+
+ it('POST / returns 400 for non-existent role when checkRoleExists is wired', async () => {
+ const app = createApp(adminUser);
+
+ const res = await request(app)
+ .post('/api/admin/grants')
+ .send({
+ principalType: PrincipalType.ROLE,
+ principalId: 'nonexistent-role',
+ capability: 'read:users',
+ })
+ .expect(400);
+
+ expect(res.body.error).toBe('Role not found');
+ });
+
+ it('POST / returns 401 without authenticated user', async () => {
+ const app = createApp(undefined);
+
+ const res = await request(app)
+ .post('/api/admin/grants')
+ .send({
+ principalType: PrincipalType.ROLE,
+ principalId: SystemRoles.USER,
+ capability: 'read:users',
+ })
+ .expect(401);
+
+ expect(res.body).toHaveProperty('error', 'Authentication required');
+ });
+});
diff --git a/api/server/routes/__tests__/keys.spec.js b/api/server/routes/__tests__/keys.spec.js
new file mode 100644
index 0000000..0c96dd3
--- /dev/null
+++ b/api/server/routes/__tests__/keys.spec.js
@@ -0,0 +1,174 @@
+const express = require('express');
+const request = require('supertest');
+
+jest.mock('~/models', () => ({
+ updateUserKey: jest.fn(),
+ deleteUserKey: jest.fn(),
+ getUserKeyExpiry: jest.fn(),
+}));
+
+jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
+
+jest.mock('~/server/middleware', () => ({
+ requireJwtAuth: (req, res, next) => next(),
+}));
+
+describe('Keys Routes', () => {
+ let app;
+ const { updateUserKey, deleteUserKey, getUserKeyExpiry } = require('~/models');
+
+ beforeAll(() => {
+ const keysRouter = require('../keys');
+
+ app = express();
+ app.use(express.json());
+
+ app.use((req, res, next) => {
+ req.user = { id: 'test-user-123' };
+ next();
+ });
+
+ app.use('/api/keys', keysRouter);
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('PUT /', () => {
+ it('should update a user key with the authenticated user ID', async () => {
+ updateUserKey.mockResolvedValue({});
+
+ const response = await request(app)
+ .put('/api/keys')
+ .send({ name: 'openAI', value: 'sk-test-key-123', expiresAt: '2026-12-31' });
+
+ expect(response.status).toBe(201);
+ expect(updateUserKey).toHaveBeenCalledWith({
+ userId: 'test-user-123',
+ name: 'openAI',
+ value: 'sk-test-key-123',
+ expiresAt: '2026-12-31',
+ });
+ expect(updateUserKey).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not allow userId override via request body (IDOR prevention)', async () => {
+ updateUserKey.mockResolvedValue({});
+
+ const response = await request(app).put('/api/keys').send({
+ userId: 'attacker-injected-id',
+ name: 'openAI',
+ value: 'sk-attacker-key',
+ });
+
+ expect(response.status).toBe(201);
+ expect(updateUserKey).toHaveBeenCalledWith({
+ userId: 'test-user-123',
+ name: 'openAI',
+ value: 'sk-attacker-key',
+ expiresAt: undefined,
+ });
+ });
+
+ it('should ignore extraneous fields from request body', async () => {
+ updateUserKey.mockResolvedValue({});
+
+ const response = await request(app).put('/api/keys').send({
+ name: 'openAI',
+ value: 'sk-test-key',
+ expiresAt: '2026-12-31',
+ _id: 'injected-mongo-id',
+ __v: 99,
+ extra: 'should-be-ignored',
+ });
+
+ expect(response.status).toBe(201);
+ expect(updateUserKey).toHaveBeenCalledWith({
+ userId: 'test-user-123',
+ name: 'openAI',
+ value: 'sk-test-key',
+ expiresAt: '2026-12-31',
+ });
+ });
+
+ it('should handle missing optional fields', async () => {
+ updateUserKey.mockResolvedValue({});
+
+ const response = await request(app)
+ .put('/api/keys')
+ .send({ name: 'anthropic', value: 'sk-ant-key' });
+
+ expect(response.status).toBe(201);
+ expect(updateUserKey).toHaveBeenCalledWith({
+ userId: 'test-user-123',
+ name: 'anthropic',
+ value: 'sk-ant-key',
+ expiresAt: undefined,
+ });
+ });
+
+ it('should return 400 when request body is null', async () => {
+ const response = await request(app)
+ .put('/api/keys')
+ .set('Content-Type', 'application/json')
+ .send('null');
+
+ expect(response.status).toBe(400);
+ expect(updateUserKey).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('DELETE /:name', () => {
+ it('should delete a user key by name', async () => {
+ deleteUserKey.mockResolvedValue({});
+
+ const response = await request(app).delete('/api/keys/openAI');
+
+ expect(response.status).toBe(204);
+ expect(deleteUserKey).toHaveBeenCalledWith({
+ userId: 'test-user-123',
+ name: 'openAI',
+ });
+ expect(deleteUserKey).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('DELETE /', () => {
+ it('should delete all keys when all=true', async () => {
+ deleteUserKey.mockResolvedValue({});
+
+ const response = await request(app).delete('/api/keys?all=true');
+
+ expect(response.status).toBe(204);
+ expect(deleteUserKey).toHaveBeenCalledWith({
+ userId: 'test-user-123',
+ all: true,
+ });
+ });
+
+ it('should return 400 when all query param is not true', async () => {
+ const response = await request(app).delete('/api/keys');
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'Specify either all=true to delete.' });
+ expect(deleteUserKey).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('GET /', () => {
+ it('should return key expiry for a given key name', async () => {
+ const mockExpiry = { expiresAt: '2026-12-31' };
+ getUserKeyExpiry.mockResolvedValue(mockExpiry);
+
+ const response = await request(app).get('/api/keys?name=openAI');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual(mockExpiry);
+ expect(getUserKeyExpiry).toHaveBeenCalledWith({
+ userId: 'test-user-123',
+ name: 'openAI',
+ });
+ });
+ });
+});
diff --git a/api/server/routes/__tests__/ldap.spec.js b/api/server/routes/__tests__/ldap.spec.js
new file mode 100644
index 0000000..bc7efa7
--- /dev/null
+++ b/api/server/routes/__tests__/ldap.spec.js
@@ -0,0 +1,58 @@
+const express = require('express');
+const request = require('supertest');
+const { isEnabled } = require('@librechat/api');
+const { getLdapConfig } = require('~/server/services/Config/ldap');
+
+jest.mock('~/server/services/Config/ldap');
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ isEnabled: jest.fn(),
+}));
+
+const app = express();
+
+// Mock the route handler
+app.get('/api/config', (req, res) => {
+ const ldapConfig = getLdapConfig();
+ res.json({ ldap: ldapConfig });
+});
+
+describe('LDAP Config Tests', () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('should return LDAP config with username property when LDAP_LOGIN_USES_USERNAME is enabled', async () => {
+ getLdapConfig.mockReturnValue({ enabled: true, username: true });
+ isEnabled.mockReturnValue(true);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.ldap).toEqual({
+ enabled: true,
+ username: true,
+ });
+ });
+
+ it('should return LDAP config without username property when LDAP_LOGIN_USES_USERNAME is not enabled', async () => {
+ getLdapConfig.mockReturnValue({ enabled: true });
+ isEnabled.mockReturnValue(false);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.ldap).toEqual({
+ enabled: true,
+ });
+ });
+
+ it('should not return LDAP config when LDAP is not enabled', async () => {
+ getLdapConfig.mockReturnValue(undefined);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.ldap).toBeUndefined();
+ });
+});
diff --git a/api/server/routes/__tests__/mcp.spec.js b/api/server/routes/__tests__/mcp.spec.js
new file mode 100644
index 0000000..5323fa0
--- /dev/null
+++ b/api/server/routes/__tests__/mcp.spec.js
@@ -0,0 +1,3328 @@
+const crypto = require('crypto');
+const express = require('express');
+const request = require('supertest');
+const mongoose = require('mongoose');
+const cookieParser = require('cookie-parser');
+const { getBasePath, PENDING_STALE_MS } = require('@librechat/api');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+
+function generateTestCsrfToken(flowId) {
+ return crypto
+ .createHmac('sha256', process.env.JWT_SECRET)
+ .update(flowId)
+ .digest('hex')
+ .slice(0, 32);
+}
+
+const mockRegistryInstance = {
+ getServerConfig: jest.fn(),
+ getOAuthServers: jest.fn(),
+ getAllServerConfigs: jest.fn(),
+ ensureConfigServers: jest.fn().mockResolvedValue({}),
+ addServer: jest.fn(),
+ updateServer: jest.fn(),
+ removeServer: jest.fn(),
+ getAllowedDomains: jest.fn().mockReturnValue(null),
+ getAllowedAddresses: jest.fn().mockReturnValue(null),
+ resolveAllowlists: jest.fn().mockResolvedValue({
+ allowedDomains: null,
+ allowedAddresses: null,
+ useSSRFProtection: true,
+ }),
+};
+let mockMCPUseAllowed = true;
+
+jest.mock('@librechat/api', () => {
+ const actual = jest.requireActual('@librechat/api');
+ return {
+ ...actual,
+ MCPOAuthHandler: {
+ initiateOAuthFlow: jest.fn(),
+ getFlowState: jest.fn(),
+ completeOAuthFlow: jest.fn(),
+ generateFlowId: jest.fn(),
+ generateTokenFlowId: jest.fn(),
+ parseFlowId: jest.fn(),
+ buildStoredClientMetadata: jest.fn((metadata, resourceMetadata) =>
+ metadata
+ ? {
+ ...metadata,
+ ...(resourceMetadata?.resource && { resource: resourceMetadata.resource }),
+ }
+ : undefined,
+ ),
+ resolveStateToFlowId: jest.fn(async (state) => state),
+ storeStateMapping: jest.fn(),
+ deleteStateMapping: jest.fn(),
+ },
+ MCPTokenStorage: {
+ storeTokens: jest.fn(),
+ getClientInfoAndMetadata: jest.fn(),
+ getTokens: jest.fn(),
+ deleteUserTokens: jest.fn(),
+ },
+ getUserMCPAuthMap: jest.fn(),
+ generateCheckAccess: jest.fn(({ permissionType, permissions }) => (req, res, next) => {
+ const { PermissionTypes, Permissions } = require('librechat-data-provider');
+ const isMCPUseCheck =
+ permissionType === PermissionTypes.MCP_SERVERS && permissions.includes(Permissions.USE);
+ if (isMCPUseCheck && !mockMCPUseAllowed) {
+ return res.status(403).json({ message: 'Forbidden: Insufficient permissions' });
+ }
+ return next();
+ }),
+ MCPServersRegistry: {
+ getInstance: () => mockRegistryInstance,
+ },
+ // Error handling utilities (from @librechat/api mcp/errors)
+ isMCPDomainNotAllowedError: (error) => error?.code === 'MCP_DOMAIN_NOT_ALLOWED',
+ isMCPInspectionFailedError: (error) => error?.code === 'MCP_INSPECTION_FAILED',
+ MCPErrorCodes: {
+ DOMAIN_NOT_ALLOWED: 'MCP_DOMAIN_NOT_ALLOWED',
+ INSPECTION_FAILED: 'MCP_INSPECTION_FAILED',
+ },
+ };
+});
+
+jest.mock('@librechat/data-schemas', () => ({
+ getTenantId: jest.fn(),
+ tenantStorage: {
+ run: jest.fn((store, fn) => fn()),
+ },
+ logger: {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+ createModels: jest.fn(() => ({
+ User: {
+ findOne: jest.fn(),
+ findById: jest.fn(),
+ },
+ Conversation: {
+ findOne: jest.fn(),
+ findById: jest.fn(),
+ },
+ })),
+ createMethods: jest.fn(() => ({
+ findUser: jest.fn(),
+ })),
+}));
+
+jest.mock('~/models', () => ({
+ findToken: jest.fn(),
+ updateToken: jest.fn(),
+ createToken: jest.fn(),
+ deleteTokens: jest.fn(),
+ findPluginAuthsByKeys: jest.fn(),
+ getRoleByName: jest.fn(),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ setCachedTools: jest.fn(),
+ getCachedTools: jest.fn(),
+ getMCPServerTools: jest.fn(),
+ loadCustomConfig: jest.fn(),
+ getAppConfig: jest.fn().mockResolvedValue({ mcpConfig: {} }),
+}));
+
+jest.mock('~/server/services/Config/mcp', () => ({
+ updateMCPServerTools: jest.fn(),
+}));
+
+const mockResolveAllMcpConfigs = jest.fn().mockResolvedValue({});
+const mockResolveMcpConfigNames = jest.fn().mockResolvedValue([]);
+jest.mock('~/server/services/MCP', () => ({
+ getMCPSetupData: jest.fn(),
+ resolveConfigServers: jest.fn().mockResolvedValue({}),
+ resolveMcpConfigNames: (...args) => mockResolveMcpConfigNames(...args),
+ resolveAllMcpConfigs: (...args) => mockResolveAllMcpConfigs(...args),
+ getServerConnectionStatus: jest.fn(),
+}));
+
+jest.mock('~/server/services/PluginService', () => ({
+ getUserPluginAuthValue: jest.fn(),
+}));
+
+jest.mock('~/config', () => ({
+ getMCPManager: jest.fn(),
+ getFlowStateManager: jest.fn(),
+ getOAuthReconnectionManager: jest.fn(),
+ getMCPServersRegistry: jest.fn(() => mockRegistryInstance),
+}));
+
+jest.mock('~/cache', () => ({
+ getLogStores: jest.fn(),
+}));
+
+jest.mock('~/server/middleware', () => ({
+ requireJwtAuth: (req, res, next) => next(),
+ canAccessMCPServerResource: () => (req, res, next) => next(),
+}));
+
+jest.mock('~/server/services/Tools/mcp', () => ({
+ reinitMCPServer: jest.fn(),
+}));
+
+describe('MCP Routes', () => {
+ let app;
+ let mongoServer;
+ let mcpRouter;
+ let currentUser;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ await mongoose.connect(mongoServer.getUri());
+
+ require('~/db/models');
+
+ mcpRouter = require('../mcp');
+
+ app = express();
+ app.use(express.json());
+ app.use(cookieParser());
+
+ app.use((req, res, next) => {
+ req.user = currentUser ?? { id: 'test-user-id' };
+ next();
+ });
+
+ app.use('/api/mcp', mcpRouter);
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ currentUser = undefined;
+ mockResolveAllMcpConfigs.mockResolvedValue({});
+ mockResolveMcpConfigNames.mockResolvedValue([]);
+ const { MCPOAuthHandler } = require('@librechat/api');
+ const { getTenantId } = require('@librechat/data-schemas');
+ getTenantId.mockReturnValue(undefined);
+ MCPOAuthHandler.generateFlowId.mockImplementation((userId, serverName, tenantId) => {
+ const flowId = `${userId}:${serverName}`;
+ return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
+ });
+ MCPOAuthHandler.generateTokenFlowId.mockImplementation((userId, serverName, tenantId) => {
+ const flowId = `${userId}:${serverName}`;
+ return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
+ });
+ MCPOAuthHandler.parseFlowId.mockImplementation((flowId) => {
+ const parts = flowId.split(':');
+ if (parts[0] === 'tenant') {
+ if (parts.length < 4 || !parts[1] || !parts[2]) {
+ return null;
+ }
+ let tenantId;
+ try {
+ tenantId = decodeURIComponent(parts[1]);
+ } catch {
+ return null;
+ }
+ return {
+ tenantId,
+ userId: parts[2],
+ serverName: parts.slice(3).join(':'),
+ };
+ }
+ if (parts.length < 2 || !parts[0]) {
+ return null;
+ }
+ return {
+ userId: parts[0],
+ serverName: parts.slice(1).join(':'),
+ };
+ });
+ MCPOAuthHandler.buildStoredClientMetadata.mockImplementation((metadata, resourceMetadata) =>
+ metadata
+ ? {
+ ...metadata,
+ ...(resourceMetadata?.resource && { resource: resourceMetadata.resource }),
+ }
+ : undefined,
+ );
+ mockMCPUseAllowed = true;
+ /**
+ * Reset registry method implementations every test. `clearAllMocks` resets
+ * call records but NOT implementations, so a `.mockRejectedValue(...)` set
+ * by an earlier test leaks into later ones — including the new
+ * `getServerConfig` lookup in updateMCPServerController.
+ */
+ mockRegistryInstance.getServerConfig.mockReset().mockResolvedValue(undefined);
+ mockRegistryInstance.addServer.mockReset();
+ mockRegistryInstance.updateServer.mockReset();
+ mockRegistryInstance.removeServer.mockReset();
+ });
+
+ describe('GET /:serverName/oauth/initiate', () => {
+ const { MCPOAuthHandler } = require('@librechat/api');
+ const { getLogStores } = require('~/cache');
+
+ it('should reuse stored authorization URL without starting a new OAuth flow', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'PENDING',
+ createdAt: Date.now(),
+ metadata: {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ authorizationUrl: 'https://oauth.example.com/auth?state=stored-state',
+ },
+ }),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
+ userId: 'test-user-id',
+ flowId: 'test-user-id:test-server',
+ });
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe('https://oauth.example.com/auth?state=stored-state');
+ expect(response.headers['set-cookie']?.join('')).toContain('oauth_csrf=');
+ expect(MCPOAuthHandler.initiateOAuthFlow).not.toHaveBeenCalled();
+ expect(MCPOAuthHandler.storeStateMapping).not.toHaveBeenCalled();
+ expect(mockRegistryInstance.getServerConfig).not.toHaveBeenCalled();
+ });
+
+ it('should accept tenant-scoped flow IDs when a tenant is active', async () => {
+ const { getTenantId } = require('@librechat/data-schemas');
+ getTenantId.mockReturnValue('tenant-a');
+ const tenantFlowId = 'tenant:tenant-a:test-user-id:test-server';
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'PENDING',
+ createdAt: Date.now(),
+ metadata: {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ authorizationUrl: 'https://oauth.example.com/auth?state=stored-state',
+ },
+ }),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
+ userId: 'test-user-id',
+ flowId: tenantFlowId,
+ });
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe('https://oauth.example.com/auth?state=stored-state');
+ expect(mockFlowManager.getFlowState).toHaveBeenCalledWith(tenantFlowId, 'mcp_oauth');
+ });
+
+ it('should reject non-tenant flow IDs when a tenant is active', async () => {
+ const { getTenantId } = require('@librechat/data-schemas');
+ getTenantId.mockReturnValue('tenant-a');
+ const mockFlowManager = { getFlowState: jest.fn() };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
+ userId: 'test-user-id',
+ flowId: 'test-user-id:test-server',
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body).toEqual({ error: 'Flow mismatch' });
+ expect(mockFlowManager.getFlowState).not.toHaveBeenCalled();
+ });
+
+ it('should reject stored authorization URL when flow is no longer pending', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'COMPLETED',
+ createdAt: Date.now(),
+ metadata: {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ authorizationUrl: 'https://oauth.example.com/auth?state=stored-state',
+ },
+ }),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
+ userId: 'test-user-id',
+ flowId: 'test-user-id:test-server',
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'Invalid flow state' });
+ expect(MCPOAuthHandler.initiateOAuthFlow).not.toHaveBeenCalled();
+ expect(MCPOAuthHandler.storeStateMapping).not.toHaveBeenCalled();
+ });
+
+ it('should initiate OAuth flow when stored authorization URL is missing', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'PENDING',
+ createdAt: Date.now(),
+ metadata: {
+ serverUrl: 'https://test-server.com',
+ state: 'old-state-value',
+ oauth: { clientId: 'test-client-id' },
+ },
+ }),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+
+ MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({
+ authorizationUrl: 'https://oauth.example.com/auth',
+ flowId: 'test-user-id:test-server',
+ flowMetadata: { state: 'random-state-value' },
+ });
+ MCPOAuthHandler.storeStateMapping.mockResolvedValue();
+ mockFlowManager.initFlow = jest.fn().mockResolvedValue();
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
+ userId: 'test-user-id',
+ flowId: 'test-user-id:test-server',
+ });
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe('https://oauth.example.com/auth');
+ expect(MCPOAuthHandler.initiateOAuthFlow).toHaveBeenCalledWith(
+ 'test-server',
+ 'https://test-server.com',
+ 'test-user-id',
+ {},
+ { clientId: 'test-client-id' },
+ null,
+ undefined,
+ null,
+ undefined,
+ );
+ expect(MCPOAuthHandler.deleteStateMapping).toHaveBeenCalledWith(
+ 'old-state-value',
+ mockFlowManager,
+ );
+ expect(mockFlowManager.initFlow).toHaveBeenCalledWith(
+ 'test-user-id:test-server',
+ 'mcp_oauth',
+ expect.objectContaining({
+ state: 'random-state-value',
+ authorizationUrl: 'https://oauth.example.com/auth',
+ }),
+ );
+ expect(MCPOAuthHandler.storeStateMapping).toHaveBeenCalledWith(
+ 'random-state-value',
+ 'test-user-id:test-server',
+ mockFlowManager,
+ );
+ });
+
+ it('should return 403 when userId does not match authenticated user', async () => {
+ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
+ userId: 'different-user-id',
+ flowId: 'test-user-id:test-server',
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body).toEqual({ error: 'User mismatch' });
+ });
+
+ it('should return 403 when flowId does not match authenticated user and server', async () => {
+ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
+ userId: 'test-user-id',
+ flowId: 'other-user-id:test-server',
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body).toEqual({ error: 'Flow mismatch' });
+ expect(getLogStores).not.toHaveBeenCalled();
+ });
+
+ it('should return 403 when flowId query value is not a string', async () => {
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/initiate')
+ .query('userId=test-user-id&flowId=test-user-id:test-server&flowId=other-flow');
+
+ expect(response.status).toBe(403);
+ expect(response.body).toEqual({ error: 'Flow mismatch' });
+ expect(getLogStores).not.toHaveBeenCalled();
+ });
+
+ it('should return 404 when flow state is not found', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue(null),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
+ userId: 'test-user-id',
+ flowId: 'test-user-id:test-server',
+ });
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({ error: 'Flow not found' });
+ });
+
+ it('should return 400 when flow state has missing OAuth config', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ metadata: {
+ serverUrl: 'https://test-server.com',
+ },
+ }),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
+ userId: 'test-user-id',
+ flowId: 'test-user-id:test-server',
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'Invalid flow state' });
+ });
+
+ it('should return 500 when OAuth initiation throws unexpected error', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockRejectedValue(new Error('Database error')),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
+ userId: 'test-user-id',
+ flowId: 'test-user-id:test-server',
+ });
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Failed to initiate OAuth' });
+ });
+
+ it('should return 400 when flow state metadata is null', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ id: 'test-user-id:test-server',
+ metadata: null,
+ }),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
+ userId: 'test-user-id',
+ flowId: 'test-user-id:test-server',
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'Invalid flow state' });
+ });
+ });
+
+ describe('GET /:serverName/oauth/callback', () => {
+ const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
+ const { getLogStores } = require('~/cache');
+
+ it('should redirect to error page when OAuth error is received', async () => {
+ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
+ error: 'access_denied',
+ state: 'test-user-id:test-server',
+ });
+ const basePath = getBasePath();
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(`${basePath}/oauth/error?error=access_denied`);
+ });
+
+ describe('OAuth error callback failFlow', () => {
+ it('should fail the flow when OAuth error is received with valid CSRF cookie', async () => {
+ const flowId = 'test-user-id:test-server';
+ const mockFlowManager = {
+ failFlow: jest.fn().mockResolvedValue(true),
+ };
+
+ getLogStores.mockReturnValueOnce({});
+ require('~/config').getFlowStateManager.mockReturnValueOnce(mockFlowManager);
+ MCPOAuthHandler.resolveStateToFlowId.mockResolvedValueOnce(flowId);
+
+ const csrfToken = generateTestCsrfToken(flowId);
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .query({
+ error: 'invalid_client',
+ state: flowId,
+ });
+ const basePath = getBasePath();
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(`${basePath}/oauth/error?error=invalid_client`);
+ expect(mockFlowManager.failFlow).toHaveBeenCalledWith(
+ flowId,
+ 'mcp_oauth',
+ 'invalid_client',
+ );
+ });
+
+ it('should fail the flow when OAuth error is received with valid session cookie', async () => {
+ const flowId = 'test-user-id:test-server';
+ const mockFlowManager = {
+ failFlow: jest.fn().mockResolvedValue(true),
+ };
+
+ getLogStores.mockReturnValueOnce({});
+ require('~/config').getFlowStateManager.mockReturnValueOnce(mockFlowManager);
+ MCPOAuthHandler.resolveStateToFlowId.mockResolvedValueOnce(flowId);
+
+ const sessionToken = generateTestCsrfToken('test-user-id');
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .set('Cookie', [`oauth_session=${sessionToken}`])
+ .query({
+ error: 'invalid_client',
+ state: flowId,
+ });
+ const basePath = getBasePath();
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(`${basePath}/oauth/error?error=invalid_client`);
+ expect(mockFlowManager.failFlow).toHaveBeenCalledWith(
+ flowId,
+ 'mcp_oauth',
+ 'invalid_client',
+ );
+ });
+
+ it('should NOT fail the flow when OAuth error is received without cookies (DoS prevention)', async () => {
+ const flowId = 'test-user-id:test-server';
+ const mockFlowManager = {
+ failFlow: jest.fn(),
+ };
+
+ getLogStores.mockReturnValueOnce({});
+ require('~/config').getFlowStateManager.mockReturnValueOnce(mockFlowManager);
+ MCPOAuthHandler.resolveStateToFlowId.mockResolvedValueOnce(flowId);
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
+ error: 'invalid_client',
+ state: flowId,
+ });
+ const basePath = getBasePath();
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(`${basePath}/oauth/error?error=invalid_client`);
+ expect(mockFlowManager.failFlow).not.toHaveBeenCalled();
+ });
+
+ it('should redirect instead of hanging when OAuth error flow ID is malformed', async () => {
+ const mockFlowManager = {
+ failFlow: jest.fn(),
+ };
+
+ getLogStores.mockReturnValueOnce({});
+ require('~/config').getFlowStateManager.mockReturnValueOnce(mockFlowManager);
+ MCPOAuthHandler.resolveStateToFlowId.mockResolvedValueOnce('malformed-flow-id');
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
+ error: 'invalid_client',
+ state: 'opaque-state',
+ });
+ const basePath = getBasePath();
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(`${basePath}/oauth/error?error=invalid_client`);
+ expect(mockFlowManager.failFlow).not.toHaveBeenCalled();
+ });
+ });
+
+ it('should redirect to error page when code is missing', async () => {
+ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
+ state: 'test-user-id:test-server',
+ });
+ const basePath = getBasePath();
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(`${basePath}/oauth/error?error=missing_code`);
+ });
+
+ it('should redirect to error page when state is missing', async () => {
+ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
+ code: 'test-auth-code',
+ });
+ const basePath = getBasePath();
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(`${basePath}/oauth/error?error=missing_state`);
+ });
+
+ it('should redirect to error page when CSRF cookie is missing', async () => {
+ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
+ code: 'test-auth-code',
+ state: 'test-user-id:test-server',
+ });
+ const basePath = getBasePath();
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(
+ `${basePath}/oauth/error?error=csrf_validation_failed`,
+ );
+ });
+
+ it('should redirect to error page when CSRF cookie does not match state', async () => {
+ const csrfToken = generateTestCsrfToken('different-flow-id');
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .query({
+ code: 'test-auth-code',
+ state: 'test-user-id:test-server',
+ });
+ const basePath = getBasePath();
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(
+ `${basePath}/oauth/error?error=csrf_validation_failed`,
+ );
+ });
+
+ it('should redirect to error page when flow state is not found', async () => {
+ MCPOAuthHandler.getFlowState.mockResolvedValue(null);
+ const flowId = 'invalid-flow:id';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .query({
+ code: 'test-auth-code',
+ state: flowId,
+ });
+ const basePath = getBasePath();
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(`${basePath}/oauth/error?error=invalid_state`);
+ });
+
+ describe('CSRF fallback via active PENDING flow', () => {
+ it('should proceed when a fresh PENDING flow exists and no cookies are present', async () => {
+ const flowId = 'test-user-id:test-server';
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'PENDING',
+ createdAt: Date.now(),
+ }),
+ completeFlow: jest.fn().mockResolvedValue(true),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const mockFlowState = {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: {},
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue({
+ access_token: 'test-token',
+ });
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+
+ const mockMcpManager = {
+ getUserConnection: jest.fn().mockResolvedValue({
+ fetchTools: jest.fn().mockResolvedValue([]),
+ }),
+ };
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ require('~/config').getOAuthReconnectionManager.mockReturnValue({
+ clearReconnection: jest.fn(),
+ });
+ require('~/server/services/Config/mcp').updateMCPServerTools.mockResolvedValue();
+
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .query({ code: 'test-code', state: flowId });
+
+ const basePath = getBasePath();
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toContain(`${basePath}/oauth/success`);
+ });
+
+ it('should forward the merged server config so the tool cache gate sees request-scoped servers', async () => {
+ const flowId = 'test-user-id:test-server';
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'PENDING',
+ createdAt: Date.now(),
+ }),
+ completeFlow: jest.fn().mockResolvedValue(true),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const mockFlowState = {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: {},
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ };
+ const mergedServerConfig = {
+ type: 'streamable-http',
+ url: 'https://override.example.com/{{LIBRECHAT_BODY_CONVERSATIONID}}/mcp',
+ source: 'config',
+ };
+ const fetchedTools = [{ name: 'search', inputSchema: { type: 'object' } }];
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue({
+ access_token: 'test-token',
+ });
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+ mockResolveAllMcpConfigs.mockResolvedValueOnce({ 'test-server': mergedServerConfig });
+
+ const mockMcpManager = {
+ getUserConnection: jest.fn().mockResolvedValue({
+ fetchTools: jest.fn().mockResolvedValue(fetchedTools),
+ }),
+ };
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ require('~/config').getOAuthReconnectionManager.mockReturnValue({
+ clearReconnection: jest.fn(),
+ });
+ const { updateMCPServerTools } = require('~/server/services/Config/mcp');
+ updateMCPServerTools.mockResolvedValue();
+
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .query({ code: 'test-code', state: flowId });
+
+ expect(response.status).toBe(302);
+ expect(mockResolveAllMcpConfigs).toHaveBeenCalledWith('test-user-id');
+ expect(mockMcpManager.getUserConnection).toHaveBeenCalledWith(
+ expect.objectContaining({ serverConfig: mergedServerConfig }),
+ );
+ expect(updateMCPServerTools).toHaveBeenCalledWith({
+ userId: 'test-user-id',
+ serverName: 'test-server',
+ tools: fetchedTools,
+ serverConfig: mergedServerConfig,
+ });
+ });
+
+ it('should reject when no PENDING flow exists and no cookies are present', async () => {
+ const flowId = 'test-user-id:test-server';
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue(null),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .query({ code: 'test-code', state: flowId });
+
+ const basePath = getBasePath();
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(
+ `${basePath}/oauth/error?error=csrf_validation_failed`,
+ );
+ });
+
+ it('should reject when only a COMPLETED flow exists (not PENDING)', async () => {
+ const flowId = 'test-user-id:test-server';
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'COMPLETED',
+ createdAt: Date.now(),
+ }),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .query({ code: 'test-code', state: flowId });
+
+ const basePath = getBasePath();
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(
+ `${basePath}/oauth/error?error=csrf_validation_failed`,
+ );
+ });
+
+ it('should reject when PENDING flow is stale (older than PENDING_STALE_MS)', async () => {
+ const flowId = 'test-user-id:test-server';
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'PENDING',
+ createdAt: Date.now() - PENDING_STALE_MS - 60 * 1000,
+ }),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .query({ code: 'test-code', state: flowId });
+
+ const basePath = getBasePath();
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(
+ `${basePath}/oauth/error?error=csrf_validation_failed`,
+ );
+ });
+ });
+
+ it('should handle OAuth callback successfully', async () => {
+ // mockRegistryInstance is defined at the top of the file
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
+ completeFlow: jest.fn().mockResolvedValue(),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const mockFlowState = {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: { toolFlowId: 'tool-flow-123' },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ };
+ const mockTokens = {
+ access_token: 'test-access-token',
+ refresh_token: 'test-refresh-token',
+ };
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const mockUserConnection = {
+ fetchTools: jest.fn().mockResolvedValue([
+ {
+ name: 'test-tool',
+ description: 'A test tool',
+ inputSchema: { type: 'object' },
+ },
+ ]),
+ };
+ const mockMcpManager = {
+ getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
+ };
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const { getCachedTools, setCachedTools } = require('~/server/services/Config');
+ const { Constants } = require('librechat-data-provider');
+ getCachedTools.mockResolvedValue({
+ [`existing-tool${Constants.mcp_delimiter}test-server`]: { type: 'function' },
+ [`other-tool${Constants.mcp_delimiter}other-server`]: { type: 'function' },
+ });
+ setCachedTools.mockResolvedValue();
+
+ const flowId = 'test-user-id:test-server';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .query({
+ code: 'test-auth-code',
+ state: flowId,
+ });
+ const basePath = getBasePath();
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(`${basePath}/oauth/success?serverName=test-server`);
+ expect(MCPOAuthHandler.completeOAuthFlow).toHaveBeenCalledWith(
+ flowId,
+ 'test-auth-code',
+ mockFlowManager,
+ {},
+ );
+ expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'test-user-id',
+ serverName: 'test-server',
+ tokens: mockTokens,
+ clientInfo: mockFlowState.clientInfo,
+ metadata: mockFlowState.metadata,
+ }),
+ );
+ const storeInvocation = MCPTokenStorage.storeTokens.mock.invocationCallOrder[0];
+ const connectInvocation = mockMcpManager.getUserConnection.mock.invocationCallOrder[0];
+ expect(storeInvocation).toBeLessThan(connectInvocation);
+ expect(mockFlowManager.completeFlow).toHaveBeenCalledWith(
+ 'tool-flow-123',
+ 'mcp_oauth',
+ mockTokens,
+ );
+ expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith(
+ 'test-user-id:test-server',
+ 'mcp_get_tokens',
+ );
+ });
+
+ it('should clear tenant-scoped token flow state after storing callback tokens', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
+ completeFlow: jest.fn().mockResolvedValue(),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const mockFlowState = {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: {
+ toolFlowId: 'tool-flow-123',
+ token_endpoint: 'https://auth.example.com/token',
+ },
+ resourceMetadata: { resource: 'https://api.example.com/' },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ tenantId: 'tenant-a',
+ };
+ const mockTokens = {
+ access_token: 'test-access-token',
+ refresh_token: 'test-refresh-token',
+ };
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ require('~/config').getOAuthReconnectionManager.mockReturnValue({
+ clearReconnection: jest.fn(),
+ });
+ require('~/config').getMCPManager.mockReturnValue({
+ getUserConnection: jest.fn().mockResolvedValue({
+ fetchTools: jest.fn().mockResolvedValue([]),
+ }),
+ });
+ const { getCachedTools, setCachedTools } = require('~/server/services/Config');
+ getCachedTools.mockResolvedValue({});
+ setCachedTools.mockResolvedValue();
+
+ const flowId = 'test-user-id:test-server';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .query({
+ code: 'test-auth-code',
+ state: flowId,
+ });
+
+ expect(response.status).toBe(302);
+ expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ resource: 'https://api.example.com/',
+ }),
+ }),
+ );
+ expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith(
+ 'tenant:tenant-a:test-user-id:test-server',
+ 'mcp_get_tokens',
+ );
+ expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith(flowId, 'mcp_get_tokens');
+ });
+
+ it('should complete pending token flow waiters after storing callback tokens', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockImplementation((id, type) => {
+ if (type === 'mcp_get_tokens' && id === 'tenant:tenant-a:test-user-id:test-server') {
+ return Promise.resolve({
+ type: 'mcp_get_tokens',
+ status: 'PENDING',
+ });
+ }
+ return Promise.resolve({ status: 'PENDING' });
+ }),
+ completeFlow: jest.fn().mockResolvedValue(true),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const mockFlowState = {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: {},
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ tenantId: 'tenant-a',
+ };
+ const mockTokens = {
+ access_token: 'fresh-access-token',
+ refresh_token: 'fresh-refresh-token',
+ };
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ require('~/config').getOAuthReconnectionManager.mockReturnValue({
+ clearReconnection: jest.fn(),
+ });
+ require('~/config').getMCPManager.mockReturnValue({
+ getUserConnection: jest.fn().mockResolvedValue({
+ fetchTools: jest.fn().mockResolvedValue([]),
+ }),
+ });
+ const { getCachedTools, setCachedTools } = require('~/server/services/Config');
+ getCachedTools.mockResolvedValue({});
+ setCachedTools.mockResolvedValue();
+
+ const flowId = 'test-user-id:test-server';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .query({
+ code: 'test-auth-code',
+ state: flowId,
+ });
+
+ expect(response.status).toBe(302);
+ expect(mockFlowManager.completeFlow).toHaveBeenCalledWith(
+ 'tenant:tenant-a:test-user-id:test-server',
+ 'mcp_get_tokens',
+ mockTokens,
+ );
+ expect(mockFlowManager.deleteFlow).not.toHaveBeenCalledWith(
+ 'tenant:tenant-a:test-user-id:test-server',
+ 'mcp_get_tokens',
+ );
+ expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith(flowId, 'mcp_get_tokens');
+ });
+
+ it('should use oauthHeaders from flow state when present', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
+ completeFlow: jest.fn().mockResolvedValue(),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const mockFlowState = {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: { toolFlowId: 'tool-flow-123' },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ oauthHeaders: { 'X-Custom-Auth': 'header-value' },
+ };
+ const mockTokens = { access_token: 'tok', refresh_token: 'ref' };
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ require('~/config').getOAuthReconnectionManager.mockReturnValue({
+ clearReconnection: jest.fn(),
+ });
+ require('~/config').getMCPManager.mockReturnValue({
+ getUserConnection: jest.fn().mockResolvedValue({
+ fetchTools: jest.fn().mockResolvedValue([]),
+ }),
+ });
+ const { getCachedTools, setCachedTools } = require('~/server/services/Config');
+ getCachedTools.mockResolvedValue({});
+ setCachedTools.mockResolvedValue();
+
+ const flowId = 'test-user-id:test-server';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .query({ code: 'auth-code', state: flowId });
+
+ expect(MCPOAuthHandler.completeOAuthFlow).toHaveBeenCalledWith(
+ flowId,
+ 'auth-code',
+ mockFlowManager,
+ { 'X-Custom-Auth': 'header-value' },
+ );
+ expect(mockRegistryInstance.getServerConfig).not.toHaveBeenCalled();
+ });
+
+ it('should fall back to registry oauth_headers when flow state lacks them', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
+ completeFlow: jest.fn().mockResolvedValue(),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const mockFlowState = {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: { toolFlowId: 'tool-flow-123' },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ };
+ const mockTokens = { access_token: 'tok', refresh_token: 'ref' };
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ oauth_headers: { 'X-Registry-Header': 'from-registry' },
+ });
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ require('~/config').getOAuthReconnectionManager.mockReturnValue({
+ clearReconnection: jest.fn(),
+ });
+ require('~/config').getMCPManager.mockReturnValue({
+ getUserConnection: jest.fn().mockResolvedValue({
+ fetchTools: jest.fn().mockResolvedValue([]),
+ }),
+ });
+ const { getCachedTools, setCachedTools } = require('~/server/services/Config');
+ getCachedTools.mockResolvedValue({});
+ setCachedTools.mockResolvedValue();
+
+ const flowId = 'test-user-id:test-server';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .query({ code: 'auth-code', state: flowId });
+
+ expect(MCPOAuthHandler.completeOAuthFlow).toHaveBeenCalledWith(
+ flowId,
+ 'auth-code',
+ mockFlowManager,
+ { 'X-Registry-Header': 'from-registry' },
+ );
+ expect(mockRegistryInstance.getServerConfig).toHaveBeenCalledWith(
+ 'test-server',
+ 'test-user-id',
+ undefined,
+ );
+ });
+
+ it('should redirect to error page when callback processing fails', async () => {
+ MCPOAuthHandler.getFlowState.mockRejectedValue(new Error('Callback error'));
+ const flowId = 'test-user-id:test-server';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .query({
+ code: 'test-auth-code',
+ state: flowId,
+ });
+ const basePath = getBasePath();
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(`${basePath}/oauth/error?error=callback_failed`);
+ });
+
+ it('should handle system-level OAuth completion', async () => {
+ // mockRegistryInstance is defined at the top of the file
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
+ completeFlow: jest.fn().mockResolvedValue(),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const mockFlowState = {
+ serverName: 'test-server',
+ userId: 'system',
+ metadata: { toolFlowId: 'tool-flow-123' },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ };
+ const mockTokens = {
+ access_token: 'test-access-token',
+ refresh_token: 'test-refresh-token',
+ };
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const flowId = 'test-user-id:test-server';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .query({
+ code: 'test-auth-code',
+ state: flowId,
+ });
+ const basePath = getBasePath();
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(`${basePath}/oauth/success?serverName=test-server`);
+ expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith(flowId, 'mcp_get_tokens');
+ });
+
+ it('should handle reconnection failure after OAuth', async () => {
+ // mockRegistryInstance is defined at the top of the file
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
+ completeFlow: jest.fn().mockResolvedValue(),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const mockFlowState = {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: { toolFlowId: 'tool-flow-123' },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ };
+ const mockTokens = {
+ access_token: 'test-access-token',
+ refresh_token: 'test-refresh-token',
+ };
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const mockMcpManager = {
+ getUserConnection: jest.fn().mockRejectedValue(new Error('Reconnection failed')),
+ };
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const { getCachedTools, setCachedTools } = require('~/server/services/Config');
+ getCachedTools.mockResolvedValue({});
+ setCachedTools.mockResolvedValue();
+
+ const flowId = 'test-user-id:test-server';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .query({
+ code: 'test-auth-code',
+ state: flowId,
+ });
+ const basePath = getBasePath();
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(`${basePath}/oauth/success?serverName=test-server`);
+ expect(MCPTokenStorage.storeTokens).toHaveBeenCalled();
+ expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith(flowId, 'mcp_get_tokens');
+ });
+
+ it('should redirect to error page if token storage fails', async () => {
+ // mockRegistryInstance is defined at the top of the file
+ const mockFlowManager = {
+ completeFlow: jest.fn().mockResolvedValue(),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const mockFlowState = {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: { toolFlowId: 'tool-flow-123' },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ };
+ const mockTokens = {
+ access_token: 'test-access-token',
+ refresh_token: 'test-refresh-token',
+ };
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockRejectedValue(new Error('store failed'));
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const mockMcpManager = {
+ getUserConnection: jest.fn(),
+ };
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const flowId = 'test-user-id:test-server';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .query({
+ code: 'test-auth-code',
+ state: flowId,
+ });
+ const basePath = getBasePath();
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(`${basePath}/oauth/error?error=callback_failed`);
+ expect(mockMcpManager.getUserConnection).not.toHaveBeenCalled();
+ });
+
+ it('should use original flow state credentials when storing tokens', async () => {
+ // mockRegistryInstance is defined at the top of the file
+ const mockFlowManager = {
+ getFlowState: jest.fn(),
+ completeFlow: jest.fn().mockResolvedValue(),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const clientInfo = {
+ client_id: 'client123',
+ client_secret: 'client_secret',
+ };
+ const flowState = {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: { toolFlowId: 'tool-flow-123', serverUrl: 'http://example.com' },
+ clientInfo: clientInfo,
+ codeVerifier: 'test-verifier',
+ status: 'PENDING',
+ };
+ const mockTokens = {
+ access_token: 'test-access-token',
+ refresh_token: 'test-refresh-token',
+ };
+
+ // First call checks idempotency (status PENDING = not completed)
+ // Second call retrieves flow state for processing
+ mockFlowManager.getFlowState
+ .mockResolvedValueOnce({ status: 'PENDING' })
+ .mockResolvedValueOnce(flowState);
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue(flowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const mockUserConnection = {
+ fetchTools: jest.fn().mockResolvedValue([]),
+ };
+ const mockMcpManager = {
+ getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
+ };
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ require('~/config').getOAuthReconnectionManager = jest.fn().mockReturnValue({
+ clearReconnection: jest.fn(),
+ });
+
+ const flowId = 'test-user-id:test-server';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .query({
+ code: 'test-auth-code',
+ state: flowId,
+ });
+ const basePath = getBasePath();
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(`${basePath}/oauth/success?serverName=test-server`);
+
+ expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'test-user-id',
+ serverName: 'test-server',
+ tokens: mockTokens,
+ clientInfo: clientInfo,
+ metadata: flowState.metadata,
+ }),
+ );
+ });
+
+ it('should prevent duplicate token exchange with idempotency check', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn(),
+ };
+
+ // Flow is already completed
+ mockFlowManager.getFlowState.mockResolvedValue({
+ status: 'COMPLETED',
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ });
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue({
+ status: 'COMPLETED',
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ });
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const flowId = 'test-user-id:test-server';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .query({
+ code: 'test-auth-code',
+ state: flowId,
+ });
+ const basePath = getBasePath();
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe(`${basePath}/oauth/success?serverName=test-server`);
+
+ expect(MCPOAuthHandler.completeOAuthFlow).not.toHaveBeenCalled();
+ expect(MCPTokenStorage.storeTokens).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('GET /oauth/tokens/:flowId', () => {
+ const { getLogStores } = require('~/cache');
+
+ it('should return tokens for completed flow', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'COMPLETED',
+ result: {
+ access_token: 'test-access-token',
+ refresh_token: 'test-refresh-token',
+ },
+ }),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/oauth/tokens/test-user-id:flow-123');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ tokens: {
+ access_token: 'test-access-token',
+ refresh_token: 'test-refresh-token',
+ },
+ });
+ });
+
+ it('should return tokens for a tenant-prefixed flow owned by the user', async () => {
+ const { getTenantId } = require('@librechat/data-schemas');
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'COMPLETED',
+ result: {
+ access_token: 'tenant-access-token',
+ },
+ }),
+ };
+
+ getTenantId.mockReturnValue('tenant-a');
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get(
+ '/api/mcp/oauth/tokens/tenant:tenant-a:test-user-id:test-server',
+ );
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ tokens: {
+ access_token: 'tenant-access-token',
+ },
+ });
+ expect(mockFlowManager.getFlowState).toHaveBeenCalledWith(
+ 'tenant:tenant-a:test-user-id:test-server',
+ 'mcp_oauth',
+ );
+ });
+
+ it('should reject tenant-prefixed token flow access from another tenant', async () => {
+ const { getTenantId } = require('@librechat/data-schemas');
+ getTenantId.mockReturnValue('tenant-b');
+
+ const response = await request(app).get(
+ '/api/mcp/oauth/tokens/tenant:tenant-a:test-user-id:test-server',
+ );
+
+ expect(response.status).toBe(403);
+ expect(response.body).toEqual({ error: 'Access denied' });
+ });
+
+ it('should return 401 when user is not authenticated', async () => {
+ const unauthApp = express();
+ unauthApp.use(express.json());
+ unauthApp.use((req, res, next) => {
+ req.user = null;
+ next();
+ });
+ unauthApp.use('/api/mcp', mcpRouter);
+
+ const response = await request(unauthApp).get('/api/mcp/oauth/tokens/test-flow-id');
+
+ expect(response.status).toBe(401);
+ expect(response.body).toEqual({ error: 'User not authenticated' });
+ });
+
+ it('should return 403 when user tries to access flow they do not own', async () => {
+ const response = await request(app).get('/api/mcp/oauth/tokens/other-user-id:flow-123');
+
+ expect(response.status).toBe(403);
+ expect(response.body).toEqual({ error: 'Access denied' });
+ });
+
+ it('should return 404 when flow is not found', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue(null),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get(
+ '/api/mcp/oauth/tokens/test-user-id:non-existent-flow',
+ );
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({ error: 'Flow not found' });
+ });
+
+ it('should return 400 when flow is not completed', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'PENDING',
+ result: null,
+ }),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/oauth/tokens/test-user-id:pending-flow');
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'Flow not completed' });
+ });
+
+ it('should return 500 when token retrieval throws an unexpected error', async () => {
+ getLogStores.mockImplementation(() => {
+ throw new Error('Database connection failed');
+ });
+
+ const response = await request(app).get('/api/mcp/oauth/tokens/test-user-id:error-flow');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Failed to get tokens' });
+ });
+ });
+
+ describe('GET /oauth/status/:flowId', () => {
+ const { getLogStores } = require('~/cache');
+
+ it('should return flow status when flow exists', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'PENDING',
+ error: null,
+ }),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/oauth/status/test-user-id:test-server');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ status: 'PENDING',
+ completed: false,
+ failed: false,
+ error: null,
+ });
+ });
+
+ it('should return flow status for a tenant-prefixed flow owned by the user', async () => {
+ const { getTenantId } = require('@librechat/data-schemas');
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'PENDING',
+ error: null,
+ }),
+ };
+
+ getTenantId.mockReturnValue('tenant-a');
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get(
+ '/api/mcp/oauth/status/tenant:tenant-a:test-user-id:test-server',
+ );
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ status: 'PENDING',
+ completed: false,
+ failed: false,
+ error: null,
+ });
+ expect(mockFlowManager.getFlowState).toHaveBeenCalledWith(
+ 'tenant:tenant-a:test-user-id:test-server',
+ 'mcp_oauth',
+ );
+ });
+
+ it('should reject tenant-prefixed status access from another tenant', async () => {
+ const { getTenantId } = require('@librechat/data-schemas');
+ getTenantId.mockReturnValue('tenant-b');
+
+ const response = await request(app).get(
+ '/api/mcp/oauth/status/tenant:tenant-a:test-user-id:test-server',
+ );
+
+ expect(response.status).toBe(403);
+ expect(response.body).toEqual({ error: 'Access denied' });
+ });
+
+ it('should return 403 when flowId does not match authenticated user', async () => {
+ const response = await request(app).get('/api/mcp/oauth/status/other-user-id:test-server');
+
+ expect(response.status).toBe(403);
+ expect(response.body).toEqual({ error: 'Access denied' });
+ });
+
+ it('should return 404 when flow is not found', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue(null),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/oauth/status/test-user-id:non-existent');
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({ error: 'Flow not found' });
+ });
+
+ it('should return 500 when status check fails', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockRejectedValue(new Error('Database error')),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/oauth/status/test-user-id:error-server');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Failed to get flow status' });
+ });
+ });
+
+ describe('POST /oauth/cancel/:serverName', () => {
+ const { MCPOAuthHandler } = require('@librechat/api');
+ const { getLogStores } = require('~/cache');
+
+ it('should cancel OAuth flow successfully', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'PENDING',
+ }),
+ failFlow: jest.fn().mockResolvedValue(),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ MCPOAuthHandler.generateFlowId.mockReturnValue('test-user-id:test-server');
+
+ const response = await request(app).post('/api/mcp/oauth/cancel/test-server');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ message: 'OAuth flow for test-server cancelled successfully',
+ });
+
+ expect(mockFlowManager.failFlow).toHaveBeenCalledWith(
+ 'test-user-id:test-server',
+ 'mcp_oauth',
+ 'User cancelled OAuth flow',
+ );
+ });
+
+ it('should return success message when no active flow exists', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue(null),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ MCPOAuthHandler.generateFlowId.mockReturnValue('test-user-id:test-server');
+
+ const response = await request(app).post('/api/mcp/oauth/cancel/test-server');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ message: 'No active OAuth flow to cancel',
+ });
+ });
+
+ it('should return 500 when cancellation fails', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
+ failFlow: jest.fn().mockRejectedValue(new Error('Database error')),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ MCPOAuthHandler.generateFlowId.mockReturnValue('test-user-id:test-server');
+
+ const response = await request(app).post('/api/mcp/oauth/cancel/test-server');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Failed to cancel OAuth flow' });
+ });
+
+ it('should return 401 when user is not authenticated', async () => {
+ const unauthApp = express();
+ unauthApp.use(express.json());
+ unauthApp.use((req, res, next) => {
+ req.user = null;
+ next();
+ });
+ unauthApp.use('/api/mcp', mcpRouter);
+
+ const response = await request(unauthApp).post('/api/mcp/oauth/cancel/test-server');
+
+ expect(response.status).toBe(401);
+ expect(response.body).toEqual({ error: 'User not authenticated' });
+ });
+ });
+
+ describe('POST /:serverName/reinitialize', () => {
+ // mockRegistryInstance is defined at the top of the file
+
+ it('should return 404 when server is not found in configuration', async () => {
+ const mockMcpManager = {
+ disconnectUserConnection: jest.fn().mockResolvedValue(),
+ };
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue(null);
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ require('~/config').getFlowStateManager.mockReturnValue({});
+ require('~/cache').getLogStores.mockReturnValue({});
+
+ const response = await request(app).post('/api/mcp/non-existent-server/reinitialize');
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({
+ error: "MCP server 'non-existent-server' not found in configuration",
+ });
+ });
+
+ it('should handle OAuth requirement during reinitialize', async () => {
+ const mockMcpManager = {
+ disconnectUserConnection: jest.fn().mockResolvedValue(),
+ mcpConfigs: {},
+ getUserConnection: jest.fn().mockImplementation(async ({ oauthStart }) => {
+ if (oauthStart) {
+ await oauthStart('https://oauth.example.com/auth');
+ }
+ throw new Error('OAuth flow initiated - return early');
+ }),
+ };
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ customUserVars: {},
+ });
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ require('~/config').getFlowStateManager.mockReturnValue({});
+ require('~/cache').getLogStores.mockReturnValue({});
+ require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
+ success: true,
+ message: "MCP server 'oauth-server' ready for OAuth authentication",
+ serverName: 'oauth-server',
+ oauthRequired: true,
+ oauthUrl: 'https://oauth.example.com/auth',
+ });
+
+ const response = await request(app).post('/api/mcp/oauth-server/reinitialize');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ message: "MCP server 'oauth-server' ready for OAuth authentication",
+ serverName: 'oauth-server',
+ oauthRequired: true,
+ oauthUrl: 'https://oauth.example.com/auth',
+ });
+ });
+
+ it('should return 500 when reinitialize fails with non-OAuth error', async () => {
+ const mockMcpManager = {
+ disconnectUserConnection: jest.fn().mockResolvedValue(),
+ mcpConfigs: {},
+ getUserConnection: jest.fn().mockRejectedValue(new Error('Connection failed')),
+ };
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ require('~/config').getFlowStateManager.mockReturnValue({});
+ require('~/cache').getLogStores.mockReturnValue({});
+ require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue(null);
+
+ const response = await request(app).post('/api/mcp/error-server/reinitialize');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({
+ error: 'Failed to reinitialize MCP server for user',
+ });
+ });
+
+ it('should return 500 when unexpected error occurs', async () => {
+ const mockMcpManager = {
+ disconnectUserConnection: jest.fn(),
+ };
+
+ mockRegistryInstance.getServerConfig.mockImplementation(() => {
+ throw new Error('Config loading failed');
+ });
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const response = await request(app).post('/api/mcp/test-server/reinitialize');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Internal server error' });
+ });
+
+ it('should return 401 when user is not authenticated', async () => {
+ const unauthApp = express();
+ unauthApp.use(express.json());
+ unauthApp.use((req, res, next) => {
+ req.user = null;
+ next();
+ });
+ unauthApp.use('/api/mcp', mcpRouter);
+
+ const response = await request(unauthApp).post('/api/mcp/test-server/reinitialize');
+
+ expect(response.status).toBe(401);
+ expect(response.body).toEqual({ error: 'User not authenticated' });
+ });
+
+ it('should successfully reinitialize server and cache tools', async () => {
+ const mockUserConnection = {
+ fetchTools: jest.fn().mockResolvedValue([
+ { name: 'tool1', description: 'Test tool 1', inputSchema: { type: 'object' } },
+ { name: 'tool2', description: 'Test tool 2', inputSchema: { type: 'object' } },
+ ]),
+ };
+
+ const mockMcpManager = {
+ disconnectUserConnection: jest.fn().mockResolvedValue(),
+ getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
+ };
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ endpoint: 'http://test-server.com',
+ });
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ require('~/config').getFlowStateManager.mockReturnValue({});
+ require('~/cache').getLogStores.mockReturnValue({});
+
+ const { getCachedTools, setCachedTools } = require('~/server/services/Config');
+ const { updateMCPServerTools } = require('~/server/services/Config/mcp');
+ getCachedTools.mockResolvedValue({});
+ setCachedTools.mockResolvedValue();
+ updateMCPServerTools.mockResolvedValue();
+
+ require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
+ success: true,
+ message: "MCP server 'test-server' reinitialized successfully",
+ serverName: 'test-server',
+ oauthRequired: false,
+ oauthUrl: null,
+ });
+
+ const response = await request(app).post('/api/mcp/test-server/reinitialize');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ message: "MCP server 'test-server' reinitialized successfully",
+ serverName: 'test-server',
+ oauthRequired: false,
+ oauthUrl: null,
+ });
+ expect(mockMcpManager.disconnectUserConnection).toHaveBeenCalledWith(
+ 'test-user-id',
+ 'test-server',
+ );
+ });
+
+ it('should handle server with custom user variables', async () => {
+ const mockUserConnection = {
+ fetchTools: jest.fn().mockResolvedValue([]),
+ };
+
+ const mockMcpManager = {
+ disconnectUserConnection: jest.fn().mockResolvedValue(),
+ getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
+ };
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ endpoint: 'http://test-server.com',
+ customUserVars: {
+ API_KEY: 'some-env-var',
+ },
+ });
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ require('~/config').getFlowStateManager.mockReturnValue({});
+ require('~/cache').getLogStores.mockReturnValue({});
+ require('@librechat/api').getUserMCPAuthMap.mockResolvedValue({
+ 'mcp:test-server': {
+ API_KEY: 'api-key-value',
+ },
+ });
+ require('~/models').findPluginAuthsByKeys.mockResolvedValue([
+ { key: 'API_KEY', value: 'api-key-value' },
+ ]);
+
+ const { getCachedTools, setCachedTools } = require('~/server/services/Config');
+ const { updateMCPServerTools } = require('~/server/services/Config/mcp');
+ getCachedTools.mockResolvedValue({});
+ setCachedTools.mockResolvedValue();
+ updateMCPServerTools.mockResolvedValue();
+
+ require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
+ success: true,
+ message: "MCP server 'test-server' reinitialized successfully",
+ serverName: 'test-server',
+ oauthRequired: false,
+ oauthUrl: null,
+ });
+
+ const response = await request(app).post('/api/mcp/test-server/reinitialize');
+
+ expect(response.status).toBe(200);
+ expect(response.body.success).toBe(true);
+ expect(require('@librechat/api').getUserMCPAuthMap).toHaveBeenCalledWith({
+ userId: 'test-user-id',
+ servers: ['test-server'],
+ findPluginAuthsByKeys: require('~/models').findPluginAuthsByKeys,
+ });
+ });
+ });
+
+ describe('GET /connection/status', () => {
+ const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
+
+ it('should return connection status for all servers', async () => {
+ const mockMcpConfig = {
+ server1: { endpoint: 'http://server1.com' },
+ server2: { endpoint: 'http://server2.com' },
+ };
+
+ getMCPSetupData.mockResolvedValue({
+ mcpConfig: mockMcpConfig,
+ appConnections: {},
+ userConnections: {},
+ oauthServers: [],
+ });
+
+ getServerConnectionStatus
+ .mockResolvedValueOnce({
+ connectionState: 'connected',
+ requiresOAuth: false,
+ })
+ .mockResolvedValueOnce({
+ connectionState: 'disconnected',
+ requiresOAuth: true,
+ });
+
+ const response = await request(app).get('/api/mcp/connection/status');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ oauthTimeout: expect.any(Number),
+ connectionStatus: {
+ server1: {
+ connectionState: 'connected',
+ requiresOAuth: false,
+ },
+ server2: {
+ connectionState: 'disconnected',
+ requiresOAuth: true,
+ },
+ },
+ });
+
+ expect(getMCPSetupData).toHaveBeenCalledWith('test-user-id', expect.any(Object));
+ expect(getServerConnectionStatus).toHaveBeenCalledTimes(2);
+ });
+
+ it('should return 500 when connection status check fails', async () => {
+ getMCPSetupData.mockRejectedValue(new Error('Database error'));
+
+ const response = await request(app).get('/api/mcp/connection/status');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Failed to get connection status' });
+ });
+
+ it('should return 401 when user is not authenticated', async () => {
+ const unauthApp = express();
+ unauthApp.use(express.json());
+ unauthApp.use((req, res, next) => {
+ req.user = null;
+ next();
+ });
+ unauthApp.use('/api/mcp', mcpRouter);
+
+ const response = await request(unauthApp).get('/api/mcp/connection/status');
+
+ expect(response.status).toBe(401);
+ expect(response.body).toEqual({ error: 'User not authenticated' });
+ });
+ });
+
+ describe('GET /connection/status/:serverName', () => {
+ const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
+
+ it('should return connection status for OAuth-required server', async () => {
+ const mockMcpConfig = {
+ 'oauth-server': { endpoint: 'http://oauth-server.com' },
+ };
+
+ getMCPSetupData.mockResolvedValue({
+ mcpConfig: mockMcpConfig,
+ appConnections: {},
+ userConnections: {},
+ oauthServers: [],
+ });
+
+ getServerConnectionStatus.mockResolvedValue({
+ connectionState: 'requires_auth',
+ requiresOAuth: true,
+ });
+
+ const response = await request(app).get('/api/mcp/connection/status/oauth-server');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ serverName: 'oauth-server',
+ connectionStatus: 'requires_auth',
+ requiresOAuth: true,
+ });
+ });
+
+ it('should return 404 when server is not found in configuration', async () => {
+ getMCPSetupData.mockResolvedValue({
+ mcpConfig: {
+ 'other-server': { endpoint: 'http://other-server.com' },
+ },
+ appConnections: {},
+ userConnections: {},
+ oauthServers: [],
+ });
+
+ const response = await request(app).get('/api/mcp/connection/status/non-existent-server');
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({
+ error: "MCP server 'non-existent-server' not found in configuration",
+ });
+ });
+
+ it('should return 500 when connection status check fails', async () => {
+ getMCPSetupData.mockRejectedValue(new Error('Database connection failed'));
+
+ const response = await request(app).get('/api/mcp/connection/status/test-server');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Failed to get connection status' });
+ });
+
+ it('should return 401 when user is not authenticated', async () => {
+ const unauthApp = express();
+ unauthApp.use(express.json());
+ unauthApp.use((req, res, next) => {
+ req.user = null;
+ next();
+ });
+ unauthApp.use('/api/mcp', mcpRouter);
+
+ const response = await request(unauthApp).get('/api/mcp/connection/status/test-server');
+
+ expect(response.status).toBe(401);
+ expect(response.body).toEqual({ error: 'User not authenticated' });
+ });
+ });
+
+ describe('GET /:serverName/auth-values', () => {
+ const { getUserPluginAuthValue } = require('~/server/services/PluginService');
+ // mockRegistryInstance is defined at the top of the file
+
+ it('should return auth value flags for server', async () => {
+ const mockMcpManager = {};
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ customUserVars: {
+ API_KEY: 'some-env-var',
+ SECRET_TOKEN: 'another-env-var',
+ },
+ });
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ getUserPluginAuthValue.mockResolvedValueOnce('some-api-key-value').mockResolvedValueOnce('');
+
+ const response = await request(app).get('/api/mcp/test-server/auth-values');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ serverName: 'test-server',
+ authValueFlags: {
+ API_KEY: true,
+ SECRET_TOKEN: false,
+ },
+ });
+
+ expect(getUserPluginAuthValue).toHaveBeenCalledTimes(2);
+ });
+
+ it('should return 404 when server is not found in configuration', async () => {
+ const mockMcpManager = {};
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue(null);
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const response = await request(app).get('/api/mcp/non-existent-server/auth-values');
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({
+ error: "MCP server 'non-existent-server' not found in configuration",
+ });
+ });
+
+ it('should handle errors when checking auth values', async () => {
+ const mockMcpManager = {};
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ customUserVars: {
+ API_KEY: 'some-env-var',
+ },
+ });
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ getUserPluginAuthValue.mockRejectedValue(new Error('Database error'));
+
+ const response = await request(app).get('/api/mcp/test-server/auth-values');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ serverName: 'test-server',
+ authValueFlags: {
+ API_KEY: false,
+ },
+ });
+ });
+
+ it('should return 500 when auth values check throws unexpected error', async () => {
+ const mockMcpManager = {};
+
+ mockRegistryInstance.getServerConfig.mockImplementation(() => {
+ throw new Error('Config loading failed');
+ });
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const response = await request(app).get('/api/mcp/test-server/auth-values');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Failed to check auth value flags' });
+ });
+
+ it('should handle customUserVars that is not an object', async () => {
+ const mockMcpManager = {};
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ customUserVars: 'not-an-object',
+ });
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const response = await request(app).get('/api/mcp/test-server/auth-values');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ serverName: 'test-server',
+ authValueFlags: {},
+ });
+ });
+
+ it('should return 401 when user is not authenticated in auth-values endpoint', async () => {
+ const appWithoutAuth = express();
+ appWithoutAuth.use(express.json());
+ appWithoutAuth.use('/api/mcp', mcpRouter);
+
+ const response = await request(appWithoutAuth).get('/api/mcp/test-server/auth-values');
+
+ expect(response.status).toBe(401);
+ expect(response.body).toEqual({ error: 'User not authenticated' });
+ });
+ });
+
+ describe('GET /:serverName/oauth/callback - Edge Cases', () => {
+ it('should handle OAuth callback without toolFlowId (falsy toolFlowId)', async () => {
+ const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
+ const mockTokens = {
+ access_token: 'edge-access-token',
+ refresh_token: 'edge-refresh-token',
+ };
+ MCPOAuthHandler.getFlowState = jest.fn().mockResolvedValue({
+ id: 'test-user-id:test-server',
+ userId: 'test-user-id',
+ metadata: {
+ serverUrl: 'https://example.com',
+ oauth: {},
+ // No toolFlowId property
+ },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ });
+ MCPOAuthHandler.completeOAuthFlow = jest.fn().mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
+ completeFlow: jest.fn(),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const mockMcpManager = {
+ getUserConnection: jest.fn().mockResolvedValue({
+ fetchTools: jest.fn().mockResolvedValue([]),
+ }),
+ };
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const flowId = 'test-user-id:test-server';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ const response = await request(app)
+ .get(`/api/mcp/test-server/oauth/callback?code=test-code&state=${flowId}`)
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .expect(302);
+
+ const basePath = getBasePath();
+
+ expect(mockFlowManager.completeFlow).not.toHaveBeenCalled();
+ expect(response.headers.location).toContain(`${basePath}/oauth/success`);
+ });
+
+ it('should handle null cached tools in OAuth callback (triggers || {} fallback)', async () => {
+ const { getCachedTools } = require('~/server/services/Config');
+ getCachedTools.mockResolvedValue(null);
+ const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
+ const mockTokens = {
+ access_token: 'edge-access-token',
+ refresh_token: 'edge-refresh-token',
+ };
+
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ id: 'test-user-id:test-server',
+ userId: 'test-user-id',
+ metadata: { serverUrl: 'https://example.com', oauth: {} },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ }),
+ completeFlow: jest.fn(),
+ };
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ MCPOAuthHandler.getFlowState.mockResolvedValue({
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: { serverUrl: 'https://example.com', oauth: {} },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ });
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+
+ const mockMcpManager = {
+ getUserConnection: jest.fn().mockResolvedValue({
+ fetchTools: jest
+ .fn()
+ .mockResolvedValue([{ name: 'test-tool', description: 'Test tool' }]),
+ }),
+ };
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const flowId = 'test-user-id:test-server';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ const response = await request(app)
+ .get(`/api/mcp/test-server/oauth/callback?code=test-code&state=${flowId}`)
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .expect(302);
+
+ const basePath = getBasePath();
+
+ expect(response.headers.location).toContain(`${basePath}/oauth/success`);
+ });
+ });
+
+ describe('GET /:serverName/oauth/callback - Tenant Context', () => {
+ beforeEach(() => {
+ const { getTenantId, tenantStorage } = require('@librechat/data-schemas');
+ const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
+ getTenantId.mockReset();
+ tenantStorage.run.mockReset();
+ tenantStorage.run.mockImplementation((store, fn) => fn());
+ MCPOAuthHandler.resolveStateToFlowId.mockReset();
+ MCPOAuthHandler.getFlowState.mockReset();
+ MCPOAuthHandler.completeOAuthFlow.mockReset();
+ MCPTokenStorage.storeTokens.mockReset();
+ });
+
+ it('should wrap callback body in tenantStorage.run when flowState has tenantId and no current context', async () => {
+ const { getTenantId, tenantStorage } = require('@librechat/data-schemas');
+ const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
+ const flowId = 'user123:test-server';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ getTenantId.mockReturnValue(undefined);
+
+ MCPOAuthHandler.resolveStateToFlowId.mockResolvedValue(flowId);
+ MCPOAuthHandler.getFlowState.mockResolvedValue({
+ serverName: 'test-server',
+ userId: 'user123',
+ tenantId: 'tenant-abc',
+ metadata: {},
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ });
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue({
+ access_token: 'token',
+ token_type: 'bearer',
+ });
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+
+ const response = await request(app)
+ .get(`/api/mcp/test-server/oauth/callback?code=test-code&state=${flowId}`)
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .expect(302);
+
+ expect(tenantStorage.run).toHaveBeenCalledWith(
+ { tenantId: 'tenant-abc' },
+ expect.any(Function),
+ );
+ expect(MCPTokenStorage.storeTokens).toHaveBeenCalled();
+
+ const basePath = getBasePath();
+ expect(response.headers.location).toContain(`${basePath}/oauth/success`);
+ });
+
+ it('should not call tenantStorage.run when flowState has no tenantId', async () => {
+ const { getTenantId, tenantStorage } = require('@librechat/data-schemas');
+ const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
+ const flowId = 'user123:test-server';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ getTenantId.mockReturnValue(undefined);
+
+ MCPOAuthHandler.resolveStateToFlowId.mockResolvedValue(flowId);
+ MCPOAuthHandler.getFlowState.mockResolvedValue({
+ serverName: 'test-server',
+ userId: 'user123',
+ metadata: {},
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ });
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue({
+ access_token: 'token',
+ token_type: 'bearer',
+ });
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+
+ await request(app)
+ .get(`/api/mcp/test-server/oauth/callback?code=test-code&state=${flowId}`)
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .expect(302);
+
+ expect(tenantStorage.run).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('GET /tools', () => {
+ it('should deny MCP tools when user lacks MCP server use permission', async () => {
+ mockMCPUseAllowed = false;
+
+ const response = await request(app).get('/api/mcp/tools');
+
+ expect(response.status).toBe(403);
+ expect(response.body).toEqual({ message: 'Forbidden: Insufficient permissions' });
+ expect(mockResolveAllMcpConfigs).not.toHaveBeenCalled();
+ });
+
+ it('should continue returning MCP tools when one server cache lookup fails', async () => {
+ const { Constants } = require('librechat-data-provider');
+ const { logger } = require('@librechat/data-schemas');
+ const { getMCPServerTools } = require('~/server/services/Config');
+
+ mockResolveAllMcpConfigs.mockResolvedValueOnce({
+ 'bad-server': {
+ type: 'sse',
+ url: 'https://bad.example.com/sse',
+ },
+ 'good-server': {
+ type: 'sse',
+ url: 'https://good.example.com/sse',
+ iconPath: '/icons/good.svg',
+ },
+ });
+
+ // Mock order matches Object.keys() order from the config above.
+ getMCPServerTools
+ .mockRejectedValueOnce(new Error('cache unavailable'))
+ .mockResolvedValueOnce({
+ [`search${Constants.mcp_delimiter}good-server`]: {
+ type: 'function',
+ function: {
+ name: `search${Constants.mcp_delimiter}good-server`,
+ description: 'Search good server',
+ parameters: { type: 'object' },
+ },
+ },
+ });
+
+ const mockGetServerToolFunctions = jest.fn().mockResolvedValue(null);
+ require('~/config').getMCPManager.mockReturnValue({
+ getServerToolFunctions: mockGetServerToolFunctions,
+ });
+
+ const response = await request(app).get('/api/mcp/tools');
+
+ expect(response.status).toBe(200);
+ expect(logger.error).toHaveBeenCalledWith(
+ '[getMCPTools] Error fetching cached tools for bad-server:',
+ expect.any(Error),
+ );
+ expect(mockGetServerToolFunctions).toHaveBeenCalledWith('test-user-id', 'bad-server');
+ expect(response.body.servers['good-server']).toMatchObject({
+ name: 'good-server',
+ icon: '/icons/good.svg',
+ tools: [
+ {
+ name: 'search',
+ pluginKey: `search${Constants.mcp_delimiter}good-server`,
+ description: 'Search good server',
+ },
+ ],
+ });
+ expect(response.body.servers['bad-server']).toMatchObject({
+ name: 'bad-server',
+ tools: [],
+ });
+ });
+
+ it('should return configured servers when all cache lookups fail', async () => {
+ const { logger } = require('@librechat/data-schemas');
+ const { getMCPServerTools } = require('~/server/services/Config');
+
+ mockResolveAllMcpConfigs.mockResolvedValueOnce({
+ 'first-server': {
+ type: 'sse',
+ url: 'https://first.example.com/sse',
+ },
+ 'second-server': {
+ type: 'sse',
+ url: 'https://second.example.com/sse',
+ },
+ });
+
+ getMCPServerTools.mockRejectedValue(new Error('cache unavailable'));
+
+ const mockGetServerToolFunctions = jest.fn().mockResolvedValue(null);
+ require('~/config').getMCPManager.mockReturnValue({
+ getServerToolFunctions: mockGetServerToolFunctions,
+ });
+
+ const response = await request(app).get('/api/mcp/tools');
+
+ expect(response.status).toBe(200);
+ expect(response.body.servers['first-server']).toMatchObject({
+ name: 'first-server',
+ tools: [],
+ });
+ expect(response.body.servers['second-server']).toMatchObject({
+ name: 'second-server',
+ tools: [],
+ });
+ expect(logger.error).toHaveBeenCalledTimes(2);
+ expect(mockGetServerToolFunctions).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('GET /servers', () => {
+ // mockRegistryInstance is defined at the top of the file
+
+ it('should return all server configs for authenticated user', async () => {
+ const mockServerConfigs = {
+ 'server-1': {
+ type: 'sse',
+ url: 'http://server1.com/sse',
+ title: 'Server 1',
+ source: 'user',
+ },
+ 'server-2': {
+ type: 'sse',
+ url: 'http://server2.com/sse',
+ title: 'Server 2',
+ source: 'user',
+ },
+ };
+
+ mockResolveAllMcpConfigs.mockResolvedValue(mockServerConfigs);
+
+ const response = await request(app).get('/api/mcp/servers');
+
+ expect(response.status).toBe(200);
+ expect(response.body['server-1']).toMatchObject({
+ type: 'sse',
+ url: 'http://server1.com/sse',
+ title: 'Server 1',
+ });
+ expect(response.body['server-2']).toMatchObject({
+ type: 'sse',
+ url: 'http://server2.com/sse',
+ title: 'Server 2',
+ });
+ expect(response.body['server-1'].headers).toBeUndefined();
+ expect(response.body['server-2'].headers).toBeUndefined();
+ expect(mockResolveAllMcpConfigs).toHaveBeenCalledWith(
+ 'test-user-id',
+ expect.objectContaining({ id: 'test-user-id' }),
+ );
+ });
+
+ it('should return empty object when no servers are configured', async () => {
+ mockResolveAllMcpConfigs.mockResolvedValue({});
+
+ const response = await request(app).get('/api/mcp/servers');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({});
+ });
+
+ it('should return 401 when user is not authenticated', async () => {
+ const unauthApp = express();
+ unauthApp.use(express.json());
+ unauthApp.use((req, _res, next) => {
+ req.user = null;
+ next();
+ });
+ unauthApp.use('/api/mcp', mcpRouter);
+
+ const response = await request(unauthApp).get('/api/mcp/servers');
+
+ expect(response.status).toBe(401);
+ expect(response.body).toEqual({ message: 'Unauthorized' });
+ });
+
+ it('should return 500 when server config retrieval fails', async () => {
+ mockResolveAllMcpConfigs.mockRejectedValue(new Error('Database error'));
+
+ const response = await request(app).get('/api/mcp/servers');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Database error' });
+ });
+ });
+
+ describe('POST /servers', () => {
+ it('should create MCP server with valid SSE config', async () => {
+ const validConfig = {
+ type: 'sse',
+ url: 'https://mcp-server.example.com/sse',
+ title: 'Test SSE Server',
+ description: 'A test SSE server',
+ };
+
+ mockRegistryInstance.addServer.mockResolvedValue({
+ serverName: 'test-sse-server',
+ config: { ...validConfig, source: 'user' },
+ });
+
+ const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
+
+ expect(response.status).toBe(201);
+ expect(response.body.serverName).toBe('test-sse-server');
+ expect(response.body.type).toBe('sse');
+ expect(response.body.url).toBe('https://mcp-server.example.com/sse');
+ expect(response.body.title).toBe('Test SSE Server');
+ expect(mockRegistryInstance.addServer).toHaveBeenCalledWith(
+ 'temp_server_name',
+ expect.objectContaining({
+ type: 'sse',
+ url: 'https://mcp-server.example.com/sse',
+ }),
+ 'DB',
+ 'test-user-id',
+ [],
+ );
+ });
+
+ it('should reserve config-managed server names when creating MCP server', async () => {
+ const validConfig = {
+ type: 'sse',
+ url: 'https://mcp-server.example.com/sse',
+ title: 'Test SSE Server',
+ };
+
+ mockResolveMcpConfigNames.mockResolvedValueOnce(['config_slack']);
+ mockRegistryInstance.addServer.mockResolvedValue({
+ serverName: 'test-sse-server',
+ config: validConfig,
+ });
+
+ const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
+
+ expect(response.status).toBe(201);
+ expect(mockRegistryInstance.addServer).toHaveBeenCalledWith(
+ 'temp_server_name',
+ expect.objectContaining({
+ type: 'sse',
+ url: 'https://mcp-server.example.com/sse',
+ }),
+ 'DB',
+ 'test-user-id',
+ ['config_slack'],
+ );
+ });
+
+ it('should reject stdio config for security reasons', async () => {
+ const stdioConfig = {
+ type: 'stdio',
+ command: 'node',
+ args: ['server.js'],
+ title: 'Test Stdio Server',
+ };
+
+ const response = await request(app).post('/api/mcp/servers').send({ config: stdioConfig });
+
+ // Stdio transport is not allowed via API - only admins can configure it via YAML
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Invalid configuration');
+ });
+
+ it('should return 400 for invalid configuration', async () => {
+ const invalidConfig = {
+ type: 'sse',
+ // Missing required 'url' field
+ title: 'Invalid Server',
+ };
+
+ const response = await request(app).post('/api/mcp/servers').send({ config: invalidConfig });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Invalid configuration');
+ expect(response.body.errors).toBeDefined();
+ });
+
+ it('should return 400 for SSE config with invalid URL protocol', async () => {
+ const invalidConfig = {
+ type: 'sse',
+ url: 'ws://invalid-protocol.example.com/sse',
+ title: 'Invalid Protocol Server',
+ };
+
+ const response = await request(app).post('/api/mcp/servers').send({ config: invalidConfig });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Invalid configuration');
+ });
+
+ it('should reject SSE URL containing env variable references', async () => {
+ const response = await request(app)
+ .post('/api/mcp/servers')
+ .send({
+ config: {
+ type: 'sse',
+ url: 'http://attacker.com/?secret=${JWT_SECRET}',
+ },
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Invalid configuration');
+ expect(mockRegistryInstance.addServer).not.toHaveBeenCalled();
+ });
+
+ it('should reject streamable-http URL containing env variable references', async () => {
+ const response = await request(app)
+ .post('/api/mcp/servers')
+ .send({
+ config: {
+ type: 'streamable-http',
+ url: 'http://attacker.com/?key=${CREDS_KEY}&iv=${CREDS_IV}',
+ },
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Invalid configuration');
+ expect(mockRegistryInstance.addServer).not.toHaveBeenCalled();
+ });
+
+ it('should reject websocket URL containing env variable references', async () => {
+ const response = await request(app)
+ .post('/api/mcp/servers')
+ .send({
+ config: {
+ type: 'websocket',
+ url: 'ws://attacker.com/?secret=${MONGO_URI}',
+ },
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Invalid configuration');
+ expect(mockRegistryInstance.addServer).not.toHaveBeenCalled();
+ });
+
+ it('should redact secrets from create response', async () => {
+ const validConfig = {
+ type: 'sse',
+ url: 'https://mcp-server.example.com/sse',
+ title: 'Test Server',
+ };
+
+ mockRegistryInstance.addServer.mockResolvedValue({
+ serverName: 'test-server',
+ config: {
+ ...validConfig,
+ apiKey: { source: 'admin', authorization_type: 'bearer', key: 'admin-secret-key' },
+ oauth: { client_id: 'cid', client_secret: 'admin-oauth-secret' },
+ headers: { Authorization: 'Bearer leaked-token' },
+ },
+ });
+
+ const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
+
+ expect(response.status).toBe(201);
+ expect(response.body.apiKey?.key).toBeUndefined();
+ expect(response.body.oauth?.client_secret).toBeUndefined();
+ expect(response.body.headers).toBeUndefined();
+ expect(response.body.apiKey?.source).toBe('admin');
+ expect(response.body.oauth?.client_id).toBe('cid');
+ });
+
+ it('should return 500 when registry throws error', async () => {
+ const validConfig = {
+ type: 'sse',
+ url: 'https://mcp-server.example.com/sse',
+ title: 'Test Server',
+ };
+
+ mockRegistryInstance.addServer.mockRejectedValue(new Error('Database connection failed'));
+
+ const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ message: 'Database connection failed' });
+ });
+
+ describe('OBO permission gate', () => {
+ const oboConfig = {
+ type: 'streamable-http',
+ url: 'https://mcp-server.example.com/mcp',
+ title: 'OBO Server',
+ obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
+ };
+ const db = require('~/models');
+
+ beforeEach(() => {
+ currentUser = { id: 'test-user-id', role: 'USER' };
+ mockRegistryInstance.addServer.mockResolvedValue({
+ serverName: 'obo-server',
+ config: oboConfig,
+ });
+ });
+
+ it('rejects POST with obo body when role lacks CONFIGURE_OBO', async () => {
+ db.getRoleByName.mockResolvedValue({
+ name: 'USER',
+ permissions: {
+ MCP_SERVERS: {
+ USE: true,
+ CREATE: true,
+ SHARE: false,
+ SHARE_PUBLIC: false,
+ CONFIGURE_OBO: false,
+ },
+ },
+ });
+
+ const response = await request(app).post('/api/mcp/servers').send({ config: oboConfig });
+
+ expect(response.status).toBe(403);
+ expect(response.body.message).toMatch(/Insufficient permissions to configure OBO/);
+ expect(mockRegistryInstance.addServer).not.toHaveBeenCalled();
+ });
+
+ it('allows POST with obo body when role has CONFIGURE_OBO', async () => {
+ db.getRoleByName.mockResolvedValue({
+ name: 'USER',
+ permissions: {
+ MCP_SERVERS: {
+ USE: true,
+ CREATE: true,
+ SHARE: false,
+ SHARE_PUBLIC: false,
+ CONFIGURE_OBO: true,
+ },
+ },
+ });
+
+ const response = await request(app).post('/api/mcp/servers').send({ config: oboConfig });
+
+ expect(response.status).toBe(201);
+ expect(mockRegistryInstance.addServer).toHaveBeenCalled();
+ });
+
+ it('allows POST without obo body regardless of CONFIGURE_OBO', async () => {
+ db.getRoleByName.mockResolvedValue({
+ name: 'USER',
+ permissions: {
+ MCP_SERVERS: {
+ USE: true,
+ CREATE: true,
+ CONFIGURE_OBO: false,
+ },
+ },
+ });
+
+ const nonOboConfig = {
+ type: 'streamable-http',
+ url: 'https://mcp-server.example.com/mcp',
+ title: 'Plain Server',
+ };
+ mockRegistryInstance.addServer.mockResolvedValue({
+ serverName: 'plain-server',
+ config: nonOboConfig,
+ });
+
+ const response = await request(app).post('/api/mcp/servers').send({ config: nonOboConfig });
+
+ expect(response.status).toBe(201);
+ expect(db.getRoleByName).not.toHaveBeenCalled();
+ expect(mockRegistryInstance.addServer).toHaveBeenCalled();
+ });
+
+ it('rejects PATCH with obo body when role lacks CONFIGURE_OBO', async () => {
+ db.getRoleByName.mockResolvedValue({
+ name: 'USER',
+ permissions: {
+ MCP_SERVERS: {
+ USE: true,
+ CREATE: true,
+ CONFIGURE_OBO: false,
+ },
+ },
+ });
+
+ const response = await request(app)
+ .patch('/api/mcp/servers/obo-server')
+ .send({ config: oboConfig });
+
+ expect(response.status).toBe(403);
+ expect(response.body.message).toMatch(/Insufficient permissions to configure OBO/);
+ expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
+ });
+
+ it('allows PATCH without CONFIGURE_OBO when OBO is unchanged', async () => {
+ // Editor without CONFIGURE_OBO should still be able to edit non-OBO fields
+ // (title, URL, description) on an OBO server as long as the OBO block is
+ // re-sent unchanged. Closes the regression where any save of an OBO server
+ // by such a user was rejected even when OBO itself was not being modified.
+ db.getRoleByName.mockResolvedValue({
+ name: 'USER',
+ permissions: {
+ MCP_SERVERS: {
+ USE: true,
+ CREATE: true,
+ CONFIGURE_OBO: false,
+ },
+ },
+ });
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ ...oboConfig,
+ obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
+ });
+ mockRegistryInstance.updateServer.mockResolvedValue({
+ ...oboConfig,
+ title: 'Renamed OBO Server',
+ });
+
+ const response = await request(app)
+ .patch('/api/mcp/servers/obo-server')
+ .send({
+ config: {
+ ...oboConfig,
+ title: 'Renamed OBO Server',
+ obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
+ },
+ });
+
+ expect(response.status).toBe(200);
+ expect(mockRegistryInstance.updateServer).toHaveBeenCalled();
+ });
+
+ it('rejects PATCH that removes OBO from an existing OBO server without CONFIGURE_OBO', async () => {
+ // Closes the silent-downgrade vector: a user with UPDATE but not
+ // CONFIGURE_OBO must not be able to convert an OBO server to non-OBO,
+ // because doing so de-secures the server end-to-end.
+ db.getRoleByName.mockResolvedValue({
+ name: 'USER',
+ permissions: {
+ MCP_SERVERS: {
+ USE: true,
+ CREATE: true,
+ CONFIGURE_OBO: false,
+ },
+ },
+ });
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ ...oboConfig,
+ obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
+ });
+
+ // Submit body that omits the obo field (auth_type changed away from OBO)
+ const downgradePayload = {
+ type: 'streamable-http',
+ url: 'https://mcp-server.example.com/mcp',
+ title: 'OBO Server',
+ };
+ const response = await request(app)
+ .patch('/api/mcp/servers/obo-server')
+ .send({ config: downgradePayload });
+
+ expect(response.status).toBe(403);
+ expect(response.body.message).toMatch(/Insufficient permissions to configure OBO/);
+ expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
+ });
+
+ it('rejects PATCH that redirects the URL of an existing OBO server without CONFIGURE_OBO', async () => {
+ // Closes the OBO redirect vector — the original trust-boundary concern
+ // CONFIGURE_OBO was introduced to address. A user with UPDATE but
+ // without the permission must not be able to point an existing OBO
+ // server at an attacker-controlled endpoint, which would cause OBO
+ // tokens minted for other users to be exfiltrated to that endpoint.
+ // The same allowlist policy also covers `proxy`, `headers`, transport
+ // type, and auth blocks.
+ db.getRoleByName.mockResolvedValue({
+ name: 'USER',
+ permissions: {
+ MCP_SERVERS: {
+ USE: true,
+ CREATE: true,
+ CONFIGURE_OBO: false,
+ },
+ },
+ });
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ ...oboConfig,
+ obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
+ });
+
+ const redirectPayload = {
+ ...oboConfig,
+ url: 'https://attacker.example.com/mcp',
+ obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
+ };
+ const response = await request(app)
+ .patch('/api/mcp/servers/obo-server')
+ .send({ config: redirectPayload });
+
+ expect(response.status).toBe(403);
+ expect(response.body.message).toMatch(/Insufficient permissions to configure OBO/);
+ expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
+ });
+ });
+
+ it('should fail closed when config-managed names cannot be resolved', async () => {
+ const validConfig = {
+ type: 'sse',
+ url: 'https://mcp-server.example.com/sse',
+ title: 'Test Server',
+ };
+
+ mockResolveMcpConfigNames.mockRejectedValueOnce(new Error('Config lookup failed'));
+
+ const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ message: 'Config lookup failed' });
+ expect(mockRegistryInstance.addServer).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('GET /servers/:serverName', () => {
+ it('should return server config when found', async () => {
+ const mockConfig = {
+ type: 'sse',
+ url: 'https://mcp-server.example.com/sse',
+ title: 'Test Server',
+ source: 'user',
+ };
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue(mockConfig);
+
+ const response = await request(app).get('/api/mcp/servers/test-server');
+
+ expect(response.status).toBe(200);
+ expect(response.body.type).toBe('sse');
+ expect(response.body.url).toBe('https://mcp-server.example.com/sse');
+ expect(response.body.title).toBe('Test Server');
+ expect(mockRegistryInstance.getServerConfig).toHaveBeenCalledWith(
+ 'test-server',
+ 'test-user-id',
+ {},
+ );
+ });
+
+ it('should return 404 when server not found', async () => {
+ mockRegistryInstance.getServerConfig.mockResolvedValue(undefined);
+
+ const response = await request(app).get('/api/mcp/servers/non-existent-server');
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({ message: 'MCP server not found' });
+ });
+
+ it('should redact secrets from get response', async () => {
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ type: 'sse',
+ url: 'https://mcp-server.example.com/sse',
+ title: 'Secret Server',
+ apiKey: { source: 'admin', authorization_type: 'bearer', key: 'decrypted-admin-key' },
+ oauth: { client_id: 'cid', client_secret: 'decrypted-oauth-secret' },
+ headers: { Authorization: 'Bearer internal-token' },
+ oauth_headers: { 'X-OAuth': 'secret-value' },
+ });
+
+ const response = await request(app).get('/api/mcp/servers/secret-server');
+
+ expect(response.status).toBe(200);
+ expect(response.body.title).toBe('Secret Server');
+ expect(response.body.apiKey?.key).toBeUndefined();
+ expect(response.body.apiKey?.source).toBe('admin');
+ expect(response.body.oauth?.client_secret).toBeUndefined();
+ expect(response.body.oauth?.client_id).toBe('cid');
+ expect(response.body.headers).toBeUndefined();
+ expect(response.body.oauth_headers).toBeUndefined();
+ });
+
+ it('should return 500 when registry throws error', async () => {
+ mockRegistryInstance.getServerConfig.mockRejectedValue(new Error('Database error'));
+
+ const response = await request(app).get('/api/mcp/servers/error-server');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ message: 'Database error' });
+ });
+ });
+
+ describe('PATCH /servers/:serverName', () => {
+ it('should update server with valid config', async () => {
+ const updatedConfig = {
+ type: 'sse',
+ url: 'https://updated-mcp-server.example.com/sse',
+ title: 'Updated Server',
+ description: 'Updated description',
+ };
+
+ mockRegistryInstance.updateServer.mockResolvedValue({ ...updatedConfig, source: 'user' });
+
+ const response = await request(app)
+ .patch('/api/mcp/servers/test-server')
+ .send({ config: updatedConfig });
+
+ expect(response.status).toBe(200);
+ expect(response.body.type).toBe('sse');
+ expect(response.body.url).toBe('https://updated-mcp-server.example.com/sse');
+ expect(response.body.title).toBe('Updated Server');
+ expect(mockRegistryInstance.updateServer).toHaveBeenCalledWith(
+ 'test-server',
+ expect.objectContaining({
+ type: 'sse',
+ url: 'https://updated-mcp-server.example.com/sse',
+ }),
+ 'DB',
+ 'test-user-id',
+ );
+ });
+
+ it('should redact secrets from update response', async () => {
+ const validConfig = {
+ type: 'sse',
+ url: 'https://mcp-server.example.com/sse',
+ title: 'Updated Server',
+ };
+
+ mockRegistryInstance.updateServer.mockResolvedValue({
+ ...validConfig,
+ apiKey: { source: 'admin', authorization_type: 'bearer', key: 'preserved-admin-key' },
+ oauth: { client_id: 'cid', client_secret: 'preserved-oauth-secret' },
+ headers: { Authorization: 'Bearer internal-token' },
+ env: { DATABASE_URL: 'postgres://admin:pass@localhost/db' },
+ });
+
+ const response = await request(app)
+ .patch('/api/mcp/servers/test-server')
+ .send({ config: validConfig });
+
+ expect(response.status).toBe(200);
+ expect(response.body.title).toBe('Updated Server');
+ expect(response.body.apiKey?.key).toBeUndefined();
+ expect(response.body.apiKey?.source).toBe('admin');
+ expect(response.body.oauth?.client_secret).toBeUndefined();
+ expect(response.body.oauth?.client_id).toBe('cid');
+ expect(response.body.headers).toBeUndefined();
+ expect(response.body.env).toBeUndefined();
+ });
+
+ it('should return 400 for invalid configuration', async () => {
+ const invalidConfig = {
+ type: 'sse',
+ // Missing required 'url' field
+ title: 'Invalid Update',
+ };
+
+ const response = await request(app)
+ .patch('/api/mcp/servers/test-server')
+ .send({ config: invalidConfig });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Invalid configuration');
+ expect(response.body.errors).toBeDefined();
+ });
+
+ it('should reject SSE URL containing env variable references', async () => {
+ const response = await request(app)
+ .patch('/api/mcp/servers/test-server')
+ .send({
+ config: {
+ type: 'sse',
+ url: 'http://attacker.com/?secret=${JWT_SECRET}',
+ },
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Invalid configuration');
+ expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
+ });
+
+ it('should reject streamable-http URL containing env variable references', async () => {
+ const response = await request(app)
+ .patch('/api/mcp/servers/test-server')
+ .send({
+ config: {
+ type: 'streamable-http',
+ url: 'http://attacker.com/?key=${CREDS_KEY}',
+ },
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Invalid configuration');
+ expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
+ });
+
+ it('should reject websocket URL containing env variable references', async () => {
+ const response = await request(app)
+ .patch('/api/mcp/servers/test-server')
+ .send({
+ config: {
+ type: 'websocket',
+ url: 'ws://attacker.com/?secret=${MONGO_URI}',
+ },
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Invalid configuration');
+ expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
+ });
+
+ it('should return 500 when registry throws error', async () => {
+ const validConfig = {
+ type: 'sse',
+ url: 'https://mcp-server.example.com/sse',
+ title: 'Test Server',
+ };
+
+ mockRegistryInstance.updateServer.mockRejectedValue(new Error('Update failed'));
+
+ const response = await request(app)
+ .patch('/api/mcp/servers/test-server')
+ .send({ config: validConfig });
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ message: 'Update failed' });
+ });
+ });
+
+ describe('DELETE /servers/:serverName', () => {
+ it('should delete server successfully', async () => {
+ mockRegistryInstance.removeServer.mockResolvedValue(undefined);
+
+ const response = await request(app).delete('/api/mcp/servers/test-server');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ message: 'MCP server deleted successfully' });
+ expect(mockRegistryInstance.removeServer).toHaveBeenCalledWith(
+ 'test-server',
+ 'DB',
+ 'test-user-id',
+ );
+ });
+
+ it('should return 500 when registry throws error', async () => {
+ mockRegistryInstance.removeServer.mockRejectedValue(new Error('Deletion failed'));
+
+ const response = await request(app).delete('/api/mcp/servers/error-server');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ message: 'Deletion failed' });
+ });
+ });
+});
diff --git a/api/server/routes/__tests__/messages-delete.spec.js b/api/server/routes/__tests__/messages-delete.spec.js
new file mode 100644
index 0000000..44ffaf5
--- /dev/null
+++ b/api/server/routes/__tests__/messages-delete.spec.js
@@ -0,0 +1,222 @@
+const mongoose = require('mongoose');
+const express = require('express');
+const request = require('supertest');
+const { v4: uuidv4 } = require('uuid');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+
+jest.mock('@librechat/agents', () => ({
+ sleep: jest.fn(),
+}));
+
+jest.mock('@librechat/api', () => ({
+ unescapeLaTeX: jest.fn((x) => x),
+ countTokens: jest.fn().mockResolvedValue(10),
+ sendFeedbackScore: jest.fn().mockResolvedValue(undefined),
+ traceIdForMessage: jest.fn((messageId) => `trace-${messageId}`),
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ logger: {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
+jest.mock('librechat-data-provider', () => ({
+ ...jest.requireActual('librechat-data-provider'),
+}));
+
+jest.mock('~/models', () => ({
+ saveConvo: jest.fn(),
+ getMessage: jest.fn(),
+ saveMessage: jest.fn(),
+ getMessages: jest.fn(),
+ updateMessage: jest.fn(),
+ deleteMessages: jest.fn(),
+ getConvosQueried: jest.fn(),
+ searchMessages: jest.fn(),
+ getMessagesByCursor: jest.fn(),
+}));
+
+jest.mock('~/server/services/Artifacts/update', () => ({
+ findAllArtifacts: jest.fn(),
+ replaceArtifactContent: jest.fn(),
+}));
+
+jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
+
+jest.mock('~/server/middleware', () => {
+ const validateMessageReq = jest.fn((req, res, next) => next());
+ const prepareMessageRequestValidation = jest.fn((req, res, next) => {
+ req.messageRequestValidation = {
+ conversationId: 'convo-1',
+ shouldFetchMessages: true,
+ promise: Promise.resolve({ ok: true }),
+ };
+ next();
+ });
+ const sendValidationResponse = jest.fn((res, result) => {
+ if (result.send) {
+ return res.status(result.status).send(result.body);
+ }
+ return res.status(result.status).json(result.body);
+ });
+
+ return {
+ requireJwtAuth: (req, res, next) => next(),
+ validateMessageReq,
+ sendValidationResponse,
+ prepareMessageRequestValidation,
+ configMiddleware: (req, res, next) => next(),
+ };
+});
+
+jest.mock('~/db/models', () => ({
+ Message: {
+ findOne: jest.fn(),
+ find: jest.fn(),
+ meiliSearch: jest.fn(),
+ },
+}));
+
+/* ─── Model-level tests: real MongoDB, proves cross-user deletion is prevented ─── */
+
+const { messageSchema } = require('@librechat/data-schemas');
+
+describe('deleteMessages – model-level IDOR prevention', () => {
+ let mongoServer;
+ let Message;
+
+ const ownerUserId = 'user-owner-111';
+ const attackerUserId = 'user-attacker-222';
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ Message = mongoose.models.Message || mongoose.model('Message', messageSchema);
+ await mongoose.connect(mongoServer.getUri());
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await Message.deleteMany({});
+ });
+
+ it("should NOT delete another user's message when attacker supplies victim messageId", async () => {
+ const conversationId = uuidv4();
+ const victimMsgId = 'victim-msg-001';
+
+ await Message.create({
+ messageId: victimMsgId,
+ conversationId,
+ user: ownerUserId,
+ text: 'Sensitive owner data',
+ });
+
+ await Message.deleteMany({ messageId: victimMsgId, user: attackerUserId });
+
+ const victimMsg = await Message.findOne({ messageId: victimMsgId }).lean();
+ expect(victimMsg).not.toBeNull();
+ expect(victimMsg.user).toBe(ownerUserId);
+ expect(victimMsg.text).toBe('Sensitive owner data');
+ });
+
+ it("should delete the user's own message", async () => {
+ const conversationId = uuidv4();
+ const ownMsgId = 'own-msg-001';
+
+ await Message.create({
+ messageId: ownMsgId,
+ conversationId,
+ user: ownerUserId,
+ text: 'My message',
+ });
+
+ const result = await Message.deleteMany({ messageId: ownMsgId, user: ownerUserId });
+ expect(result.deletedCount).toBe(1);
+
+ const deleted = await Message.findOne({ messageId: ownMsgId }).lean();
+ expect(deleted).toBeNull();
+ });
+
+ it('should scope deletion by conversationId, messageId, and user together', async () => {
+ const convoA = uuidv4();
+ const convoB = uuidv4();
+
+ await Message.create([
+ { messageId: 'msg-a1', conversationId: convoA, user: ownerUserId, text: 'A1' },
+ { messageId: 'msg-b1', conversationId: convoB, user: ownerUserId, text: 'B1' },
+ ]);
+
+ await Message.deleteMany({ messageId: 'msg-a1', conversationId: convoA, user: attackerUserId });
+
+ const remaining = await Message.find({ user: ownerUserId }).lean();
+ expect(remaining).toHaveLength(2);
+ });
+});
+
+/* ─── Route-level tests: supertest + mocked deleteMessages ─── */
+
+describe('DELETE /:conversationId/:messageId – route handler', () => {
+ let app;
+ const { deleteMessages } = require('~/models');
+
+ const authenticatedUserId = 'user-owner-123';
+
+ beforeAll(() => {
+ const messagesRouter = require('../messages');
+
+ app = express();
+ app.use(express.json());
+ app.use((req, res, next) => {
+ req.user = { id: authenticatedUserId };
+ next();
+ });
+ app.use('/api/messages', messagesRouter);
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should pass user and conversationId in the deleteMessages filter', async () => {
+ deleteMessages.mockResolvedValue({ deletedCount: 1 });
+
+ await request(app).delete('/api/messages/convo-1/msg-1');
+
+ expect(deleteMessages).toHaveBeenCalledTimes(1);
+ expect(deleteMessages).toHaveBeenCalledWith({
+ messageId: 'msg-1',
+ conversationId: 'convo-1',
+ user: authenticatedUserId,
+ });
+ });
+
+ it('should return 204 on successful deletion', async () => {
+ deleteMessages.mockResolvedValue({ deletedCount: 1 });
+
+ const response = await request(app).delete('/api/messages/convo-1/msg-owned');
+
+ expect(response.status).toBe(204);
+ expect(deleteMessages).toHaveBeenCalledWith({
+ messageId: 'msg-owned',
+ conversationId: 'convo-1',
+ user: authenticatedUserId,
+ });
+ });
+
+ it('should return 500 when deleteMessages throws', async () => {
+ deleteMessages.mockRejectedValue(new Error('DB failure'));
+
+ const response = await request(app).delete('/api/messages/convo-1/msg-1');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Internal server error' });
+ });
+});
diff --git a/api/server/routes/__tests__/messages-get-real-validation.spec.js b/api/server/routes/__tests__/messages-get-real-validation.spec.js
new file mode 100644
index 0000000..7e8b24a
--- /dev/null
+++ b/api/server/routes/__tests__/messages-get-real-validation.spec.js
@@ -0,0 +1,167 @@
+const express = require('express');
+const request = require('supertest');
+
+jest.mock('@librechat/agents', () => ({
+ ...jest.requireActual('@librechat/agents'),
+ CODE_EXECUTION_TOOLS: new Set(['execute_code', 'bash_tool']),
+ BashExecutionToolDefinition: {
+ name: 'bash_tool',
+ description: 'bash',
+ schema: { type: 'object', properties: {} },
+ },
+ ReadFileToolDefinition: {
+ name: 'read_file',
+ description: 'Read a file',
+ parameters: { type: 'object', properties: {} },
+ responseFormat: 'content',
+ },
+ buildBashExecutionToolDescription: () => 'bash',
+ sleep: jest.fn(),
+}));
+
+jest.mock('@librechat/api', () => ({
+ createMessageRequestMiddleware:
+ jest.requireActual('@librechat/api').createMessageRequestMiddleware,
+ unescapeLaTeX: jest.fn((x) => x),
+ countTokens: jest.fn().mockResolvedValue(10),
+ sendFeedbackScore: jest.fn().mockResolvedValue(undefined),
+ traceIdForMessage: jest.fn((messageId) => `trace-${messageId}`),
+ mergeQuotedTextForCount: jest.fn((text) => text),
+ GenerationJobManager: {
+ getJob: jest.fn(),
+ },
+ isPendingActionStale: jest.fn(() => false),
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ logger: {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
+jest.mock('librechat-data-provider', () => ({
+ ...jest.requireActual('librechat-data-provider'),
+}));
+
+jest.mock('~/models', () => ({
+ saveConvo: jest.fn(),
+ getConvo: jest.fn(),
+ getMessage: jest.fn(),
+ saveMessage: jest.fn(),
+ getMessages: jest.fn(),
+ updateMessage: jest.fn(),
+ deleteMessages: jest.fn(),
+ getConvosQueried: jest.fn(),
+ searchMessages: jest.fn(),
+ getMessagesByCursor: jest.fn(),
+}));
+
+jest.mock('~/server/services/Artifacts/update', () => ({
+ findAllArtifacts: jest.fn(),
+ replaceArtifactContent: jest.fn(),
+}));
+
+jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
+
+jest.mock('~/server/middleware', () => {
+ const { sendValidationResponse, validateMessageReq, prepareMessageRequestValidation } =
+ jest.requireActual('~/server/middleware/messageValidation');
+
+ return {
+ requireJwtAuth: (req, res, next) => next(),
+ validateMessageReq,
+ sendValidationResponse,
+ prepareMessageRequestValidation,
+ configMiddleware: (req, res, next) => next(),
+ };
+});
+
+jest.mock('~/db/models', () => ({
+ Message: {
+ findOne: jest.fn(),
+ find: jest.fn(),
+ meiliSearch: jest.fn(),
+ },
+}));
+
+describe('GET /api/messages/:conversationId with real validation middleware', () => {
+ let app;
+ const { getConvo, getMessages } = require('~/models');
+ const authenticatedUserId = 'user-owner-123';
+
+ beforeAll(() => {
+ const messagesRouter = require('../messages');
+
+ app = express();
+ app.use(express.json());
+ app.use((req, res, next) => {
+ req.user = { id: authenticatedUserId };
+ next();
+ });
+ app.use('/api/messages', messagesRouter);
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns the existing empty response for new conversations without fetching messages', async () => {
+ const response = await request(app).get('/api/messages/new');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual([]);
+ expect(getConvo).not.toHaveBeenCalled();
+ expect(getMessages).not.toHaveBeenCalled();
+ });
+
+ it('starts user-scoped message reads before real conversation validation resolves', async () => {
+ const events = [];
+ let resolveConvo;
+ const convoPromise = new Promise((resolve) => {
+ resolveConvo = resolve;
+ });
+
+ getConvo.mockImplementation(() => {
+ events.push('convo-started');
+ return convoPromise;
+ });
+
+ let resolveMessagesStarted;
+ const messagesStartedPromise = new Promise((resolve) => {
+ resolveMessagesStarted = resolve;
+ });
+ getMessages.mockImplementation(() => {
+ events.push('messages-started');
+ resolveMessagesStarted();
+ return Promise.resolve([{ messageId: 'message-1', conversationId: 'convo-1' }]);
+ });
+
+ const responsePromise = new Promise((resolve, reject) => {
+ request(app)
+ .get('/api/messages/convo-1')
+ .end((error, response) => (error ? reject(error) : resolve(response)));
+ });
+
+ await Promise.race([
+ messagesStartedPromise,
+ new Promise((resolve) => setTimeout(resolve, 100)),
+ ]);
+ const eventsBeforeValidation = [...events];
+
+ resolveConvo({ conversationId: 'convo-1', user: authenticatedUserId });
+ const response = await responsePromise;
+
+ expect(eventsBeforeValidation).toEqual(['convo-started', 'messages-started']);
+ expect(getConvo).toHaveBeenCalledWith(authenticatedUserId, 'convo-1');
+ expect(getMessages).toHaveBeenCalledWith(
+ { conversationId: 'convo-1', user: authenticatedUserId },
+ '-_id -__v -user',
+ );
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual([{ messageId: 'message-1', conversationId: 'convo-1' }]);
+ });
+});
diff --git a/api/server/routes/__tests__/messages-get.spec.js b/api/server/routes/__tests__/messages-get.spec.js
new file mode 100644
index 0000000..fd75898
--- /dev/null
+++ b/api/server/routes/__tests__/messages-get.spec.js
@@ -0,0 +1,247 @@
+const express = require('express');
+const request = require('supertest');
+
+jest.mock('@librechat/agents', () => ({
+ sleep: jest.fn(),
+}));
+
+jest.mock('@librechat/api', () => ({
+ unescapeLaTeX: jest.fn((x) => x),
+ countTokens: jest.fn().mockResolvedValue(10),
+ sendFeedbackScore: jest.fn().mockResolvedValue(undefined),
+ traceIdForMessage: jest.fn((messageId) => `trace-${messageId}`),
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ logger: {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
+jest.mock('librechat-data-provider', () => ({
+ ...jest.requireActual('librechat-data-provider'),
+}));
+
+jest.mock('~/models', () => ({
+ saveConvo: jest.fn(),
+ getMessage: jest.fn(),
+ saveMessage: jest.fn(),
+ getMessages: jest.fn(),
+ updateMessage: jest.fn(),
+ deleteMessages: jest.fn(),
+ getConvosQueried: jest.fn(),
+ searchMessages: jest.fn(),
+ getMessagesByCursor: jest.fn(),
+}));
+
+jest.mock('~/server/services/Artifacts/update', () => ({
+ findAllArtifacts: jest.fn(),
+ replaceArtifactContent: jest.fn(),
+}));
+
+jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
+
+jest.mock('~/server/middleware', () => {
+ const validateMessageReq = jest.fn((req, res, next) => next());
+ const prepareMessageRequestValidation = jest.fn((req, res, next) => {
+ req.messageRequestValidation = {
+ conversationId: 'convo-1',
+ shouldFetchMessages: true,
+ promise: Promise.resolve({ ok: true }),
+ };
+ next();
+ });
+ const sendValidationResponse = jest.fn((res, result) => {
+ if (result.send) {
+ return res.status(result.status).send(result.body);
+ }
+ return res.status(result.status).json(result.body);
+ });
+
+ return {
+ requireJwtAuth: (req, res, next) => next(),
+ validateMessageReq,
+ sendValidationResponse,
+ prepareMessageRequestValidation,
+ configMiddleware: (req, res, next) => next(),
+ };
+});
+
+jest.mock('~/db/models', () => ({
+ Message: {
+ findOne: jest.fn(),
+ find: jest.fn(),
+ meiliSearch: jest.fn(),
+ },
+}));
+
+describe('message route conversation ownership filters', () => {
+ let app;
+ const { getMessages, saveConvo, saveMessage } = require('~/models');
+ const { prepareMessageRequestValidation } = require('~/server/middleware');
+
+ const authenticatedUserId = 'user-owner-123';
+
+ beforeAll(() => {
+ const messagesRouter = require('../messages');
+
+ app = express();
+ app.use(express.json());
+ app.use((req, res, next) => {
+ req.user = { id: authenticatedUserId };
+ next();
+ });
+ app.use('/api/messages', messagesRouter);
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ prepareMessageRequestValidation.mockImplementation((req, res, next) => {
+ req.messageRequestValidation = {
+ conversationId: 'convo-1',
+ shouldFetchMessages: true,
+ promise: Promise.resolve({ ok: true }),
+ };
+ next();
+ });
+ });
+
+ it('should save POST messages with the validated URL conversationId', async () => {
+ const urlConversationId = '11111111-1111-4111-8111-111111111111';
+ const bodyConversationId = '22222222-2222-4222-8222-222222222222';
+ const savedMessage = {
+ messageId: 'message-1',
+ conversationId: urlConversationId,
+ text: 'hello',
+ user: authenticatedUserId,
+ };
+
+ saveMessage.mockResolvedValue(savedMessage);
+ saveConvo.mockResolvedValue({ conversationId: urlConversationId });
+
+ const response = await request(app).post(`/api/messages/${urlConversationId}`).send({
+ messageId: savedMessage.messageId,
+ conversationId: bodyConversationId,
+ text: savedMessage.text,
+ });
+
+ expect(response.status).toBe(201);
+ expect(saveMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ userId: authenticatedUserId }),
+ expect.objectContaining({
+ messageId: savedMessage.messageId,
+ conversationId: urlConversationId,
+ text: savedMessage.text,
+ user: authenticatedUserId,
+ }),
+ { context: 'POST /api/messages/:conversationId' },
+ );
+ expect(saveMessage.mock.calls[0][1].conversationId).not.toBe(bodyConversationId);
+ expect(saveConvo).toHaveBeenCalledWith(
+ expect.objectContaining({ userId: authenticatedUserId }),
+ savedMessage,
+ { context: 'POST /api/messages/:conversationId' },
+ );
+ });
+
+ it('should filter conversation message reads by authenticated user', async () => {
+ getMessages.mockResolvedValue([{ messageId: 'message-1', conversationId: 'convo-1' }]);
+
+ const response = await request(app).get('/api/messages/convo-1');
+
+ expect(response.status).toBe(200);
+ expect(getMessages).toHaveBeenCalledWith(
+ { conversationId: 'convo-1', user: authenticatedUserId },
+ '-_id -__v -user',
+ );
+ });
+
+ it('should start conversation message reads before validation resolves', async () => {
+ const events = [];
+ let resolveValidation;
+ const validationPromise = new Promise((resolve) => {
+ resolveValidation = resolve;
+ });
+ prepareMessageRequestValidation.mockImplementationOnce((req, res, next) => {
+ req.messageRequestValidation = {
+ conversationId: 'convo-1',
+ shouldFetchMessages: true,
+ promise: validationPromise,
+ };
+ next();
+ });
+ let resolveMessagesStarted;
+ const messagesStartedPromise = new Promise((resolve) => {
+ resolveMessagesStarted = resolve;
+ });
+ getMessages.mockImplementation(() => {
+ events.push('messages-started');
+ resolveMessagesStarted();
+ return Promise.resolve([{ messageId: 'message-1', conversationId: 'convo-1' }]);
+ });
+
+ const responsePromise = new Promise((resolve, reject) => {
+ request(app)
+ .get('/api/messages/convo-1')
+ .end((error, response) => (error ? reject(error) : resolve(response)));
+ });
+ await Promise.race([
+ messagesStartedPromise,
+ new Promise((resolve) => setTimeout(resolve, 100)),
+ ]);
+
+ const eventsBeforeValidation = [...events];
+ resolveValidation({ ok: true });
+ const response = await responsePromise;
+
+ expect(eventsBeforeValidation).toEqual(['messages-started']);
+ expect(getMessages).toHaveBeenCalledWith(
+ { conversationId: 'convo-1', user: authenticatedUserId },
+ '-_id -__v -user',
+ );
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual([{ messageId: 'message-1', conversationId: 'convo-1' }]);
+ });
+
+ it('should not return fetched messages when conversation validation fails', async () => {
+ prepareMessageRequestValidation.mockImplementationOnce((req, res, next) => {
+ req.messageRequestValidation = {
+ conversationId: 'convo-1',
+ shouldFetchMessages: true,
+ promise: Promise.resolve({
+ ok: false,
+ status: 404,
+ body: { error: 'Conversation not found' },
+ }),
+ };
+ next();
+ });
+ getMessages.mockResolvedValue([{ messageId: 'secret-message', conversationId: 'convo-1' }]);
+
+ const response = await request(app).get('/api/messages/convo-1');
+
+ expect(getMessages).toHaveBeenCalledWith(
+ { conversationId: 'convo-1', user: authenticatedUserId },
+ '-_id -__v -user',
+ );
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({ error: 'Conversation not found' });
+ });
+
+ it('should filter single message reads by authenticated user', async () => {
+ getMessages.mockResolvedValue([{ messageId: 'message-1', conversationId: 'convo-1' }]);
+
+ const response = await request(app).get('/api/messages/convo-1/message-1');
+
+ expect(response.status).toBe(200);
+ expect(getMessages).toHaveBeenCalledWith(
+ { conversationId: 'convo-1', messageId: 'message-1', user: authenticatedUserId },
+ '-_id -__v -user',
+ );
+ });
+});
diff --git a/api/server/routes/__tests__/roles.spec.js b/api/server/routes/__tests__/roles.spec.js
new file mode 100644
index 0000000..0235c44
--- /dev/null
+++ b/api/server/routes/__tests__/roles.spec.js
@@ -0,0 +1,194 @@
+const express = require('express');
+const request = require('supertest');
+const { SystemRoles, roleDefaults } = require('librechat-data-provider');
+
+const mockGetRoleByName = jest.fn();
+const mockHasCapability = jest.fn();
+
+jest.mock('~/server/middleware', () => ({
+ requireJwtAuth: (_req, _res, next) => next(),
+}));
+
+jest.mock('~/server/middleware/roles/capabilities', () => ({
+ hasCapability: (...args) => mockHasCapability(...args),
+ requireCapability: () => (_req, _res, next) => next(),
+}));
+
+jest.mock('~/models', () => ({
+ getRoleByName: (...args) => mockGetRoleByName(...args),
+ updateRoleByName: jest.fn(),
+}));
+
+const rolesRouter = require('../roles');
+
+function createApp(user) {
+ const app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ req.user = user;
+ next();
+ });
+ app.use('/api/roles', rolesRouter);
+ return app;
+}
+
+const staffRole = {
+ name: 'STAFF',
+ permissions: {
+ PROMPTS: { USE: true, CREATE: false },
+ },
+};
+
+const userRole = roleDefaults[SystemRoles.USER];
+const adminRole = roleDefaults[SystemRoles.ADMIN];
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockHasCapability.mockResolvedValue(false);
+ mockGetRoleByName.mockResolvedValue(null);
+});
+
+describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
+ it('allows a custom role user to fetch their own role', async () => {
+ mockGetRoleByName.mockResolvedValue(staffRole);
+ const app = createApp({ id: 'u1', role: 'STAFF' });
+
+ const res = await request(app).get('/api/roles/STAFF');
+
+ expect(res.status).toBe(200);
+ expect(res.body.name).toBe('STAFF');
+ expect(mockGetRoleByName).toHaveBeenCalledWith('STAFF', '-_id -__v');
+ expect(mockHasCapability).not.toHaveBeenCalled();
+ });
+
+ it('returns 403 when a custom role user requests a different custom role', async () => {
+ const app = createApp({ id: 'u1', role: 'STAFF' });
+
+ const res = await request(app).get('/api/roles/MANAGER');
+
+ expect(res.status).toBe(403);
+ expect(mockGetRoleByName).not.toHaveBeenCalled();
+ });
+
+ it('returns 403 when a custom role user requests ADMIN', async () => {
+ const app = createApp({ id: 'u1', role: 'STAFF' });
+
+ const res = await request(app).get('/api/roles/ADMIN');
+
+ expect(res.status).toBe(403);
+ expect(mockGetRoleByName).not.toHaveBeenCalled();
+ });
+
+ it('allows USER to fetch the USER role (roleDefaults key)', async () => {
+ mockGetRoleByName.mockResolvedValue(userRole);
+ const app = createApp({ id: 'u1', role: SystemRoles.USER });
+
+ const res = await request(app).get(`/api/roles/${SystemRoles.USER}`);
+
+ expect(res.status).toBe(200);
+ expect(mockHasCapability).not.toHaveBeenCalled();
+ });
+
+ it('allows a custom role user to fetch a non-admin default role without a capability probe', async () => {
+ mockGetRoleByName.mockResolvedValue(userRole);
+ const app = createApp({ id: 'u1', role: 'STAFF' });
+
+ const res = await request(app).get(`/api/roles/${SystemRoles.USER}`);
+
+ expect(res.status).toBe(200);
+ expect(mockHasCapability).not.toHaveBeenCalled();
+ });
+
+ it('returns 403 when USER requests the ADMIN role', async () => {
+ const app = createApp({ id: 'u1', role: SystemRoles.USER });
+
+ const res = await request(app).get(`/api/roles/${SystemRoles.ADMIN}`);
+
+ expect(res.status).toBe(403);
+ });
+
+ it('allows ADMIN user to fetch their own ADMIN role via isOwnRole', async () => {
+ mockHasCapability.mockResolvedValue(false);
+ mockGetRoleByName.mockResolvedValue(adminRole);
+ const app = createApp({ id: 'u1', role: SystemRoles.ADMIN });
+
+ const res = await request(app).get(`/api/roles/${SystemRoles.ADMIN}`);
+
+ expect(res.status).toBe(200);
+ expect(mockHasCapability).not.toHaveBeenCalled();
+ });
+
+ it('allows any user with READ_ROLES capability to fetch any role', async () => {
+ mockHasCapability.mockResolvedValue(true);
+ mockGetRoleByName.mockResolvedValue(staffRole);
+ const app = createApp({ id: 'u1', role: SystemRoles.USER });
+
+ const res = await request(app).get('/api/roles/STAFF');
+
+ expect(res.status).toBe(200);
+ expect(res.body.name).toBe('STAFF');
+ });
+
+ it('probes READ_ROLES with a coerced CapabilityUser for non-own-role lookups', async () => {
+ mockHasCapability.mockResolvedValue(true);
+ mockGetRoleByName.mockResolvedValue(staffRole);
+ const app = createApp({ id: 'u1', role: SystemRoles.USER });
+
+ const res = await request(app).get('/api/roles/STAFF');
+
+ expect(res.status).toBe(200);
+ expect(mockHasCapability).toHaveBeenCalledTimes(1);
+ expect(mockHasCapability).toHaveBeenCalledWith(
+ { id: 'u1', role: SystemRoles.USER, tenantId: undefined, idOnTheSource: null },
+ expect.any(String),
+ );
+ });
+
+ it('returns 404 when the requested role does not exist', async () => {
+ mockGetRoleByName.mockResolvedValue(null);
+ const app = createApp({ id: 'u1', role: 'GHOST' });
+
+ const res = await request(app).get('/api/roles/GHOST');
+
+ expect(res.status).toBe(404);
+ });
+
+ it('returns 500 when getRoleByName throws', async () => {
+ mockGetRoleByName.mockRejectedValue(new Error('db error'));
+ const app = createApp({ id: 'u1', role: SystemRoles.USER });
+
+ const res = await request(app).get(`/api/roles/${SystemRoles.USER}`);
+
+ expect(res.status).toBe(500);
+ });
+
+ it('returns 403 for prototype property names like constructor (no prototype pollution)', async () => {
+ const app = createApp({ id: 'u1', role: 'STAFF' });
+
+ const res = await request(app).get('/api/roles/constructor');
+
+ expect(res.status).toBe(403);
+ expect(mockGetRoleByName).not.toHaveBeenCalled();
+ });
+
+ it('serves own role without probing even when hasCapability would fail', async () => {
+ mockHasCapability.mockRejectedValue(new Error('capability check failed'));
+ const app = createApp({ id: 'u1', role: 'STAFF' });
+ mockGetRoleByName.mockResolvedValue(staffRole);
+
+ const res = await request(app).get('/api/roles/STAFF');
+
+ expect(res.status).toBe(200);
+ expect(mockHasCapability).not.toHaveBeenCalled();
+ });
+
+ it('treats hasCapability failure as no capability for other roles (403, not 500)', async () => {
+ mockHasCapability.mockRejectedValue(new Error('capability check failed'));
+ const app = createApp({ id: 'u1', role: 'STAFF' });
+
+ const res = await request(app).get('/api/roles/MANAGER');
+
+ expect(res.status).toBe(403);
+ expect(mockGetRoleByName).not.toHaveBeenCalled();
+ });
+});
diff --git a/api/server/routes/__tests__/rum.spec.js b/api/server/routes/__tests__/rum.spec.js
new file mode 100644
index 0000000..cdd0ec3
--- /dev/null
+++ b/api/server/routes/__tests__/rum.spec.js
@@ -0,0 +1,73 @@
+const express = require('express');
+const request = require('supertest');
+
+const mockRequireRumProxyAuth = jest.fn((_req, _res, next) => next());
+const mockIsRumProxyEnabled = jest.fn();
+const mockProxyRumRequest = jest.fn((_req, res) => res.status(202).send());
+
+jest.mock('~/server/middleware', () => ({
+ requireRumProxyAuth: (...args) => mockRequireRumProxyAuth(...args),
+}));
+
+jest.mock('@librechat/api', () => ({
+ getRumProxyBodyLimit: jest.fn(() => '3mb'),
+ isRumProxyEnabled: (...args) => mockIsRumProxyEnabled(...args),
+ proxyRumRequest: (...args) => mockProxyRumRequest(...args),
+}));
+
+describe('RUM proxy routes', () => {
+ let app;
+
+ beforeAll(() => {
+ const rumRouter = require('../rum');
+
+ app = express();
+ app.use('/api/rum', rumRouter);
+ });
+
+ beforeEach(() => {
+ mockRequireRumProxyAuth.mockClear();
+ mockIsRumProxyEnabled.mockReset();
+ mockProxyRumRequest.mockClear();
+ });
+
+ it('returns 404 before auth and proxying when RUM proxy mode is disabled', async () => {
+ mockIsRumProxyEnabled.mockReturnValue(false);
+
+ const response = await request(app)
+ .post('/api/rum/v1/traces')
+ .set('Content-Type', 'application/x-protobuf')
+ .send(Buffer.from('payload'));
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({ message: 'RUM proxy is not configured' });
+ expect(mockRequireRumProxyAuth).not.toHaveBeenCalled();
+ expect(mockProxyRumRequest).not.toHaveBeenCalled();
+ });
+
+ it('authenticates and proxies when RUM proxy mode is enabled', async () => {
+ mockIsRumProxyEnabled.mockReturnValue(true);
+
+ const response = await request(app)
+ .post('/api/rum/v1/traces')
+ .set('Content-Type', 'application/x-protobuf')
+ .send(Buffer.from('payload'));
+
+ expect(response.status).toBe(202);
+ expect(mockRequireRumProxyAuth).toHaveBeenCalledTimes(1);
+ expect(mockProxyRumRequest).toHaveBeenCalledTimes(1);
+ });
+
+ it('uses RUM-specific auth for logs as well as traces', async () => {
+ mockIsRumProxyEnabled.mockReturnValue(true);
+
+ const response = await request(app)
+ .post('/api/rum/v1/logs')
+ .set('Content-Type', 'application/x-protobuf')
+ .send(Buffer.from('payload'));
+
+ expect(response.status).toBe(202);
+ expect(mockRequireRumProxyAuth).toHaveBeenCalledTimes(1);
+ expect(mockProxyRumRequest).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js
new file mode 100644
index 0000000..253fa93
--- /dev/null
+++ b/api/server/routes/__tests__/share.spec.js
@@ -0,0 +1,814 @@
+const express = require('express');
+const request = require('supertest');
+const mongoose = require('mongoose');
+
+const mockGetSharedLinkExpiration = jest.fn();
+const mockGrantCreationPermissions = jest.fn();
+const mockUpdateSharedLinkPermissionsExpiration = jest.fn();
+const mockSharedLinksAccess = jest.fn((_req, _res, next) => next());
+const mockBuildSharedLinkStartupPayload = jest.fn();
+const mockCanAccessSharedLink = jest.fn((req, _res, next) => {
+ req.shareResourceId = 'resource-123';
+ next();
+});
+const mockGetAppConfig = jest.fn();
+const mockGetTenantId = jest.fn(() => undefined);
+
+jest.mock('@librechat/api', () => ({
+ isEnabled: jest.fn(() => true),
+ generateCheckAccess: jest.fn(() => mockSharedLinksAccess),
+ grantCreationPermissions: (...args) => mockGrantCreationPermissions(...args),
+ updateSharedLinkPermissionsExpiration: (...args) =>
+ mockUpdateSharedLinkPermissionsExpiration(...args),
+ ensureLinkPermissions: jest.fn(),
+ isFileSnapshotEnabled: jest.fn(() => true),
+ isFileSnapshotKillSwitchActive: jest.fn(() => false),
+ buildSharedLinkStartupPayload: (...args) => mockBuildSharedLinkStartupPayload(...args),
+ deleteSharedLinkWithCleanup: jest.fn(),
+ getSharedLinkExpiration: (...args) => mockGetSharedLinkExpiration(...args),
+ isActiveExpirationDate: jest.fn((expiredAt) => expiredAt > new Date()),
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: { error: jest.fn(), warn: jest.fn() },
+ getTenantId: (...args) => mockGetTenantId(...args),
+ createTempChatExpirationDate: jest.fn(() => new Date('2030-01-01T00:00:00.000Z')),
+ runAsSystem: jest.fn((fn) => fn()),
+ tenantStorage: { run: jest.fn((_ctx, fn) => fn()) },
+ SYSTEM_TENANT_ID: '__SYSTEM__',
+}));
+
+jest.mock('librechat-data-provider', () => ({
+ PermissionTypes: {
+ SHARED_LINKS: 'SHARED_LINKS',
+ },
+ Permissions: {
+ CREATE: 'CREATE',
+ SHARE_PUBLIC: 'SHARE_PUBLIC',
+ },
+ RetentionMode: {
+ ALL: 'all',
+ TEMPORARY: 'temporary',
+ },
+ FileSources: {
+ local: 'local',
+ s3: 's3',
+ cloudfront: 'cloudfront',
+ azure_blob: 'azure_blob',
+ firebase: 'firebase',
+ text: 'text',
+ },
+}));
+
+jest.mock('mongoose', () => ({
+ models: {
+ Conversation: {
+ findOne: jest.fn(),
+ },
+ SharedLink: {
+ findOne: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('~/models', () => ({
+ getFiles: jest.fn(),
+ updateFile: jest.fn(),
+ getSharedMessages: jest.fn(),
+ createSharedLink: jest.fn(),
+ updateSharedLink: jest.fn(),
+ deleteSharedLink: jest.fn(),
+ getSharedLinks: jest.fn(),
+ getSharedLink: jest.fn(),
+ getSharedLinkFile: jest.fn(),
+ backfillSharedLinkFiles: jest.fn(),
+ getRoleByName: jest.fn(),
+}));
+
+const mockGetStrategyFunctions = jest.fn();
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: (...args) => mockGetStrategyFunctions(...args),
+}));
+jest.mock('~/server/utils/files', () => ({
+ cleanFileName: jest.fn((name) => name),
+ getContentDisposition: jest.fn((name, disposition = 'attachment') => `${disposition}; ${name}`),
+}));
+
+jest.mock(
+ '~/server/middleware/canAccessSharedLink',
+ () =>
+ (...args) =>
+ mockCanAccessSharedLink(...args),
+);
+jest.mock('~/server/middleware/optionalShareFileAuth', () => (_req, _res, next) => next());
+jest.mock('~/server/middleware/optionalJwtAuth', () => (req, _res, next) => next());
+jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
+jest.mock('~/server/middleware/config/app', () => (_req, _res, next) => next());
+jest.mock('~/server/services/Config/app', () => ({
+ getAppConfig: (...args) => mockGetAppConfig(...args),
+}));
+
+jest.mock('~/server/middleware/limiters', () => ({
+ createForkLimiters: () => ({
+ forkIpLimiter: (_req, _res, next) => next(),
+ forkUserLimiter: (_req, _res, next) => next(),
+ }),
+}));
+
+jest.mock('~/server/utils/import/fork', () => ({
+ forkSharedConversation: jest.fn(),
+}));
+
+const { Readable } = require('stream');
+const { RetentionMode } = require('librechat-data-provider');
+const { createTempChatExpirationDate, logger } = require('@librechat/data-schemas');
+const {
+ deleteSharedLinkWithCleanup,
+ isFileSnapshotEnabled,
+ isFileSnapshotKillSwitchActive,
+} = require('@librechat/api');
+const {
+ getFiles,
+ updateFile,
+ getSharedMessages,
+ createSharedLink,
+ updateSharedLink,
+ getSharedLinkFile,
+ backfillSharedLinkFiles,
+ getRoleByName,
+} = require('~/models');
+const { forkSharedConversation } = require('~/server/utils/import/fork');
+const shareRouter = require('../share');
+
+const activeExpiration = new Date('2030-01-01T00:00:00.000Z');
+const expiredExpiration = new Date('2020-01-01T00:00:00.000Z');
+
+const lean = (value) => ({
+ lean: jest.fn().mockResolvedValue(value),
+});
+
+const buildApp = ({ retentionMode = RetentionMode.TEMPORARY, user = { id: 'user-123' } } = {}) => {
+ const app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ req.user = user;
+ req.config = { interfaceConfig: { retentionMode } };
+ next();
+ });
+ app.use('/api/share', shareRouter);
+ return app;
+};
+
+describe('share routes', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetTenantId.mockReturnValue(undefined);
+ mockGetAppConfig.mockResolvedValue({
+ interfaceConfig: {
+ privacyPolicy: { externalUrl: 'https://example.com/privacy' },
+ },
+ });
+ mockBuildSharedLinkStartupPayload.mockReturnValue({
+ appTitle: 'Shared Chat',
+ bundlerURL: 'https://bundler.example.com',
+ interface: {
+ privacyPolicy: { externalUrl: 'https://example.com/privacy' },
+ },
+ });
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ SHARED_LINKS: {
+ SHARE_PUBLIC: true,
+ },
+ },
+ });
+ mockGrantCreationPermissions.mockResolvedValue(undefined);
+ });
+
+ it('serves shared startup config after shared-link access is granted', async () => {
+ const response = await request(buildApp()).get('/api/share/share-123/config');
+
+ expect(response.status).toBe(200);
+ expect(response.headers['cache-control']).toBe('private, no-store');
+ expect(mockCanAccessSharedLink).toHaveBeenCalled();
+ expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true });
+ expect(mockBuildSharedLinkStartupPayload).toHaveBeenCalledWith({
+ interfaceConfig: {
+ privacyPolicy: { externalUrl: 'https://example.com/privacy' },
+ },
+ });
+ expect(response.body).toEqual({
+ appTitle: 'Shared Chat',
+ bundlerURL: 'https://bundler.example.com',
+ interface: {
+ privacyPolicy: { externalUrl: 'https://example.com/privacy' },
+ },
+ });
+ });
+
+ it('uses tenant-scoped app config for shared startup config when tenant context is present', async () => {
+ mockGetTenantId.mockReturnValue('tenant-abc');
+
+ const response = await request(buildApp()).get('/api/share/share-123/config');
+
+ expect(response.status).toBe(200);
+ expect(mockGetAppConfig).toHaveBeenCalledWith({ tenantId: 'tenant-abc' });
+ });
+
+ it('uses base app config for shared startup config in system context', async () => {
+ mockGetTenantId.mockReturnValue('__SYSTEM__');
+
+ const response = await request(buildApp()).get('/api/share/share-123/config');
+
+ expect(response.status).toBe(200);
+ expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true });
+ });
+
+ it('prevents successful shared message responses from being cached', async () => {
+ getSharedMessages.mockResolvedValue({ shareId: 'share-123', messages: [] });
+
+ const response = await request(buildApp()).get('/api/share/share-123');
+
+ expect(response.status).toBe(200);
+ expect(response.headers['cache-control']).toBe('private, no-store');
+ });
+
+ it('expires new shares for retained non-temporary conversations', async () => {
+ mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
+ createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
+
+ const response = await request(buildApp())
+ .post('/api/share/convo-123')
+ .send({ targetMessageId: 'msg-123' });
+
+ expect(response.status).toBe(200);
+ expect(mockGetSharedLinkExpiration).toHaveBeenCalledWith(
+ expect.objectContaining({
+ conversationId: 'convo-123',
+ req: expect.objectContaining({ user: { id: 'user-123' } }),
+ }),
+ expect.objectContaining({
+ getConvo: expect.any(Function),
+ createExpirationDate: createTempChatExpirationDate,
+ logger,
+ }),
+ );
+ const [, dependencies] = mockGetSharedLinkExpiration.mock.calls[0];
+ mongoose.models.Conversation.findOne.mockReturnValue(lean({ expiredAt: activeExpiration }));
+ await dependencies.getConvo('user-123', 'convo-123');
+ expect(mongoose.models.Conversation.findOne).toHaveBeenCalledWith(
+ { conversationId: 'convo-123', user: 'user-123' },
+ 'isTemporary expiredAt',
+ );
+ expect(createSharedLink).toHaveBeenCalledWith(
+ 'user-123',
+ 'convo-123',
+ 'msg-123',
+ new Date('2030-01-01T00:00:00.000Z'),
+ true,
+ );
+ expect(mockGrantCreationPermissions).toHaveBeenCalledWith(
+ 'link-123',
+ 'user-123',
+ true,
+ new Date('2030-01-01T00:00:00.000Z'),
+ );
+ expect(mockSharedLinksAccess).toHaveBeenCalled();
+ });
+
+ it('snapshots files by default when the user does not opt out', async () => {
+ mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
+ createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
+
+ await request(buildApp()).post('/api/share/convo-123').send({ targetMessageId: 'msg-123' });
+
+ expect(createSharedLink).toHaveBeenCalledWith(
+ 'user-123',
+ 'convo-123',
+ 'msg-123',
+ expect.anything(),
+ true,
+ );
+ });
+
+ it('does not snapshot files when the user opts out (snapshotFiles=false)', async () => {
+ mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
+ createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
+
+ await request(buildApp())
+ .post('/api/share/convo-123')
+ .send({ targetMessageId: 'msg-123', snapshotFiles: false });
+
+ expect(createSharedLink).toHaveBeenCalledWith(
+ 'user-123',
+ 'convo-123',
+ 'msg-123',
+ expect.anything(),
+ false,
+ );
+ });
+
+ it('forces snapshotFiles=false when the feature is disabled, ignoring the body flag', async () => {
+ isFileSnapshotEnabled.mockReturnValueOnce(false);
+ mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
+ createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
+
+ await request(buildApp())
+ .post('/api/share/convo-123')
+ .send({ targetMessageId: 'msg-123', snapshotFiles: true });
+
+ expect(createSharedLink).toHaveBeenCalledWith(
+ 'user-123',
+ 'convo-123',
+ 'msg-123',
+ expect.anything(),
+ false,
+ );
+ });
+
+ it('passes the snapshotFiles opt-out through on update', async () => {
+ mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
+ mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
+ updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
+
+ await request(buildApp()).patch('/api/share/share-123').send({ snapshotFiles: false });
+
+ expect(updateSharedLink).toHaveBeenCalledWith(
+ 'user-123',
+ 'share-123',
+ undefined,
+ expect.anything(),
+ false,
+ );
+ });
+
+ it('rejects new shares when the retained conversation expired', async () => {
+ mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
+ createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
+
+ const response = await request(buildApp())
+ .post('/api/share/convo-123')
+ .send({ targetMessageId: 'msg-123' });
+
+ expect(response.status).toBe(404);
+ expect(createSharedLink).not.toHaveBeenCalled();
+ });
+
+ it('rejects new shares for expired conversations in all retention mode', async () => {
+ mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
+ createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
+
+ const response = await request(buildApp({ retentionMode: RetentionMode.ALL }))
+ .post('/api/share/convo-123')
+ .send({ targetMessageId: 'msg-123' });
+
+ expect(response.status).toBe(404);
+ expect(createSharedLink).not.toHaveBeenCalled();
+ });
+
+ it('expires updated shares for retained non-temporary conversations', async () => {
+ mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
+ mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
+ updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
+
+ const response = await request(buildApp()).patch('/api/share/share-123');
+
+ expect(response.status).toBe(200);
+ expect(mongoose.models.SharedLink.findOne).toHaveBeenCalledWith(
+ { shareId: 'share-123', user: 'user-123' },
+ 'conversationId',
+ );
+ expect(mockGetSharedLinkExpiration).toHaveBeenCalledTimes(1);
+ expect(mockGetSharedLinkExpiration).toHaveBeenCalledWith(
+ expect.objectContaining({
+ conversationId: 'convo-123',
+ req: expect.objectContaining({ user: { id: 'user-123' } }),
+ }),
+ expect.objectContaining({
+ getConvo: expect.any(Function),
+ createExpirationDate: createTempChatExpirationDate,
+ logger,
+ }),
+ );
+ expect(updateSharedLink).toHaveBeenCalledWith(
+ 'user-123',
+ 'share-123',
+ undefined,
+ new Date('2030-01-01T00:00:00.000Z'),
+ true,
+ );
+ expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith(
+ 'link-456',
+ new Date('2030-01-01T00:00:00.000Z'),
+ );
+ });
+
+ it('rejects updated shares when the retained conversation expired', async () => {
+ mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
+ mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
+ updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
+
+ const response = await request(buildApp()).patch('/api/share/share-123');
+
+ expect(response.status).toBe(404);
+ expect(updateSharedLink).not.toHaveBeenCalled();
+ });
+
+ it('rejects updated shares for expired conversations in all retention mode', async () => {
+ mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
+ mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
+ updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
+
+ const response = await request(buildApp({ retentionMode: RetentionMode.ALL })).patch(
+ '/api/share/share-123',
+ );
+
+ expect(response.status).toBe(404);
+ expect(mongoose.models.SharedLink.findOne).toHaveBeenCalledWith(
+ { shareId: 'share-123', user: 'user-123' },
+ 'conversationId',
+ );
+ expect(updateSharedLink).not.toHaveBeenCalled();
+ });
+
+ it('clears updated share expiration when the conversation is no longer retained', async () => {
+ mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
+ mockGetSharedLinkExpiration.mockResolvedValue(null);
+ updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
+
+ const response = await request(buildApp()).patch('/api/share/share-123');
+
+ expect(response.status).toBe(200);
+ expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null, true);
+ expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith('link-456', null);
+ expect(mockSharedLinksAccess).not.toHaveBeenCalled();
+ });
+
+ it('preserves updated share expiration when the conversation cannot be found', async () => {
+ mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
+ mockGetSharedLinkExpiration.mockResolvedValue(undefined);
+ updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
+
+ const response = await request(buildApp()).patch('/api/share/share-123');
+
+ expect(response.status).toBe(200);
+ expect(updateSharedLink).toHaveBeenCalledWith(
+ 'user-123',
+ 'share-123',
+ undefined,
+ undefined,
+ true,
+ );
+ expect(mockUpdateSharedLinkPermissionsExpiration).not.toHaveBeenCalled();
+ });
+
+ it('clears updated share expiration when creating a new expiration throws', async () => {
+ const error = new Error('bad config');
+ mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
+ mockGetSharedLinkExpiration.mockImplementationOnce(async (_input, dependencies) => {
+ dependencies.logger.error('[getSharedLinkExpiration] Error creating expiration date:', error);
+ return null;
+ });
+ updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
+
+ const response = await request(buildApp()).patch('/api/share/share-123');
+
+ expect(response.status).toBe(200);
+ expect(logger.error).toHaveBeenCalledWith(
+ '[getSharedLinkExpiration] Error creating expiration date:',
+ error,
+ );
+ expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null, true);
+ expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith('link-456', null);
+ });
+
+ it('updates share target message while applying retention expiration', async () => {
+ mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
+ mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
+ updateSharedLink.mockResolvedValue({ shareId: 'share-456', targetMessageId: 'msg-456' });
+
+ const response = await request(buildApp())
+ .patch('/api/share/share-123')
+ .send({ targetMessageId: 'msg-456' });
+
+ expect(response.status).toBe(200);
+ expect(updateSharedLink).toHaveBeenCalledWith(
+ 'user-123',
+ 'share-123',
+ 'msg-456',
+ new Date('2030-01-01T00:00:00.000Z'),
+ true,
+ );
+ });
+
+ it('rejects non-string target message updates', async () => {
+ const response = await request(buildApp())
+ .patch('/api/share/share-123')
+ .send({ targetMessageId: 123 });
+
+ expect(response.status).toBe(400);
+ expect(updateSharedLink).not.toHaveBeenCalled();
+ });
+
+ it('allows deleting existing shares without CREATE permission gate', async () => {
+ deleteSharedLinkWithCleanup.mockResolvedValue({ shareId: 'share-123' });
+
+ const response = await request(buildApp()).delete('/api/share/share-123');
+
+ expect(response.status).toBe(200);
+ expect(mockSharedLinksAccess).not.toHaveBeenCalled();
+ expect(deleteSharedLinkWithCleanup).toHaveBeenCalledWith('user-123', 'share-123');
+ });
+});
+
+describe('share fork route', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('forks a shared conversation for the requesting user', async () => {
+ const forkResult = {
+ conversation: { conversationId: 'convo-456', title: 'Shared Title' },
+ messages: [{ messageId: 'msg-456' }],
+ };
+ forkSharedConversation.mockResolvedValue(forkResult);
+
+ const response = await request(
+ buildApp({ user: { id: 'user-123', role: 'USER', tenantId: 'tenant-viewer' } }),
+ )
+ .post('/api/share/share-123/fork')
+ .send({ targetMessageIndex: 3 });
+
+ expect(response.status).toBe(201);
+ expect(response.body).toEqual(forkResult);
+ expect(forkSharedConversation).toHaveBeenCalledWith({
+ shareId: 'share-123',
+ shareResourceId: 'resource-123',
+ requestUserId: 'user-123',
+ userRole: 'USER',
+ userTenantId: 'tenant-viewer',
+ targetMessageIndex: 3,
+ snapshotFiles: true,
+ });
+ });
+
+ it('forces snapshotFiles=false into the fork when the file snapshot kill switch is active', async () => {
+ isFileSnapshotKillSwitchActive.mockReturnValueOnce(true);
+ forkSharedConversation.mockResolvedValue({
+ conversation: { conversationId: 'convo-456' },
+ messages: [],
+ });
+
+ await request(buildApp()).post('/api/share/share-123/fork');
+
+ expect(forkSharedConversation).toHaveBeenCalledWith(
+ expect.objectContaining({ snapshotFiles: false }),
+ );
+ });
+
+ it('returns 404 when the shared conversation is missing or empty', async () => {
+ forkSharedConversation.mockResolvedValue(null);
+
+ const response = await request(buildApp()).post('/api/share/share-123/fork');
+
+ expect(response.status).toBe(404);
+ });
+
+ it('returns 500 when forking fails', async () => {
+ forkSharedConversation.mockRejectedValue(new Error('db down'));
+
+ const response = await request(buildApp()).post('/api/share/share-123/fork');
+
+ expect(response.status).toBe(500);
+ });
+});
+
+describe('share-scoped file routes', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetStrategyFunctions.mockReturnValue({
+ getDownloadStream: jest.fn(async () => Readable.from(['file-bytes'])),
+ });
+ // Live file record present by default (resolveShareFile requires it).
+ getFiles.mockResolvedValue([{ status: 'ready' }]);
+ });
+
+ it('serves a snapshotted image inline from its original stored object', async () => {
+ const getDownloadStream = jest.fn(async () => Readable.from(['file-bytes']));
+ mockGetStrategyFunctions.mockReturnValue({ getDownloadStream });
+ getSharedLinkFile.mockResolvedValue({
+ file: {
+ file_id: 'file-1',
+ source: 'local',
+ filepath: '/images/owner/pic.png',
+ type: 'image/png',
+ filename: 'pic.png',
+ },
+ hasSnapshots: true,
+ });
+
+ const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
+
+ expect(response.status).toBe(200);
+ expect(response.headers['content-type']).toContain('image/png');
+ expect(response.headers['x-content-type-options']).toBe('nosniff');
+ expect(response.headers['content-disposition']).toContain('inline');
+ expect(mockGetStrategyFunctions).toHaveBeenCalledWith('local');
+ expect(getDownloadStream).toHaveBeenCalledWith(expect.anything(), '/images/owner/pic.png');
+ expect(backfillSharedLinkFiles).not.toHaveBeenCalled();
+ });
+
+ it('forces attachment for unsafe inline types (no stored XSS)', async () => {
+ const getDownloadStream = jest.fn(async () => Readable.from([' ']));
+ mockGetStrategyFunctions.mockReturnValue({ getDownloadStream });
+ getSharedLinkFile.mockResolvedValue({
+ file: {
+ file_id: 'file-1',
+ source: 'local',
+ filepath: '/uploads/owner/evil.svg',
+ type: 'image/svg+xml',
+ filename: 'evil.svg',
+ },
+ hasSnapshots: true,
+ });
+
+ const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
+
+ expect(response.status).toBe(200);
+ expect(response.headers['content-type']).toContain('application/octet-stream');
+ expect(response.headers['content-disposition']).toContain('attachment');
+ expect(response.headers['x-content-type-options']).toBe('nosniff');
+ });
+
+ it('downloads a snapshotted file as an attachment', async () => {
+ getSharedLinkFile.mockResolvedValue({
+ file: {
+ file_id: 'file-1',
+ source: 'local',
+ filepath: '/uploads/owner/file-1',
+ type: 'application/pdf',
+ filename: 'report.pdf',
+ },
+ hasSnapshots: true,
+ });
+
+ const response = await request(buildApp()).get('/api/share/share-123/files/file-1/download');
+
+ expect(response.status).toBe(200);
+ expect(response.headers['content-disposition']).toContain('attachment');
+ });
+
+ it('returns preview status read live from the file record', async () => {
+ getSharedLinkFile.mockResolvedValue({
+ file: { file_id: 'file-1', source: 'local' },
+ hasSnapshots: true,
+ });
+ getFiles.mockResolvedValue([{ status: 'ready', text: 'extracted text', textFormat: 'text' }]);
+
+ const response = await request(buildApp()).get('/api/share/share-123/files/file-1/preview');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ file_id: 'file-1',
+ status: 'ready',
+ text: 'extracted text',
+ textFormat: 'text',
+ });
+ expect(getFiles).toHaveBeenCalledWith({ file_id: 'file-1' }, null, {});
+ });
+
+ it('404s for a file not in the snapshot without rebuilding it', async () => {
+ getSharedLinkFile.mockResolvedValue({ file: null, hasSnapshots: true });
+
+ const response = await request(buildApp()).get('/api/share/share-123/files/not-shared');
+
+ expect(response.status).toBe(404);
+ expect(backfillSharedLinkFiles).not.toHaveBeenCalled();
+ expect(mockGetStrategyFunctions).not.toHaveBeenCalled();
+ });
+
+ it('lazily backfills only a legacy share that has no snapshot field', async () => {
+ getSharedLinkFile.mockResolvedValue({ file: null, hasSnapshots: false });
+ backfillSharedLinkFiles.mockResolvedValue({
+ file_id: 'file-1',
+ source: 'local',
+ filepath: '/images/owner/pic.png',
+ type: 'image/png',
+ filename: 'pic.png',
+ });
+
+ const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
+
+ expect(response.status).toBe(200);
+ expect(backfillSharedLinkFiles).toHaveBeenCalledWith('share-123', 'file-1');
+ });
+
+ it('404s cleanly when the snapshotted file is no longer available', async () => {
+ getSharedLinkFile.mockResolvedValue({
+ file: { file_id: 'file-1', source: 'local', filepath: '/uploads/owner/gone.pdf' },
+ hasSnapshots: true,
+ });
+ getFiles.mockResolvedValue([]); // original record deleted/expired
+
+ const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
+
+ expect(response.status).toBe(404);
+ expect(mockGetStrategyFunctions).not.toHaveBeenCalled();
+ });
+
+ it('404s (no serving) when the global kill switch is active', async () => {
+ isFileSnapshotKillSwitchActive.mockReturnValueOnce(true);
+
+ const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
+
+ expect(response.status).toBe(404);
+ expect(getSharedLinkFile).not.toHaveBeenCalled();
+ expect(mockGetStrategyFunctions).not.toHaveBeenCalled();
+ });
+
+ it('404s (no serving, no backfill) for a link that opted out of file sharing', async () => {
+ getSharedLinkFile.mockResolvedValue({ file: null, hasSnapshots: false, optedOut: true });
+
+ const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
+
+ expect(response.status).toBe(404);
+ expect(backfillSharedLinkFiles).not.toHaveBeenCalled();
+ expect(mockGetStrategyFunctions).not.toHaveBeenCalled();
+ });
+
+ it('404s when the snapshotted file version was overwritten (revision mismatch)', async () => {
+ getSharedLinkFile.mockResolvedValue({
+ file: {
+ file_id: 'file-1',
+ source: 'local',
+ filepath: '/uploads/owner/x',
+ previewRevision: 'r1',
+ },
+ hasSnapshots: true,
+ });
+ getFiles.mockResolvedValue([{ status: 'ready', previewRevision: 'r2' }]);
+
+ const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
+
+ expect(response.status).toBe(404);
+ expect(mockGetStrategyFunctions).not.toHaveBeenCalled();
+ });
+
+ it('404s when the snapshotted file was overwritten (size/bytes mismatch)', async () => {
+ getSharedLinkFile.mockResolvedValue({
+ file: { file_id: 'file-1', source: 'local', filepath: '/uploads/owner/x', bytes: 100 },
+ hasSnapshots: true,
+ });
+ getFiles.mockResolvedValue([{ status: 'ready', bytes: 200 }]);
+
+ const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
+
+ expect(response.status).toBe(404);
+ expect(mockGetStrategyFunctions).not.toHaveBeenCalled();
+ });
+
+ it('strips a cache-busting query string before local streaming', async () => {
+ const getDownloadStream = jest.fn(async () => Readable.from(['bytes']));
+ mockGetStrategyFunctions.mockReturnValue({ getDownloadStream });
+ getSharedLinkFile.mockResolvedValue({
+ file: {
+ file_id: 'file-1',
+ source: 'local',
+ filepath: '/images/owner/pic.png?v=2',
+ type: 'image/png',
+ filename: 'pic.png',
+ bytes: 100,
+ },
+ hasSnapshots: true,
+ });
+ getFiles.mockResolvedValue([{ status: 'ready', bytes: 100 }]);
+
+ const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
+
+ expect(response.status).toBe(200);
+ expect(getDownloadStream).toHaveBeenCalledWith(expect.anything(), '/images/owner/pic.png');
+ });
+
+ it('sweeps an orphaned pending preview to failed', async () => {
+ getSharedLinkFile.mockResolvedValue({
+ file: { file_id: 'file-1', source: 'local' },
+ hasSnapshots: true,
+ });
+ const stale = new Date(Date.now() - 5 * 60 * 1000);
+ getFiles.mockResolvedValue([{ status: 'pending', updatedAt: stale }]);
+ updateFile.mockResolvedValue({ status: 'failed', previewError: 'orphaned' });
+
+ const response = await request(buildApp()).get('/api/share/share-123/files/file-1/preview');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ file_id: 'file-1',
+ status: 'failed',
+ previewError: 'orphaned',
+ });
+ expect(updateFile).toHaveBeenCalledWith(
+ { file_id: 'file-1', status: 'failed', previewError: 'orphaned' },
+ { status: 'pending', updatedAt: stale },
+ );
+ });
+});
diff --git a/api/server/routes/__tests__/static.spec.js b/api/server/routes/__tests__/static.spec.js
new file mode 100644
index 0000000..239ad7e
--- /dev/null
+++ b/api/server/routes/__tests__/static.spec.js
@@ -0,0 +1,162 @@
+const fs = require('fs');
+const path = require('path');
+const express = require('express');
+const request = require('supertest');
+const zlib = require('zlib');
+
+// Create test setup
+const mockTestDir = path.join(__dirname, 'test-static-route');
+
+// Mock the paths module to point to our test directory
+jest.mock('~/config/paths', () => ({
+ imageOutput: mockTestDir,
+}));
+
+describe('Static Route Integration', () => {
+ let app;
+ let staticRoute;
+ let testDir;
+ let testImagePath;
+
+ beforeAll(() => {
+ // Create a test directory and files
+ testDir = mockTestDir;
+ testImagePath = path.join(testDir, 'test-image.jpg');
+
+ if (!fs.existsSync(testDir)) {
+ fs.mkdirSync(testDir, { recursive: true });
+ }
+
+ // Create a test image file
+ fs.writeFileSync(testImagePath, 'fake-image-data');
+
+ // Create a gzipped version of the test image (for gzip scanning tests)
+ fs.writeFileSync(testImagePath + '.gz', zlib.gzipSync('fake-image-data'));
+ });
+
+ afterAll(() => {
+ // Clean up test files
+ if (fs.existsSync(testDir)) {
+ fs.rmSync(testDir, { recursive: true, force: true });
+ }
+ });
+
+ // Helper function to set up static route with specific config
+ const setupStaticRoute = (skipGzipScan = false) => {
+ if (skipGzipScan) {
+ delete process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN;
+ } else {
+ process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN = 'true';
+ }
+
+ staticRoute = require('../static');
+ app.use('/images', staticRoute);
+ };
+
+ beforeEach(() => {
+ // Clear the module cache to get fresh imports
+ jest.resetModules();
+
+ app = express();
+
+ // Clear environment variables
+ delete process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN;
+ delete process.env.NODE_ENV;
+ });
+
+ describe('route functionality', () => {
+ it('should serve static image files', async () => {
+ process.env.NODE_ENV = 'production';
+ setupStaticRoute();
+
+ const response = await request(app).get('/images/test-image.jpg').expect(200);
+
+ expect(response.body.toString()).toBe('fake-image-data');
+ });
+
+ it('should return 404 for non-existent files', async () => {
+ setupStaticRoute();
+
+ const response = await request(app).get('/images/nonexistent.jpg');
+ expect(response.status).toBe(404);
+ });
+ });
+
+ describe('cache behavior', () => {
+ it('should set cache headers for images in production', async () => {
+ process.env.NODE_ENV = 'production';
+ setupStaticRoute();
+
+ const response = await request(app).get('/images/test-image.jpg').expect(200);
+
+ expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
+ });
+
+ it('should not set cache headers in development', async () => {
+ process.env.NODE_ENV = 'development';
+ setupStaticRoute();
+
+ const response = await request(app).get('/images/test-image.jpg').expect(200);
+
+ // Our middleware should not set the production cache-control header in development
+ expect(response.headers['cache-control']).not.toBe('public, max-age=172800, s-maxage=86400');
+ });
+ });
+
+ describe('gzip compression behavior', () => {
+ beforeEach(() => {
+ process.env.NODE_ENV = 'production';
+ });
+
+ it('should serve gzipped files when gzip scanning is enabled', async () => {
+ setupStaticRoute(false); // Enable gzip scanning
+
+ const response = await request(app)
+ .get('/images/test-image.jpg')
+ .set('Accept-Encoding', 'gzip')
+ .expect(200);
+
+ expect(response.headers['content-encoding']).toBe('gzip');
+ expect(response.body.toString()).toBe('fake-image-data');
+ });
+
+ it('should not serve gzipped files when gzip scanning is disabled', async () => {
+ setupStaticRoute(true); // Disable gzip scanning
+
+ const response = await request(app)
+ .get('/images/test-image.jpg')
+ .set('Accept-Encoding', 'gzip')
+ .expect(200);
+
+ expect(response.headers['content-encoding']).toBeUndefined();
+ expect(response.body.toString()).toBe('fake-image-data');
+ });
+ });
+
+ describe('path configuration', () => {
+ it('should use the configured imageOutput path', async () => {
+ setupStaticRoute();
+
+ const response = await request(app).get('/images/test-image.jpg').expect(200);
+
+ expect(response.body.toString()).toBe('fake-image-data');
+ });
+
+ it('should serve from subdirectories', async () => {
+ // Create a subdirectory with a file
+ const subDir = path.join(testDir, 'thumbs');
+ fs.mkdirSync(subDir, { recursive: true });
+ const thumbPath = path.join(subDir, 'thumb.jpg');
+ fs.writeFileSync(thumbPath, 'thumbnail-data');
+
+ setupStaticRoute();
+
+ const response = await request(app).get('/images/thumbs/thumb.jpg').expect(200);
+
+ expect(response.body.toString()).toBe('thumbnail-data');
+
+ // Clean up
+ fs.rmSync(subDir, { recursive: true, force: true });
+ });
+ });
+});
diff --git a/api/server/routes/accessPermissions.js b/api/server/routes/accessPermissions.js
new file mode 100644
index 0000000..6ef731d
--- /dev/null
+++ b/api/server/routes/accessPermissions.js
@@ -0,0 +1,197 @@
+const mongoose = require('mongoose');
+const express = require('express');
+const {
+ AccessRoleIds,
+ PrincipalType,
+ ResourceType,
+ PermissionBits,
+} = require('librechat-data-provider');
+const {
+ getUserEffectivePermissions,
+ getAllEffectivePermissions,
+ updateResourcePermissions,
+ getResourcePermissions,
+ getResourceRoles,
+ searchPrincipals,
+} = require('~/server/controllers/PermissionsController');
+const {
+ checkShareAccess,
+ checkSharePublicAccess,
+} = require('~/server/middleware/checkSharePublicAccess');
+const { requireJwtAuth, checkBan, uaParser, canAccessResource } = require('~/server/middleware');
+const { checkPeoplePickerAccess } = require('~/server/middleware/checkPeoplePickerAccess');
+const { findMCPServerByObjectId, getSkillById } = require('~/models');
+
+const router = express.Router();
+
+// Apply common middleware
+router.use(requireJwtAuth);
+router.use(checkBan);
+router.use(uaParser);
+
+/**
+ * Generic routes for resource permissions
+ * Pattern: /api/permissions/{resourceType}/{resourceId}
+ */
+
+/**
+ * GET /api/permissions/search-principals
+ * Search for users and groups to grant permissions
+ */
+router.get('/search-principals', checkPeoplePickerAccess, searchPrincipals);
+
+/**
+ * GET /api/permissions/{resourceType}/roles
+ * Get available roles for a resource type
+ */
+router.get('/:resourceType/roles', getResourceRoles);
+
+/**
+ * Middleware factory to check resource access for permission-related operations.
+ * SECURITY: Users must have SHARE permission to view or modify resource permissions.
+ * @param {string} requiredPermission - The permission bit required (e.g., SHARE)
+ * @returns Express middleware function
+ */
+const checkResourcePermissionAccess = (requiredPermission) => (req, res, next) => {
+ const { resourceType } = req.params;
+ let middleware;
+
+ if (resourceType === ResourceType.AGENT) {
+ middleware = canAccessResource({
+ resourceType: ResourceType.AGENT,
+ requiredPermission,
+ resourceIdParam: 'resourceId',
+ });
+ } else if (resourceType === ResourceType.REMOTE_AGENT) {
+ middleware = canAccessResource({
+ resourceType: ResourceType.REMOTE_AGENT,
+ requiredPermission,
+ resourceIdParam: 'resourceId',
+ });
+ } else if (resourceType === ResourceType.PROMPTGROUP) {
+ middleware = canAccessResource({
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermission,
+ resourceIdParam: 'resourceId',
+ });
+ } else if (resourceType === ResourceType.MCPSERVER) {
+ middleware = canAccessResource({
+ resourceType: ResourceType.MCPSERVER,
+ requiredPermission,
+ resourceIdParam: 'resourceId',
+ idResolver: findMCPServerByObjectId,
+ });
+ } else if (resourceType === ResourceType.SKILL) {
+ middleware = canAccessResource({
+ resourceType: ResourceType.SKILL,
+ requiredPermission,
+ resourceIdParam: 'resourceId',
+ idResolver: getSkillById,
+ });
+ } else if (resourceType === ResourceType.SHARED_LINK) {
+ middleware = canAccessResource({
+ resourceType: ResourceType.SHARED_LINK,
+ requiredPermission,
+ resourceIdParam: 'resourceId',
+ });
+ } else {
+ return res.status(400).json({
+ error: 'Bad Request',
+ message: `Unsupported resource type: ${resourceType}`,
+ });
+ }
+
+ // Execute the middleware
+ middleware(req, res, next);
+};
+
+const rejectSharedLinkOwnerPermissionChanges = async (req, res, next) => {
+ if (req.params.resourceType !== ResourceType.SHARED_LINK) {
+ return next();
+ }
+
+ const updated = Array.isArray(req.body?.updated) ? req.body.updated : [];
+ const removed = Array.isArray(req.body?.removed) ? req.body.removed : [];
+ const grantsOwner = updated.some(
+ (principal) => principal?.accessRoleId === AccessRoleIds.SHARED_LINK_OWNER,
+ );
+ const grantsPublicOwner = req.body?.publicAccessRoleId === AccessRoleIds.SHARED_LINK_OWNER;
+
+ if (grantsOwner || grantsPublicOwner) {
+ return res.status(400).json({
+ error: 'Bad Request',
+ message: 'Shared link owner permissions cannot be changed',
+ });
+ }
+
+ const userMutations = [...updated, ...removed].filter(
+ (principal) => principal?.type === PrincipalType.USER && principal?.id,
+ );
+
+ if (userMutations.length === 0) {
+ return next();
+ }
+
+ try {
+ const SharedLink = mongoose.models.SharedLink;
+ const link = await SharedLink.findById(req.params.resourceId, 'user').lean();
+ const ownerId = link?.user?.toString();
+ const touchesOwner = ownerId
+ ? userMutations.some((principal) => principal.id?.toString() === ownerId)
+ : false;
+
+ if (touchesOwner) {
+ return res.status(400).json({
+ error: 'Bad Request',
+ message: 'Shared link owner permissions cannot be changed',
+ });
+ }
+ } catch (_error) {
+ return res.status(500).json({
+ error: 'Internal Server Error',
+ message: 'Failed to validate shared link owner permissions',
+ });
+ }
+
+ return next();
+};
+
+/**
+ * GET /api/permissions/{resourceType}/{resourceId}
+ * Get all permissions for a specific resource
+ * SECURITY: Requires SHARE permission to view resource permissions
+ */
+router.get(
+ '/:resourceType/:resourceId',
+ checkResourcePermissionAccess(PermissionBits.SHARE),
+ getResourcePermissions,
+);
+
+/**
+ * PUT /api/permissions/{resourceType}/{resourceId}
+ * Bulk update permissions for a specific resource
+ * SECURITY: Requires resource ACL SHARE and role SHARE to modify resource permissions
+ * SECURITY: Requires SHARE_PUBLIC permission to enable public sharing
+ */
+router.put(
+ '/:resourceType/:resourceId',
+ checkResourcePermissionAccess(PermissionBits.SHARE),
+ checkShareAccess,
+ checkSharePublicAccess,
+ rejectSharedLinkOwnerPermissionChanges,
+ updateResourcePermissions,
+);
+
+/**
+ * GET /api/permissions/{resourceType}/effective/all
+ * Get user's effective permissions for all accessible resources of a type
+ */
+router.get('/:resourceType/effective/all', getAllEffectivePermissions);
+
+/**
+ * GET /api/permissions/{resourceType}/{resourceId}/effective
+ * Get user's effective permissions for a specific resource
+ */
+router.get('/:resourceType/:resourceId/effective', getUserEffectivePermissions);
+
+module.exports = router;
diff --git a/api/server/routes/accessPermissions.sharePolicy.test.js b/api/server/routes/accessPermissions.sharePolicy.test.js
new file mode 100644
index 0000000..ed17a04
--- /dev/null
+++ b/api/server/routes/accessPermissions.sharePolicy.test.js
@@ -0,0 +1,320 @@
+jest.mock('~/models', () => ({
+ getRoleByName: jest.fn(),
+ findMCPServerByObjectId: jest.fn(),
+ getSkillById: jest.fn(),
+}));
+
+jest.mock('~/server/middleware', () => ({
+ requireJwtAuth: (req, _res, next) => next(),
+ checkBan: (_req, _res, next) => next(),
+ uaParser: (_req, _res, next) => next(),
+ canAccessResource: jest.fn(() => (_req, _res, next) => next()),
+}));
+
+jest.mock('~/server/middleware/checkPeoplePickerAccess', () => ({
+ checkPeoplePickerAccess: jest.fn((_req, _res, next) => next()),
+}));
+
+jest.mock('~/server/middleware/roles/capabilities', () => ({
+ hasCapability: jest.fn(),
+}));
+
+jest.mock('~/server/controllers/PermissionsController', () => ({
+ getUserEffectivePermissions: jest.fn((_req, res) => res.json({ permissions: [] })),
+ getAllEffectivePermissions: jest.fn((_req, res) => res.json({ permissions: [] })),
+ updateResourcePermissions: jest.fn((_req, res) => res.json({ success: true })),
+ getResourcePermissions: jest.fn((_req, res) => res.json({ permissions: [] })),
+ getResourceRoles: jest.fn((_req, res) => res.json({ roles: [] })),
+ searchPrincipals: jest.fn((_req, res) => res.json({ principals: [] })),
+}));
+
+const express = require('express');
+const request = require('supertest');
+const mongoose = require('mongoose');
+const {
+ SystemRoles,
+ ResourceType,
+ AccessRoleIds,
+ PrincipalType,
+ PermissionBits,
+ PermissionTypes,
+ Permissions,
+} = require('librechat-data-provider');
+
+const { updateResourcePermissions } = require('~/server/controllers/PermissionsController');
+const { hasCapability } = require('~/server/middleware/roles/capabilities');
+const { canAccessResource } = require('~/server/middleware');
+const accessPermissionsRouter = require('./accessPermissions');
+const { getRoleByName } = require('~/models');
+
+describe('Access permissions share policy', () => {
+ let app;
+ const mockSharedLinkFindById = jest.fn();
+ const originalSharedLinkModel = mongoose.models.SharedLink;
+
+ const resourceId = '507f1f77bcf86cd799439011';
+ const sharePolicyCases = [
+ {
+ label: 'agent',
+ resourceType: ResourceType.AGENT,
+ permissionType: PermissionTypes.AGENTS,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ middlewareOptions: {
+ resourceType: ResourceType.AGENT,
+ requiredPermission: PermissionBits.SHARE,
+ resourceIdParam: 'resourceId',
+ },
+ },
+ {
+ label: 'prompt group',
+ resourceType: ResourceType.PROMPTGROUP,
+ permissionType: PermissionTypes.PROMPTS,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
+ middlewareOptions: {
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermission: PermissionBits.SHARE,
+ resourceIdParam: 'resourceId',
+ },
+ },
+ {
+ label: 'MCP server',
+ resourceType: ResourceType.MCPSERVER,
+ permissionType: PermissionTypes.MCP_SERVERS,
+ accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
+ middlewareOptions: {
+ resourceType: ResourceType.MCPSERVER,
+ requiredPermission: PermissionBits.SHARE,
+ resourceIdParam: 'resourceId',
+ idResolver: expect.any(Function),
+ },
+ },
+ {
+ label: 'remote agent',
+ resourceType: ResourceType.REMOTE_AGENT,
+ permissionType: PermissionTypes.REMOTE_AGENTS,
+ accessRoleId: AccessRoleIds.REMOTE_AGENT_VIEWER,
+ middlewareOptions: {
+ resourceType: ResourceType.REMOTE_AGENT,
+ requiredPermission: PermissionBits.SHARE,
+ resourceIdParam: 'resourceId',
+ },
+ },
+ {
+ label: 'skill',
+ resourceType: ResourceType.SKILL,
+ permissionType: PermissionTypes.SKILLS,
+ accessRoleId: AccessRoleIds.SKILL_VIEWER,
+ middlewareOptions: {
+ resourceType: ResourceType.SKILL,
+ requiredPermission: PermissionBits.SHARE,
+ resourceIdParam: 'resourceId',
+ idResolver: expect.any(Function),
+ },
+ },
+ ];
+
+ const createUpdatedPrincipal = (accessRoleId) => ({
+ type: PrincipalType.USER,
+ id: 'target-user',
+ accessRoleId,
+ });
+
+ const allowSharedLinkSharing = () => {
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.SHARED_LINKS]: {
+ [Permissions.SHARE]: true,
+ [Permissions.SHARE_PUBLIC]: true,
+ },
+ },
+ });
+ };
+
+ const mockSharedLinkOwner = (ownerId = 'owner-user') => {
+ mockSharedLinkFindById.mockReturnValue({
+ lean: jest.fn().mockResolvedValue({ user: ownerId }),
+ });
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ if (mongoose.models.SharedLink) {
+ mongoose.models.SharedLink.findById = mockSharedLinkFindById;
+ } else {
+ mongoose.models.SharedLink = { findById: mockSharedLinkFindById };
+ }
+ hasCapability.mockResolvedValue(false);
+
+ app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ req.user = { id: 'skill-owner', role: SystemRoles.USER };
+ next();
+ });
+ app.use('/api/permissions', accessPermissionsRouter);
+ });
+
+ afterAll(() => {
+ if (originalSharedLinkModel) {
+ mongoose.models.SharedLink = originalSharedLinkModel;
+ } else {
+ delete mongoose.models.SharedLink;
+ }
+ });
+
+ it.each(sharePolicyCases)(
+ 'blocks non-public $label sharing when ACL SHARE passes but role SHARE is disabled',
+ async ({ resourceType, permissionType, accessRoleId, middlewareOptions }) => {
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [permissionType]: {
+ [Permissions.SHARE]: false,
+ [Permissions.SHARE_PUBLIC]: false,
+ },
+ },
+ });
+
+ const response = await request(app)
+ .put(`/api/permissions/${resourceType}/${resourceId}`)
+ .send({ updated: [createUpdatedPrincipal(accessRoleId)], public: false });
+
+ expect(response.status).toBe(403);
+ expect(response.body).toEqual({
+ error: 'Forbidden',
+ message: `You do not have permission to share ${resourceType} resources`,
+ });
+ expect(canAccessResource).toHaveBeenCalledWith(middlewareOptions);
+ expect(updateResourcePermissions).not.toHaveBeenCalled();
+ },
+ );
+
+ it('allows non-public skill sharing when both ACL SHARE and role SKILLS.SHARE pass', async () => {
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.SKILLS]: {
+ [Permissions.SHARE]: true,
+ [Permissions.SHARE_PUBLIC]: false,
+ },
+ },
+ });
+
+ const response = await request(app)
+ .put(`/api/permissions/${ResourceType.SKILL}/${resourceId}`)
+ .send({ updated: [createUpdatedPrincipal(AccessRoleIds.SKILL_VIEWER)], public: false });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ success: true });
+ expect(updateResourcePermissions).toHaveBeenCalledTimes(1);
+ });
+
+ it('preserves resource management capability bypass for non-public skill sharing', async () => {
+ hasCapability.mockResolvedValue(true);
+
+ const response = await request(app)
+ .put(`/api/permissions/${ResourceType.SKILL}/${resourceId}`)
+ .send({ updated: [createUpdatedPrincipal(AccessRoleIds.SKILL_VIEWER)], public: false });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ success: true });
+ expect(getRoleByName).not.toHaveBeenCalled();
+ expect(updateResourcePermissions).toHaveBeenCalledTimes(1);
+ });
+
+ it('still requires SHARE_PUBLIC when enabling public skill sharing', async () => {
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.SKILLS]: {
+ [Permissions.SHARE]: true,
+ [Permissions.SHARE_PUBLIC]: false,
+ },
+ },
+ });
+
+ const response = await request(app)
+ .put(`/api/permissions/${ResourceType.SKILL}/${resourceId}`)
+ .send({ public: true, publicAccessRoleId: AccessRoleIds.SKILL_VIEWER });
+
+ expect(response.status).toBe(403);
+ expect(response.body).toEqual({
+ error: 'Forbidden',
+ message: `You do not have permission to share ${ResourceType.SKILL} resources publicly`,
+ });
+ expect(updateResourcePermissions).not.toHaveBeenCalled();
+ });
+
+ it('blocks granting shared-link owner through generic permission updates', async () => {
+ allowSharedLinkSharing();
+ mockSharedLinkOwner();
+
+ const response = await request(app)
+ .put(`/api/permissions/${ResourceType.SHARED_LINK}/${resourceId}`)
+ .send({
+ updated: [
+ {
+ type: PrincipalType.USER,
+ id: 'target-user',
+ accessRoleId: AccessRoleIds.SHARED_LINK_OWNER,
+ },
+ ],
+ public: false,
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Shared link owner permissions cannot be changed');
+ expect(updateResourcePermissions).not.toHaveBeenCalled();
+ });
+
+ it('blocks granting shared-link owner to the public principal', async () => {
+ allowSharedLinkSharing();
+
+ const response = await request(app)
+ .put(`/api/permissions/${ResourceType.SHARED_LINK}/${resourceId}`)
+ .send({
+ public: true,
+ publicAccessRoleId: AccessRoleIds.SHARED_LINK_OWNER,
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Shared link owner permissions cannot be changed');
+ expect(updateResourcePermissions).not.toHaveBeenCalled();
+ expect(mockSharedLinkFindById).not.toHaveBeenCalled();
+ });
+
+ it('blocks removing the canonical shared-link owner', async () => {
+ allowSharedLinkSharing();
+ mockSharedLinkOwner('owner-user');
+
+ const response = await request(app)
+ .put(`/api/permissions/${ResourceType.SHARED_LINK}/${resourceId}`)
+ .send({
+ updated: [],
+ removed: [{ type: PrincipalType.USER, id: 'owner-user' }],
+ public: false,
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Shared link owner permissions cannot be changed');
+ expect(updateResourcePermissions).not.toHaveBeenCalled();
+ });
+
+ it('allows viewer grants for non-owner shared-link users', async () => {
+ allowSharedLinkSharing();
+ mockSharedLinkOwner('owner-user');
+
+ const response = await request(app)
+ .put(`/api/permissions/${ResourceType.SHARED_LINK}/${resourceId}`)
+ .send({
+ updated: [
+ {
+ type: PrincipalType.USER,
+ id: 'target-user',
+ accessRoleId: AccessRoleIds.SHARED_LINK_VIEWER,
+ },
+ ],
+ public: false,
+ });
+
+ expect(response.status).toBe(200);
+ expect(updateResourcePermissions).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/api/server/routes/accessPermissions.test.js b/api/server/routes/accessPermissions.test.js
new file mode 100644
index 0000000..ddbe702
--- /dev/null
+++ b/api/server/routes/accessPermissions.test.js
@@ -0,0 +1,228 @@
+const express = require('express');
+const request = require('supertest');
+const mongoose = require('mongoose');
+const { v4: uuidv4 } = require('uuid');
+const { createMethods } = require('@librechat/data-schemas');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { ResourceType, PermissionBits } = require('librechat-data-provider');
+const { createAgent } = require('~/models');
+
+/**
+ * Mock the PermissionsController to isolate route testing
+ */
+jest.mock('~/server/controllers/PermissionsController', () => ({
+ getUserEffectivePermissions: jest.fn((req, res) => res.json({ permissions: [] })),
+ getAllEffectivePermissions: jest.fn((req, res) => res.json({ permissions: [] })),
+ updateResourcePermissions: jest.fn((req, res) => res.json({ success: true })),
+ getResourcePermissions: jest.fn((req, res) =>
+ res.json({
+ resourceType: req.params.resourceType,
+ resourceId: req.params.resourceId,
+ principals: [],
+ public: false,
+ }),
+ ),
+ getResourceRoles: jest.fn((req, res) => res.json({ roles: [] })),
+ searchPrincipals: jest.fn((req, res) => res.json({ principals: [] })),
+}));
+
+jest.mock('~/server/middleware/checkPeoplePickerAccess', () => ({
+ checkPeoplePickerAccess: jest.fn((req, res, next) => next()),
+}));
+
+// Import actual middleware to get canAccessResource
+const { canAccessResource } = require('~/server/middleware');
+const { findMCPServerByObjectId } = require('~/models');
+
+/**
+ * Security Tests for SBA-ADV-20251203-02
+ *
+ * These tests verify that users cannot query or modify agent permissions
+ * without proper SHARE permission.
+ */
+describe('Access Permissions Routes - Security Tests (SBA-ADV-20251203-02)', () => {
+ let app;
+ let mongoServer;
+ let authorId;
+ let attackerId;
+ let agentId;
+ let methods;
+ let User;
+ let modelsToCleanup = [];
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+
+ // Initialize models
+ const { createModels } = require('@librechat/data-schemas');
+ const models = createModels(mongoose);
+ modelsToCleanup = Object.keys(models);
+ Object.assign(mongoose.models, models);
+
+ methods = createMethods(mongoose);
+ User = models.User;
+
+ await methods.seedDefaultRoles();
+ });
+
+ afterAll(async () => {
+ const collections = mongoose.connection.collections;
+ for (const key in collections) {
+ await collections[key].deleteMany({});
+ }
+ for (const modelName of modelsToCleanup) {
+ delete mongoose.models[modelName];
+ }
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ // Clear all collections
+ const collections = mongoose.connection.collections;
+ for (const key in collections) {
+ await collections[key].deleteMany({});
+ }
+ await methods.seedDefaultRoles();
+
+ // Create author (owner of the agent)
+ authorId = new mongoose.Types.ObjectId().toString();
+ await User.create({
+ _id: authorId,
+ name: 'Agent Owner',
+ email: 'owner@example.com',
+ username: 'owner@example.com',
+ provider: 'local',
+ });
+
+ // Create attacker (should not have access)
+ attackerId = new mongoose.Types.ObjectId().toString();
+ await User.create({
+ _id: attackerId,
+ name: 'Attacker',
+ email: 'attacker@example.com',
+ username: 'attacker@example.com',
+ provider: 'local',
+ });
+
+ // Create private agent owned by author
+ const customAgentId = `agent_${uuidv4().replace(/-/g, '').substring(0, 20)}`;
+ await createAgent({
+ id: customAgentId,
+ name: 'Private Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+ agentId = customAgentId;
+
+ // Create Express app with attacker as current user
+ app = express();
+ app.use(express.json());
+
+ // Mock authentication middleware - attacker is the current user
+ app.use((req, res, next) => {
+ req.user = { id: attackerId, role: 'USER' };
+ req.app = { locals: {} };
+ next();
+ });
+
+ // Middleware factory for permission access check (mirrors actual implementation)
+ const checkResourcePermissionAccess = (requiredPermission) => (req, res, next) => {
+ const { resourceType } = req.params;
+ let middleware;
+
+ if (resourceType === ResourceType.AGENT) {
+ middleware = canAccessResource({
+ resourceType: ResourceType.AGENT,
+ requiredPermission,
+ resourceIdParam: 'resourceId',
+ });
+ } else if (resourceType === ResourceType.PROMPTGROUP) {
+ middleware = canAccessResource({
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermission,
+ resourceIdParam: 'resourceId',
+ });
+ } else if (resourceType === ResourceType.MCPSERVER) {
+ middleware = canAccessResource({
+ resourceType: ResourceType.MCPSERVER,
+ requiredPermission,
+ resourceIdParam: 'resourceId',
+ idResolver: findMCPServerByObjectId,
+ });
+ } else {
+ return res.status(400).json({
+ error: 'Bad Request',
+ message: `Unsupported resource type: ${resourceType}`,
+ });
+ }
+
+ middleware(req, res, next);
+ };
+
+ // GET route with access control (THE FIX)
+ app.get(
+ '/permissions/:resourceType/:resourceId',
+ checkResourcePermissionAccess(PermissionBits.SHARE),
+ (req, res) =>
+ res.json({
+ resourceType: req.params.resourceType,
+ resourceId: req.params.resourceId,
+ principals: [],
+ public: false,
+ }),
+ );
+
+ // PUT route with access control
+ app.put(
+ '/permissions/:resourceType/:resourceId',
+ checkResourcePermissionAccess(PermissionBits.SHARE),
+ (req, res) => res.json({ success: true }),
+ );
+ });
+
+ describe('GET /permissions/:resourceType/:resourceId', () => {
+ it('should deny permission query for user without access (main vulnerability test)', async () => {
+ /**
+ * SECURITY TEST: This is the core test for SBA-ADV-20251203-02
+ *
+ * Before the fix, any authenticated user could query permissions for
+ * any agent by just knowing the agent ID, exposing information about
+ * who has access to private agents.
+ *
+ * After the fix, users must have SHARE permission to view permissions.
+ */
+ const response = await request(app)
+ .get(`/permissions/agent/${agentId}`)
+ .set('Content-Type', 'application/json');
+
+ // Should be denied - attacker has no permission on the agent
+ expect(response.status).toBe(403);
+ expect(response.body.error).toBe('Forbidden');
+ });
+
+ it('should return 400 for unsupported resource type', async () => {
+ const response = await request(app)
+ .get(`/permissions/unsupported/${agentId}`)
+ .set('Content-Type', 'application/json');
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toContain('Unsupported resource type');
+ });
+ });
+
+ describe('PUT /permissions/:resourceType/:resourceId', () => {
+ it('should deny permission update for user without access', async () => {
+ const response = await request(app)
+ .put(`/permissions/agent/${agentId}`)
+ .set('Content-Type', 'application/json')
+ .send({ principals: [] });
+
+ expect(response.status).toBe(403);
+ expect(response.body.error).toBe('Forbidden');
+ });
+ });
+});
diff --git a/api/server/routes/actions.js b/api/server/routes/actions.js
new file mode 100644
index 0000000..d9a2f2f
--- /dev/null
+++ b/api/server/routes/actions.js
@@ -0,0 +1,133 @@
+const express = require('express');
+const jwt = require('jsonwebtoken');
+const { logger } = require('@librechat/data-schemas');
+const { CacheKeys } = require('librechat-data-provider');
+const {
+ getBasePath,
+ getAccessToken,
+ setOAuthSession,
+ validateOAuthCsrf,
+ OAUTH_CSRF_COOKIE,
+ setOAuthCsrfCookie,
+ validateOAuthSession,
+ OAUTH_SESSION_COOKIE,
+} = require('@librechat/api');
+const { findToken, updateToken, createToken } = require('~/models');
+const { requireJwtAuth } = require('~/server/middleware');
+const { getActionFlowStateManager } = require('~/config');
+const { getLogStores } = require('~/cache');
+
+const router = express.Router();
+const JWT_SECRET = process.env.JWT_SECRET;
+const OAUTH_CSRF_COOKIE_PATH = '/api/actions';
+
+/**
+ * Sets a CSRF cookie binding the action OAuth flow to the current browser session.
+ * Must be called before the user opens the IdP authorization URL.
+ *
+ * @route POST /actions/:action_id/oauth/bind
+ */
+router.post('/:action_id/oauth/bind', requireJwtAuth, setOAuthSession, async (req, res) => {
+ try {
+ const { action_id } = req.params;
+ const user = req.user;
+ if (!user?.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+ const flowId = `${user.id}:${action_id}`;
+ setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH);
+ res.json({ success: true });
+ } catch (error) {
+ logger.error('[Action OAuth] Failed to set CSRF binding cookie', error);
+ res.status(500).json({ error: 'Failed to bind OAuth flow' });
+ }
+});
+
+/**
+ * Handles the OAuth callback and exchanges the authorization code for tokens.
+ *
+ * @route GET /actions/:action_id/oauth/callback
+ * @param {string} req.params.action_id - The ID of the action.
+ * @param {string} req.query.code - The authorization code returned by the provider.
+ * @param {string} req.query.state - The state token to verify the authenticity of the request.
+ * @returns {void} Sends a success message after updating the action with OAuth tokens.
+ */
+router.get('/:action_id/oauth/callback', async (req, res) => {
+ const { action_id } = req.params;
+ const { code, state } = req.query;
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getActionFlowStateManager(flowsCache);
+ const basePath = getBasePath();
+ let identifier = action_id;
+ try {
+ let decodedState;
+ try {
+ decodedState = jwt.verify(state, JWT_SECRET);
+ } catch (err) {
+ logger.error('Error verifying state parameter:', err);
+ await flowManager.failFlow(identifier, 'oauth', 'Invalid or expired state parameter');
+ return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
+ }
+
+ if (decodedState.action_id !== action_id) {
+ await flowManager.failFlow(identifier, 'oauth', 'Mismatched action ID in state parameter');
+ return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
+ }
+
+ if (!decodedState.user) {
+ await flowManager.failFlow(identifier, 'oauth', 'Invalid user ID in state parameter');
+ return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
+ }
+
+ identifier = `${decodedState.user}:${action_id}`;
+
+ if (
+ !validateOAuthCsrf(req, res, identifier, OAUTH_CSRF_COOKIE_PATH) &&
+ !validateOAuthSession(req, decodedState.user)
+ ) {
+ logger.error('[Action OAuth] CSRF validation failed: no valid CSRF or session cookie', {
+ identifier,
+ hasCsrfCookie: !!req.cookies?.[OAUTH_CSRF_COOKIE],
+ hasSessionCookie: !!req.cookies?.[OAUTH_SESSION_COOKIE],
+ });
+ await flowManager.failFlow(identifier, 'oauth', 'CSRF validation failed');
+ return res.redirect(`${basePath}/oauth/error?error=csrf_validation_failed`);
+ }
+
+ const flowState = await flowManager.getFlowState(identifier, 'oauth');
+ if (!flowState) {
+ throw new Error('OAuth flow not found');
+ }
+
+ const tokenData = await getAccessToken(
+ {
+ code,
+ userId: decodedState.user,
+ identifier,
+ client_url: flowState.metadata.client_url,
+ redirect_uri: flowState.metadata.redirect_uri,
+ token_exchange_method: flowState.metadata.token_exchange_method,
+ allowedAddresses: flowState.metadata.allowedAddresses,
+ /** Encrypted values */
+ encrypted_oauth_client_id: flowState.metadata.encrypted_oauth_client_id,
+ encrypted_oauth_client_secret: flowState.metadata.encrypted_oauth_client_secret,
+ },
+ {
+ findToken,
+ updateToken,
+ createToken,
+ },
+ );
+ await flowManager.completeFlow(identifier, 'oauth', tokenData);
+
+ const serverName = flowState.metadata?.action_name || `Action ${action_id}`;
+ const redirectUrl = `${basePath}/oauth/success?serverName=${encodeURIComponent(serverName)}`;
+ res.redirect(redirectUrl);
+ } catch (error) {
+ logger.error('Error in OAuth callback:', error);
+ await flowManager.failFlow(identifier, 'oauth', error);
+ res.redirect(`${basePath}/oauth/error?error=callback_failed`);
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/admin/audit.js b/api/server/routes/admin/audit.js
new file mode 100644
index 0000000..7332f82
--- /dev/null
+++ b/api/server/routes/admin/audit.js
@@ -0,0 +1,35 @@
+const express = require('express');
+const { createAdminAuditLogHandlers } = require('@librechat/api');
+const { SystemCapabilities } = require('@librechat/data-schemas');
+const { requireCapability } = require('~/server/middleware/roles/capabilities');
+const { requireJwtAuth } = require('~/server/middleware');
+const db = require('~/models');
+
+const router = express.Router();
+
+const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
+const requireAuditLogRead = requireCapability(SystemCapabilities.READ_AUDIT_LOG);
+
+const handlers = createAdminAuditLogHandlers({
+ listAuditLogPage: db.listAuditLogPage,
+ findAuditLogEntry: db.findAuditLogEntry,
+ streamAuditLogEntries: db.streamAuditLogEntries,
+ verifyAuditChain: db.verifyAuditChain,
+});
+
+/**
+ * `ACCESS_ADMIN` gates entry to the admin surface; `READ_AUDIT_LOG` then gates
+ * this specific feature within that surface. The two capabilities are
+ * independent in `CapabilityImplications`, so a role delegated only
+ * `READ_AUDIT_LOG` without `ACCESS_ADMIN` would otherwise bypass the admin
+ * boundary on this router — every other admin router enforces the same pair.
+ */
+router.use(requireJwtAuth, requireAdminAccess, requireAuditLogRead);
+
+router.get('/', handlers.listAuditLog);
+/** Literal sub-paths MUST precede `/:id` so they aren't matched as `{ id }`. */
+router.get('/export.csv', handlers.exportAuditLogCsv);
+router.get('/verify', handlers.verifyAuditLog);
+router.get('/:id', handlers.getAuditLogEntry);
+
+module.exports = router;
diff --git a/api/server/routes/admin/auth.js b/api/server/routes/admin/auth.js
new file mode 100644
index 0000000..4708123
--- /dev/null
+++ b/api/server/routes/admin/auth.js
@@ -0,0 +1,622 @@
+const express = require('express');
+const passport = require('passport');
+const crypto = require('node:crypto');
+const openIdClient = require('openid-client');
+const { CacheKeys } = require('librechat-data-provider');
+const {
+ logger,
+ DEFAULT_SESSION_EXPIRY,
+ SystemCapabilities,
+ getTenantId,
+} = require('@librechat/data-schemas');
+const {
+ isEnabled,
+ getAdminPanelUrl,
+ exchangeAdminCode,
+ createSetBalanceConfig,
+ storeAndStripChallenge,
+ tenantContextMiddleware,
+ preAuthTenantMiddleware,
+ applyAdminRefresh,
+ AdminRefreshError,
+ buildOpenIDRefreshParams,
+} = require('@librechat/api');
+const { loginController } = require('~/server/controllers/auth/LoginController');
+const { hasCapability, requireCapability } = require('~/server/middleware/roles/capabilities');
+const { createOAuthHandler } = require('~/server/controllers/auth/oauth');
+const {
+ findBalanceByUser,
+ findUsers,
+ generateToken,
+ getUserById,
+ upsertBalanceFields,
+} = require('~/models');
+const { getAppConfig } = require('~/server/services/Config');
+const getLogStores = require('~/cache/getLogStores');
+const { getOpenIdConfig } = require('~/strategies');
+const middleware = require('~/server/middleware');
+
+const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
+
+const setBalanceConfig = createSetBalanceConfig({
+ getAppConfig,
+ findBalanceByUser,
+ upsertBalanceFields,
+});
+
+const router = express.Router();
+
+function resolveRequestOrigin(req) {
+ const originHeader = req.get('origin');
+ if (originHeader) {
+ try {
+ return new URL(originHeader).origin;
+ } catch {
+ return undefined;
+ }
+ }
+
+ const refererHeader = req.get('referer');
+ if (!refererHeader) {
+ return undefined;
+ }
+
+ try {
+ return new URL(refererHeader).origin;
+ } catch {
+ return undefined;
+ }
+}
+
+router.post(
+ '/login/local',
+ middleware.logHeaders,
+ middleware.loginLimiter,
+ middleware.checkBan,
+ middleware.requireLocalAuth,
+ tenantContextMiddleware,
+ requireAdminAccess,
+ setBalanceConfig,
+ loginController,
+);
+
+router.get('/verify', middleware.requireJwtAuth, requireAdminAccess, (req, res) => {
+ const { password: _p, totpSecret: _t, __v, ...user } = req.user;
+ user.id = user._id.toString();
+ res.status(200).json({ user });
+});
+
+router.get('/oauth/openid/check', (req, res) => {
+ const openidConfig = getOpenIdConfig();
+ if (!openidConfig) {
+ return res.status(404).json({
+ error: 'OpenID configuration not found',
+ error_code: 'OPENID_NOT_CONFIGURED',
+ });
+ }
+ res.status(200).json({ message: 'OpenID check successful' });
+});
+
+/**
+ * Generates a random hex state string for OAuth flows.
+ * @returns {string} A 32-byte random hex string.
+ */
+function generateState() {
+ return crypto.randomBytes(32).toString('hex');
+}
+
+/**
+ * Middleware to retrieve PKCE challenge from cache using the OAuth state.
+ * Reads state from req.oauthState (set by a preceding middleware).
+ * @param {string} provider - Provider name for logging.
+ * @returns {Function} Express middleware.
+ */
+function retrievePkceChallenge(provider) {
+ return async (req, res, next) => {
+ if (!req.oauthState) {
+ return next();
+ }
+ try {
+ const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
+ const challenge = await cache.get(`pkce:${req.oauthState}`);
+ if (challenge) {
+ req.pkceChallenge = challenge;
+ await cache.delete(`pkce:${req.oauthState}`);
+ } else {
+ logger.warn(
+ `[admin/oauth/${provider}/callback] State present but no PKCE challenge found; PKCE will not be enforced for this request`,
+ );
+ }
+ } catch (err) {
+ logger.error(
+ `[admin/oauth/${provider}/callback] Failed to retrieve PKCE challenge, aborting:`,
+ err,
+ );
+ return res.redirect(
+ `${getAdminPanelUrl()}/auth/${provider}/callback?error=pkce_retrieval_failed&error_description=Failed+to+retrieve+PKCE+challenge`,
+ );
+ }
+ next();
+ };
+}
+
+/* ──────────────────────────────────────────────
+ * OpenID Admin Routes
+ * ────────────────────────────────────────────── */
+
+router.get('/oauth/openid', async (req, res, next) => {
+ const state = generateState();
+ const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
+ const stored = await storeAndStripChallenge(cache, req, state, 'openid');
+ if (!stored) {
+ return res.redirect(
+ `${getAdminPanelUrl()}/auth/openid/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`,
+ );
+ }
+
+ return passport.authenticate('openidAdmin', {
+ session: false,
+ state,
+ })(req, res, next);
+});
+
+router.get(
+ '/oauth/openid/callback',
+ (req, res, next) => {
+ req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined;
+ next();
+ },
+ passport.authenticate('openidAdmin', {
+ failureRedirect: `${getAdminPanelUrl()}/auth/openid/callback?error=auth_failed&error_description=Authentication+failed`,
+ failureMessage: true,
+ session: false,
+ }),
+ tenantContextMiddleware,
+ retrievePkceChallenge('openid'),
+ requireAdminAccess,
+ setBalanceConfig,
+ middleware.checkDomainAllowed,
+ createOAuthHandler(`${getAdminPanelUrl()}/auth/openid/callback`),
+);
+
+/* ──────────────────────────────────────────────
+ * SAML Admin Routes
+ * ────────────────────────────────────────────── */
+
+router.get('/oauth/saml', async (req, res, next) => {
+ const state = generateState();
+ const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
+ const stored = await storeAndStripChallenge(cache, req, state, 'saml');
+ if (!stored) {
+ return res.redirect(
+ `${getAdminPanelUrl()}/auth/saml/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`,
+ );
+ }
+
+ return passport.authenticate('samlAdmin', {
+ session: false,
+ additionalParams: { RelayState: state },
+ })(req, res, next);
+});
+
+router.post(
+ '/oauth/saml/callback',
+ (req, res, next) => {
+ req.oauthState = typeof req.body.RelayState === 'string' ? req.body.RelayState : undefined;
+ next();
+ },
+ passport.authenticate('samlAdmin', {
+ failureRedirect: `${getAdminPanelUrl()}/auth/saml/callback?error=auth_failed&error_description=Authentication+failed`,
+ failureMessage: true,
+ session: false,
+ }),
+ tenantContextMiddleware,
+ retrievePkceChallenge('saml'),
+ requireAdminAccess,
+ setBalanceConfig,
+ middleware.checkDomainAllowed,
+ createOAuthHandler(`${getAdminPanelUrl()}/auth/saml/callback`),
+);
+
+/* ──────────────────────────────────────────────
+ * Google Admin Routes
+ * ────────────────────────────────────────────── */
+
+router.get('/oauth/google', async (req, res, next) => {
+ const state = generateState();
+ const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
+ const stored = await storeAndStripChallenge(cache, req, state, 'google');
+ if (!stored) {
+ return res.redirect(
+ `${getAdminPanelUrl()}/auth/google/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`,
+ );
+ }
+
+ return passport.authenticate('googleAdmin', {
+ scope: ['openid', 'profile', 'email'],
+ session: false,
+ state,
+ })(req, res, next);
+});
+
+router.get(
+ '/oauth/google/callback',
+ (req, res, next) => {
+ req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined;
+ next();
+ },
+ passport.authenticate('googleAdmin', {
+ failureRedirect: `${getAdminPanelUrl()}/auth/google/callback?error=auth_failed&error_description=Authentication+failed`,
+ failureMessage: true,
+ session: false,
+ }),
+ tenantContextMiddleware,
+ retrievePkceChallenge('google'),
+ requireAdminAccess,
+ setBalanceConfig,
+ middleware.checkDomainAllowed,
+ createOAuthHandler(`${getAdminPanelUrl()}/auth/google/callback`),
+);
+
+/* ──────────────────────────────────────────────
+ * GitHub Admin Routes
+ * ────────────────────────────────────────────── */
+
+router.get('/oauth/github', async (req, res, next) => {
+ const state = generateState();
+ const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
+ const stored = await storeAndStripChallenge(cache, req, state, 'github');
+ if (!stored) {
+ return res.redirect(
+ `${getAdminPanelUrl()}/auth/github/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`,
+ );
+ }
+
+ return passport.authenticate('githubAdmin', {
+ scope: ['user:email', 'read:user'],
+ session: false,
+ state,
+ })(req, res, next);
+});
+
+router.get(
+ '/oauth/github/callback',
+ (req, res, next) => {
+ req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined;
+ next();
+ },
+ passport.authenticate('githubAdmin', {
+ failureRedirect: `${getAdminPanelUrl()}/auth/github/callback?error=auth_failed&error_description=Authentication+failed`,
+ failureMessage: true,
+ session: false,
+ }),
+ tenantContextMiddleware,
+ retrievePkceChallenge('github'),
+ requireAdminAccess,
+ setBalanceConfig,
+ middleware.checkDomainAllowed,
+ createOAuthHandler(`${getAdminPanelUrl()}/auth/github/callback`),
+);
+
+/* ──────────────────────────────────────────────
+ * Discord Admin Routes
+ * ────────────────────────────────────────────── */
+
+router.get('/oauth/discord', async (req, res, next) => {
+ const state = generateState();
+ const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
+ const stored = await storeAndStripChallenge(cache, req, state, 'discord');
+ if (!stored) {
+ return res.redirect(
+ `${getAdminPanelUrl()}/auth/discord/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`,
+ );
+ }
+
+ return passport.authenticate('discordAdmin', {
+ scope: ['identify', 'email'],
+ session: false,
+ state,
+ })(req, res, next);
+});
+
+router.get(
+ '/oauth/discord/callback',
+ (req, res, next) => {
+ req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined;
+ next();
+ },
+ passport.authenticate('discordAdmin', {
+ failureRedirect: `${getAdminPanelUrl()}/auth/discord/callback?error=auth_failed&error_description=Authentication+failed`,
+ failureMessage: true,
+ session: false,
+ }),
+ tenantContextMiddleware,
+ retrievePkceChallenge('discord'),
+ requireAdminAccess,
+ setBalanceConfig,
+ middleware.checkDomainAllowed,
+ createOAuthHandler(`${getAdminPanelUrl()}/auth/discord/callback`),
+);
+
+/* ──────────────────────────────────────────────
+ * Facebook Admin Routes
+ * ────────────────────────────────────────────── */
+
+router.get('/oauth/facebook', async (req, res, next) => {
+ const state = generateState();
+ const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
+ const stored = await storeAndStripChallenge(cache, req, state, 'facebook');
+ if (!stored) {
+ return res.redirect(
+ `${getAdminPanelUrl()}/auth/facebook/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`,
+ );
+ }
+
+ return passport.authenticate('facebookAdmin', {
+ scope: ['public_profile'],
+ session: false,
+ state,
+ })(req, res, next);
+});
+
+router.get(
+ '/oauth/facebook/callback',
+ (req, res, next) => {
+ req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined;
+ next();
+ },
+ passport.authenticate('facebookAdmin', {
+ failureRedirect: `${getAdminPanelUrl()}/auth/facebook/callback?error=auth_failed&error_description=Authentication+failed`,
+ failureMessage: true,
+ session: false,
+ }),
+ tenantContextMiddleware,
+ retrievePkceChallenge('facebook'),
+ requireAdminAccess,
+ setBalanceConfig,
+ middleware.checkDomainAllowed,
+ createOAuthHandler(`${getAdminPanelUrl()}/auth/facebook/callback`),
+);
+
+/* ──────────────────────────────────────────────
+ * Apple Admin Routes (POST callback)
+ * ────────────────────────────────────────────── */
+
+router.get('/oauth/apple', async (req, res, next) => {
+ const state = generateState();
+ const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
+ const stored = await storeAndStripChallenge(cache, req, state, 'apple');
+ if (!stored) {
+ return res.redirect(
+ `${getAdminPanelUrl()}/auth/apple/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`,
+ );
+ }
+
+ return passport.authenticate('appleAdmin', {
+ session: false,
+ state,
+ })(req, res, next);
+});
+
+router.post(
+ '/oauth/apple/callback',
+ (req, res, next) => {
+ req.oauthState = typeof req.body.state === 'string' ? req.body.state : undefined;
+ next();
+ },
+ passport.authenticate('appleAdmin', {
+ failureRedirect: `${getAdminPanelUrl()}/auth/apple/callback?error=auth_failed&error_description=Authentication+failed`,
+ failureMessage: true,
+ session: false,
+ }),
+ tenantContextMiddleware,
+ retrievePkceChallenge('apple'),
+ requireAdminAccess,
+ setBalanceConfig,
+ middleware.checkDomainAllowed,
+ createOAuthHandler(`${getAdminPanelUrl()}/auth/apple/callback`),
+);
+
+/** Regex pattern for valid exchange codes: 64 hex characters */
+const EXCHANGE_CODE_PATTERN = /^[a-f0-9]{64}$/;
+
+/**
+ * Exchange OAuth authorization code for tokens.
+ * This endpoint is called server-to-server by the admin panel.
+ * The code is one-time-use and expires in 30 seconds.
+ *
+ * POST /api/admin/oauth/exchange
+ * Body: { code: string, code_verifier?: string }
+ * Response: { token: string, refreshToken: string, user: object }
+ */
+router.post('/oauth/exchange', middleware.loginLimiter, async (req, res) => {
+ try {
+ const { code, code_verifier: codeVerifier } = req.body;
+
+ if (!code) {
+ logger.warn('[admin/oauth/exchange] Missing authorization code');
+ return res.status(400).json({
+ error: 'Missing authorization code',
+ error_code: 'MISSING_CODE',
+ });
+ }
+
+ if (typeof code !== 'string' || !EXCHANGE_CODE_PATTERN.test(code)) {
+ logger.warn('[admin/oauth/exchange] Invalid authorization code format');
+ return res.status(400).json({
+ error: 'Invalid authorization code format',
+ error_code: 'INVALID_CODE_FORMAT',
+ });
+ }
+
+ if (
+ codeVerifier !== undefined &&
+ (typeof codeVerifier !== 'string' || codeVerifier.length < 1 || codeVerifier.length > 512)
+ ) {
+ logger.warn('[admin/oauth/exchange] Invalid code_verifier format');
+ return res.status(400).json({
+ error: 'Invalid code_verifier',
+ error_code: 'INVALID_VERIFIER',
+ });
+ }
+
+ const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
+ const requestOrigin = resolveRequestOrigin(req);
+ const result = await exchangeAdminCode(cache, code, requestOrigin, codeVerifier);
+
+ if (!result) {
+ return res.status(401).json({
+ error: 'Invalid or expired authorization code',
+ error_code: 'INVALID_OR_EXPIRED_CODE',
+ });
+ }
+
+ res.json(result);
+ } catch (error) {
+ logger.error('[admin/oauth/exchange] Error:', error);
+ res.status(500).json({
+ error: 'Internal server error',
+ error_code: 'INTERNAL_ERROR',
+ });
+ }
+});
+
+/**
+ * Admin-panel-shaped token refresh.
+ *
+ * The standard `/api/auth/refresh` controller reads the refresh token from
+ * cookies, which a cross-origin admin panel can't set. This endpoint accepts
+ * the refresh token in the request body, exchanges it at the IdP, mints a
+ * fresh LibreChat JWT, and returns the same response shape as
+ * `/api/admin/oauth/exchange`.
+ *
+ * POST /api/admin/oauth/refresh
+ * Body: { refresh_token: string, user_id?: string }
+ * Response: { token: string, refreshToken?: string, user: object, expiresAt: number }
+ *
+ * Errors (all responses are `{ error: string, error_code: string }`):
+ * 400 MISSING_REFRESH_TOKEN — refresh_token absent or empty
+ * 401 REFRESH_FAILED — IdP rejected the refresh grant
+ * 401 USER_NOT_FOUND — no LibreChat user matches the refreshed sub
+ * 401 USER_ID_MISMATCH — supplied user_id resolves to a user with a different openidId
+ * 401 ISSUER_MISMATCH — refreshed tokenset was issued by an unexpected issuer
+ * 401 TENANT_MISMATCH — resolved user belongs to a different tenant than the request
+ * 403 FORBIDDEN — resolved user no longer holds ACCESS_ADMIN
+ * 403 TOKEN_REUSE_DISABLED — OPENID_REUSE_TOKENS is not enabled on the server
+ * 502 IDP_INCOMPLETE — IdP returned a tokenset missing access_token
+ * 502 CLAIMS_INCOMPLETE — IdP tokenset has no readable claims or no sub
+ * 503 OPENID_NOT_CONFIGURED — OpenID is not configured on this server
+ * 500 INTERNAL_ERROR — anything else (logged server-side)
+ */
+router.post(
+ '/oauth/refresh',
+ middleware.loginLimiter,
+ preAuthTenantMiddleware,
+ async (req, res) => {
+ try {
+ const { refresh_token: refreshToken, user_id: userId } = req.body ?? {};
+ if (typeof refreshToken !== 'string' || refreshToken.length === 0) {
+ return res.status(400).json({
+ error: 'Missing refresh_token',
+ error_code: 'MISSING_REFRESH_TOKEN',
+ });
+ }
+
+ if (!isEnabled(process.env.OPENID_REUSE_TOKENS)) {
+ return res.status(403).json({
+ error: 'OpenID token reuse is not enabled',
+ error_code: 'TOKEN_REUSE_DISABLED',
+ });
+ }
+
+ let openIdConfig;
+ try {
+ openIdConfig = getOpenIdConfig();
+ } catch {
+ return res.status(503).json({
+ error: 'OpenID is not configured',
+ error_code: 'OPENID_NOT_CONFIGURED',
+ });
+ }
+
+ const refreshParams = buildOpenIDRefreshParams();
+ logger.debug('[admin/oauth/refresh] OpenID refresh params', {
+ has_scope: Boolean(process.env.OPENID_SCOPE),
+ has_refresh_audience: Boolean(process.env.OPENID_REFRESH_AUDIENCE),
+ });
+ let tokenset;
+ try {
+ tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken, refreshParams);
+ logger.debug('[admin/oauth/refresh] OpenID refresh succeeded', {
+ has_access_token: Boolean(tokenset.access_token),
+ has_id_token: Boolean(tokenset.id_token),
+ has_refresh_token: Boolean(tokenset.refresh_token),
+ expires_in: tokenset.expires_in,
+ });
+ } catch (err) {
+ logger.warn('[admin/oauth/refresh] IdP refresh grant failed', {
+ code: err?.code,
+ name: err?.name,
+ });
+ return res.status(401).json({
+ error: 'Refresh failed',
+ error_code: 'REFRESH_FAILED',
+ });
+ }
+
+ const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY;
+ const expectedIssuer = openIdConfig.serverMetadata?.()?.issuer;
+
+ try {
+ const result = await applyAdminRefresh(
+ tokenset,
+ {
+ findUsers,
+ getUserById,
+ canAccessAdmin: async (user) => {
+ try {
+ return await hasCapability(
+ {
+ id: user.id ?? user._id?.toString(),
+ role: user.role ?? '',
+ tenantId: user.tenantId,
+ },
+ SystemCapabilities.ACCESS_ADMIN,
+ );
+ } catch (err) {
+ logger.warn(
+ `[admin/oauth/refresh] capability check failed, denying: ${err?.message}`,
+ );
+ return false;
+ }
+ },
+ mintToken: async (user) => ({
+ token: await generateToken(user, sessionExpiry),
+ expiresAt: Date.now() + sessionExpiry,
+ }),
+ },
+ {
+ userId: typeof userId === 'string' && userId.length > 0 ? userId : undefined,
+ previousRefreshToken: refreshToken,
+ expectedIssuer,
+ tenantId: getTenantId(),
+ },
+ );
+ return res.json(result);
+ } catch (err) {
+ if (err instanceof AdminRefreshError) {
+ return res.status(err.status).json({ error: err.message, error_code: err.code });
+ }
+ throw err;
+ }
+ } catch (error) {
+ logger.error('[admin/oauth/refresh] Error:', error);
+ res.status(500).json({
+ error: 'Internal server error',
+ error_code: 'INTERNAL_ERROR',
+ });
+ }
+ },
+);
+
+module.exports = router;
diff --git a/api/server/routes/admin/auth.refresh.test.js b/api/server/routes/admin/auth.refresh.test.js
new file mode 100644
index 0000000..d4fb59c
--- /dev/null
+++ b/api/server/routes/admin/auth.refresh.test.js
@@ -0,0 +1,250 @@
+const express = require('express');
+const request = require('supertest');
+
+jest.mock('passport', () => ({
+ authenticate: jest.fn(() => (req, res, next) => next()),
+}));
+
+jest.mock('openid-client', () => ({
+ refreshTokenGrant: jest.fn(),
+}));
+
+jest.mock('librechat-data-provider', () => ({
+ CacheKeys: { ADMIN_OAUTH_EXCHANGE: 'admin-oauth-exchange' },
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: {
+ debug: jest.fn(),
+ error: jest.fn(),
+ warn: jest.fn(),
+ },
+ DEFAULT_SESSION_EXPIRY: 60000,
+ SystemCapabilities: { ACCESS_ADMIN: 'ACCESS_ADMIN' },
+ getTenantId: jest.fn(() => undefined),
+}));
+
+jest.mock('@librechat/api', () => {
+ class AdminRefreshError extends Error {
+ constructor(code, status, message) {
+ super(message);
+ this.name = 'AdminRefreshError';
+ this.code = code;
+ this.status = status;
+ }
+ }
+
+ return {
+ isEnabled: jest.fn(),
+ getAdminPanelUrl: jest.fn(() => 'http://admin.example.com'),
+ exchangeAdminCode: jest.fn(),
+ createSetBalanceConfig: jest.fn(() => (req, res, next) => next()),
+ storeAndStripChallenge: jest.fn(),
+ tenantContextMiddleware: jest.fn((req, res, next) => next()),
+ preAuthTenantMiddleware: jest.fn((req, res, next) => next()),
+ applyAdminRefresh: jest.fn(),
+ AdminRefreshError,
+ buildOpenIDRefreshParams: jest.fn(() => {
+ const params = {};
+ if (process.env.OPENID_SCOPE) {
+ params.scope = process.env.OPENID_SCOPE;
+ }
+ if (process.env.OPENID_REFRESH_AUDIENCE) {
+ params.audience = process.env.OPENID_REFRESH_AUDIENCE;
+ }
+ return params;
+ }),
+ };
+});
+
+jest.mock('~/server/controllers/auth/LoginController', () => ({
+ loginController: jest.fn((req, res) => res.status(200).end()),
+}));
+
+jest.mock('~/server/middleware/roles/capabilities', () => ({
+ hasCapability: jest.fn(() => Promise.resolve(true)),
+ requireCapability: jest.fn(() => (req, res, next) => next()),
+}));
+
+jest.mock('~/server/controllers/auth/oauth', () => ({
+ createOAuthHandler: jest.fn(() => (req, res) => res.status(200).end()),
+}));
+
+jest.mock('~/models', () => ({
+ findBalanceByUser: jest.fn(),
+ findUsers: jest.fn(),
+ generateToken: jest.fn(() => Promise.resolve('minted-token')),
+ getUserById: jest.fn(),
+ upsertBalanceFields: jest.fn(),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn(),
+}));
+
+jest.mock('~/cache/getLogStores', () =>
+ jest.fn(() => ({
+ get: jest.fn(),
+ delete: jest.fn(),
+ })),
+);
+
+jest.mock('~/strategies', () => ({
+ getOpenIdConfig: jest.fn(),
+}));
+
+jest.mock('~/server/middleware', () => ({
+ logHeaders: jest.fn((req, res, next) => next()),
+ loginLimiter: jest.fn((req, res, next) => next()),
+ checkBan: jest.fn((req, res, next) => next()),
+ requireLocalAuth: jest.fn((req, res, next) => next()),
+ requireJwtAuth: jest.fn((req, res, next) => next()),
+ checkDomainAllowed: jest.fn((req, res, next) => next()),
+}));
+
+const openIdClient = require('openid-client');
+const { logger } = require('@librechat/data-schemas');
+const { isEnabled, applyAdminRefresh, buildOpenIDRefreshParams } = require('@librechat/api');
+const { getOpenIdConfig } = require('~/strategies');
+const adminAuthRouter = require('./auth');
+
+const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE;
+const ORIGINAL_OPENID_REFRESH_AUDIENCE = process.env.OPENID_REFRESH_AUDIENCE;
+const ORIGINAL_SESSION_EXPIRY = process.env.SESSION_EXPIRY;
+
+describe('admin auth OpenID refresh route', () => {
+ const openIdConfig = {
+ serverMetadata: jest.fn(() => ({ issuer: 'https://issuer.example.com' })),
+ };
+ const tokenset = {
+ access_token: 'new-admin-access',
+ id_token: 'new-admin-id',
+ refresh_token: 'new-admin-refresh',
+ expires_in: 3600,
+ claims: jest.fn(() => ({ sub: 'admin-openid-id' })),
+ };
+
+ let app;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ delete process.env.OPENID_SCOPE;
+ delete process.env.OPENID_REFRESH_AUDIENCE;
+ delete process.env.SESSION_EXPIRY;
+
+ app = express();
+ app.use(express.json());
+ app.use('/api/admin', adminAuthRouter);
+
+ isEnabled.mockReturnValue(true);
+ getOpenIdConfig.mockReturnValue(openIdConfig);
+ openIdClient.refreshTokenGrant.mockResolvedValue(tokenset);
+ applyAdminRefresh.mockResolvedValue({
+ token: 'admin-jwt',
+ refreshToken: 'new-admin-refresh',
+ user: { id: 'user-id', email: 'admin@example.com' },
+ expiresAt: 1234567890,
+ });
+ });
+
+ afterAll(() => {
+ if (ORIGINAL_OPENID_SCOPE === undefined) {
+ delete process.env.OPENID_SCOPE;
+ } else {
+ process.env.OPENID_SCOPE = ORIGINAL_OPENID_SCOPE;
+ }
+
+ if (ORIGINAL_OPENID_REFRESH_AUDIENCE === undefined) {
+ delete process.env.OPENID_REFRESH_AUDIENCE;
+ } else {
+ process.env.OPENID_REFRESH_AUDIENCE = ORIGINAL_OPENID_REFRESH_AUDIENCE;
+ }
+
+ if (ORIGINAL_SESSION_EXPIRY === undefined) {
+ delete process.env.SESSION_EXPIRY;
+ } else {
+ process.env.SESSION_EXPIRY = ORIGINAL_SESSION_EXPIRY;
+ }
+ });
+
+ it.each([
+ ['scope-only', { OPENID_SCOPE: 'openid profile email' }, { scope: 'openid profile email' }],
+ [
+ 'scope and audience',
+ {
+ OPENID_SCOPE: 'openid profile email',
+ OPENID_REFRESH_AUDIENCE: 'https://api.example.com',
+ },
+ { scope: 'openid profile email', audience: 'https://api.example.com' },
+ ],
+ [
+ 'audience-only',
+ { OPENID_REFRESH_AUDIENCE: 'https://api.example.com' },
+ { audience: 'https://api.example.com' },
+ ],
+ ['empty audience', { OPENID_REFRESH_AUDIENCE: '' }, {}],
+ ])('passes %s params to the OpenID refresh grant', async (_label, env, expectedParams) => {
+ Object.assign(process.env, env);
+
+ const response = await request(app)
+ .post('/api/admin/oauth/refresh')
+ .send({ refresh_token: 'incoming-refresh-token' });
+
+ expect(response.status).toBe(200);
+ expect(buildOpenIDRefreshParams).toHaveBeenCalledTimes(1);
+ expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
+ openIdConfig,
+ 'incoming-refresh-token',
+ expectedParams,
+ );
+ expect(applyAdminRefresh).toHaveBeenCalledWith(
+ tokenset,
+ expect.any(Object),
+ expect.objectContaining({ previousRefreshToken: 'incoming-refresh-token' }),
+ );
+ });
+
+ it('returns the existing refresh failure response when the IdP rejects the grant', async () => {
+ openIdClient.refreshTokenGrant.mockRejectedValue({
+ code: 'invalid_grant',
+ name: 'OAuthError',
+ });
+
+ const response = await request(app)
+ .post('/api/admin/oauth/refresh')
+ .send({ refresh_token: 'incoming-refresh-token' });
+
+ expect(response.status).toBe(401);
+ expect(response.body).toEqual({
+ error: 'Refresh failed',
+ error_code: 'REFRESH_FAILED',
+ });
+ expect(applyAdminRefresh).not.toHaveBeenCalled();
+ });
+
+ it('keeps admin refresh diagnostics free of token and audience values', async () => {
+ process.env.OPENID_SCOPE = 'openid profile email';
+ process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
+
+ await request(app)
+ .post('/api/admin/oauth/refresh')
+ .send({ refresh_token: 'incoming-refresh-token' });
+
+ expect(logger.debug).toHaveBeenCalledWith('[admin/oauth/refresh] OpenID refresh params', {
+ has_scope: true,
+ has_refresh_audience: true,
+ });
+ expect(logger.debug).toHaveBeenCalledWith('[admin/oauth/refresh] OpenID refresh succeeded', {
+ has_access_token: true,
+ has_id_token: true,
+ has_refresh_token: true,
+ expires_in: 3600,
+ });
+ const debugOutput = JSON.stringify(logger.debug.mock.calls);
+ expect(debugOutput).not.toContain('incoming-refresh-token');
+ expect(debugOutput).not.toContain('new-admin-access');
+ expect(debugOutput).not.toContain('new-admin-id');
+ expect(debugOutput).not.toContain('new-admin-refresh');
+ expect(debugOutput).not.toContain('https://api.example.com');
+ });
+});
diff --git a/api/server/routes/admin/config.js b/api/server/routes/admin/config.js
new file mode 100644
index 0000000..ab7aa01
--- /dev/null
+++ b/api/server/routes/admin/config.js
@@ -0,0 +1,44 @@
+const express = require('express');
+const { createAdminConfigHandlers } = require('@librechat/api');
+const { SystemCapabilities } = require('@librechat/data-schemas');
+const {
+ hasCapability,
+ hasConfigCapability,
+ requireCapability,
+} = require('~/server/middleware/roles/capabilities');
+const { getAppConfig, invalidateConfigCaches } = require('~/server/services/Config');
+const { requireJwtAuth } = require('~/server/middleware');
+const db = require('~/models');
+
+const router = express.Router();
+
+const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
+
+const handlers = createAdminConfigHandlers({
+ listAllConfigs: db.listAllConfigs,
+ findConfigByPrincipal: db.findConfigByPrincipal,
+ upsertConfig: db.upsertConfig,
+ patchConfigFields: db.patchConfigFields,
+ tombstoneConfigField: db.tombstoneConfigField,
+ unsetConfigField: db.unsetConfigField,
+ deleteConfig: db.deleteConfig,
+ toggleConfigActive: db.toggleConfigActive,
+ hasConfigCapability,
+ hasCapability,
+ getAppConfig,
+ invalidateConfigCaches,
+});
+
+router.use(requireJwtAuth, requireAdminAccess);
+
+router.get('/', handlers.listConfigs);
+router.get('/base', handlers.getBaseConfig);
+router.get('/:principalType/:principalId', handlers.getConfig);
+router.put('/:principalType/:principalId', handlers.upsertConfigOverrides);
+router.patch('/:principalType/:principalId/fields', handlers.patchConfigField);
+router.post('/:principalType/:principalId/fields/tombstone', handlers.tombstoneConfigField);
+router.delete('/:principalType/:principalId/fields', handlers.deleteConfigField);
+router.delete('/:principalType/:principalId', handlers.deleteConfigOverrides);
+router.patch('/:principalType/:principalId/active', handlers.toggleConfig);
+
+module.exports = router;
diff --git a/api/server/routes/admin/grants.js b/api/server/routes/admin/grants.js
new file mode 100644
index 0000000..f48c3dd
--- /dev/null
+++ b/api/server/routes/admin/grants.js
@@ -0,0 +1,38 @@
+const express = require('express');
+const { createAdminGrantsHandlers, getCachedPrincipals } = require('@librechat/api');
+const { SystemCapabilities } = require('@librechat/data-schemas');
+const { requireCapability } = require('~/server/middleware/roles/capabilities');
+const { requireJwtAuth } = require('~/server/middleware');
+const db = require('~/models');
+
+const router = express.Router();
+
+const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
+
+const handlers = createAdminGrantsHandlers({
+ listGrants: db.listGrants,
+ countGrants: db.countGrants,
+ getCapabilitiesForPrincipal: db.getCapabilitiesForPrincipal,
+ getCapabilitiesForPrincipals: db.getCapabilitiesForPrincipals,
+ grantCapability: db.grantCapability,
+ revokeCapability: db.revokeCapability,
+ getUserPrincipals: db.getUserPrincipals,
+ hasCapabilityForPrincipals: db.hasCapabilityForPrincipals,
+ getHeldCapabilities: db.getHeldCapabilities,
+ getCachedPrincipals,
+ checkRoleExists: async (name) => (await db.getRoleByName(name)) != null,
+ recordAuditEntry: db.recordAuditEntry,
+ /** Opt-in: fail the grant request if its audit entry can't be persisted. */
+ auditFailClosed: process.env.AUDIT_LOG_FAIL_CLOSED === 'true',
+});
+
+router.use(requireJwtAuth, requireAdminAccess);
+
+router.get('/', handlers.listGrants);
+router.get('/effective', handlers.getEffectiveCapabilities);
+router.get('/:principalType/:principalId', handlers.getPrincipalGrants);
+router.post('/', handlers.assignGrant);
+/** Callers should encodeURIComponent the capability for client compatibility (e.g. manage%3Aconfigs%3Aendpoints). */
+router.delete('/:principalType/:principalId/:capability', handlers.revokeGrant);
+
+module.exports = router;
diff --git a/api/server/routes/admin/groups.js b/api/server/routes/admin/groups.js
new file mode 100644
index 0000000..11ed597
--- /dev/null
+++ b/api/server/routes/admin/groups.js
@@ -0,0 +1,40 @@
+const express = require('express');
+const { createAdminGroupsHandlers } = require('@librechat/api');
+const { SystemCapabilities } = require('@librechat/data-schemas');
+const { requireCapability } = require('~/server/middleware/roles/capabilities');
+const { requireJwtAuth } = require('~/server/middleware');
+const db = require('~/models');
+
+const router = express.Router();
+
+const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
+const requireReadGroups = requireCapability(SystemCapabilities.READ_GROUPS);
+const requireManageGroups = requireCapability(SystemCapabilities.MANAGE_GROUPS);
+
+const handlers = createAdminGroupsHandlers({
+ listGroups: db.listGroups,
+ countGroups: db.countGroups,
+ findGroupById: db.findGroupById,
+ createGroup: db.createGroup,
+ updateGroupById: db.updateGroupById,
+ deleteGroup: db.deleteGroup,
+ addUserToGroup: db.addUserToGroup,
+ removeUserFromGroup: db.removeUserFromGroup,
+ removeMemberById: db.removeMemberById,
+ findUsers: db.findUsers,
+ deleteConfig: db.deleteConfig,
+ deleteAclEntries: db.deleteAclEntries,
+});
+
+router.use(requireJwtAuth, requireAdminAccess);
+
+router.get('/', requireReadGroups, handlers.listGroups);
+router.post('/', requireManageGroups, handlers.createGroup);
+router.get('/:id', requireReadGroups, handlers.getGroup);
+router.patch('/:id', requireManageGroups, handlers.updateGroup);
+router.delete('/:id', requireManageGroups, handlers.deleteGroup);
+router.get('/:id/members', requireReadGroups, handlers.getGroupMembers);
+router.post('/:id/members', requireManageGroups, handlers.addGroupMember);
+router.delete('/:id/members/:userId', requireManageGroups, handlers.removeGroupMember);
+
+module.exports = router;
diff --git a/api/server/routes/admin/roles.js b/api/server/routes/admin/roles.js
new file mode 100644
index 0000000..5c6d4e9
--- /dev/null
+++ b/api/server/routes/admin/roles.js
@@ -0,0 +1,47 @@
+const express = require('express');
+const { createAdminRolesHandlers } = require('@librechat/api');
+const { SystemCapabilities } = require('@librechat/data-schemas');
+const { requireCapability } = require('~/server/middleware/roles/capabilities');
+const { requireJwtAuth } = require('~/server/middleware');
+const db = require('~/models');
+
+const router = express.Router();
+
+const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
+const requireReadRoles = requireCapability(SystemCapabilities.READ_ROLES);
+const requireManageRoles = requireCapability(SystemCapabilities.MANAGE_ROLES);
+
+const handlers = createAdminRolesHandlers({
+ listRoles: db.listRoles,
+ countRoles: db.countRoles,
+ getRoleByName: db.getRoleByName,
+ createRoleByName: db.createRoleByName,
+ updateRoleByName: db.updateRoleByName,
+ updateAccessPermissions: db.updateAccessPermissions,
+ deleteRoleByName: db.deleteRoleByName,
+ findUser: db.findUser,
+ updateUser: db.updateUser,
+ updateUsersByRole: db.updateUsersByRole,
+ findUserIdsByRole: db.findUserIdsByRole,
+ updateUsersRoleByIds: db.updateUsersRoleByIds,
+ listUsersByRole: db.listUsersByRole,
+ countUsersByRole: db.countUsersByRole,
+ deleteConfig: db.deleteConfig,
+ deleteAclEntries: db.deleteAclEntries,
+ deleteGrantsForPrincipal: db.deleteGrantsForPrincipal,
+ recordAuditEntry: db.recordAuditEntry,
+});
+
+router.use(requireJwtAuth, requireAdminAccess);
+
+router.get('/', requireReadRoles, handlers.listRoles);
+router.post('/', requireManageRoles, handlers.createRole);
+router.get('/:name', requireReadRoles, handlers.getRole);
+router.patch('/:name', requireManageRoles, handlers.updateRole);
+router.delete('/:name', requireManageRoles, handlers.deleteRole);
+router.patch('/:name/permissions', requireManageRoles, handlers.updateRolePermissions);
+router.get('/:name/members', requireReadRoles, handlers.getRoleMembers);
+router.post('/:name/members', requireManageRoles, handlers.addRoleMember);
+router.delete('/:name/members/:userId', requireManageRoles, handlers.removeRoleMember);
+
+module.exports = router;
diff --git a/api/server/routes/admin/skills.js b/api/server/routes/admin/skills.js
new file mode 100644
index 0000000..54e5400
--- /dev/null
+++ b/api/server/routes/admin/skills.js
@@ -0,0 +1,50 @@
+const express = require('express');
+const { createAdminSkillsSyncAccess, createAdminSkillsSyncHandlers } = require('@librechat/api');
+const { SystemCapabilities } = require('@librechat/data-schemas');
+const { hasCapability, requireCapability } = require('~/server/middleware/roles/capabilities');
+const { requireJwtAuth } = require('~/server/middleware');
+const { upsertSkillSyncCredential, deleteSkillSyncCredential } = require('~/models');
+const { getGitHubSkillSyncRunnerForRequest } = require('~/server/services/Skills/sync');
+const { getAppConfig } = require('~/server/services/Config');
+const configMiddleware = require('~/server/middleware/config/app');
+
+const router = express.Router();
+const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
+
+const syncAccess = createAdminSkillsSyncAccess({
+ getAppConfig,
+ hasCapability,
+});
+
+const handlers = createAdminSkillsSyncHandlers({
+ getRunner: getGitHubSkillSyncRunnerForRequest,
+ upsertCredential: upsertSkillSyncCredential,
+ deleteCredential: deleteSkillSyncCredential,
+});
+
+router.use(
+ requireJwtAuth,
+ requireAdminAccess,
+ configMiddleware,
+ syncAccess.attachBaseSkillSyncConfig,
+);
+
+router.get(
+ '/sync/status',
+ syncAccess.requireReadSkills,
+ syncAccess.attachCredentialReadAccess,
+ handlers.getSyncStatus,
+);
+router.post('/sync/run', syncAccess.requireSyncRunCapability, handlers.runSync);
+router.put(
+ '/sync/credentials/:credentialKey',
+ syncAccess.requirePlatformManageSkills,
+ handlers.setCredential,
+);
+router.delete(
+ '/sync/credentials/:credentialKey',
+ syncAccess.requirePlatformManageSkills,
+ handlers.deleteCredential,
+);
+
+module.exports = router;
diff --git a/api/server/routes/admin/skills.test.js b/api/server/routes/admin/skills.test.js
new file mode 100644
index 0000000..f452d6e
--- /dev/null
+++ b/api/server/routes/admin/skills.test.js
@@ -0,0 +1,119 @@
+const express = require('express');
+const request = require('supertest');
+
+const mockRequireJwtAuth = jest.fn((req, res, next) => {
+ req.user = { id: 'user-1', role: 'ADMIN', tenantId: 'tenant-a' };
+ next();
+});
+const mockCapabilityMiddleware = jest.fn((req, res, next) => next());
+const mockRequireCapability = jest.fn(() => mockCapabilityMiddleware);
+const mockHasCapability = jest.fn().mockResolvedValue(true);
+const mockConfigMiddleware = jest.fn((req, res, next) => {
+ req.config = { skillSync: { github: { enabled: false, sources: [] } } };
+ next();
+});
+const mockGetAppConfig = jest.fn();
+const mockGetGitHubSkillSyncRunnerForRequest = jest.fn();
+const mockHandlers = {
+ getSyncStatus: jest.fn((req, res) => res.status(200).json({ ok: true })),
+ runSync: jest.fn((req, res) => res.status(200).json({ ok: true })),
+ setCredential: jest.fn((req, res) => res.status(200).json({ ok: true })),
+ deleteCredential: jest.fn((req, res) => res.status(200).json({ ok: true })),
+};
+const mockSyncAccess = {
+ attachBaseSkillSyncConfig: jest.fn((req, res, next) => next()),
+ requireReadSkills: jest.fn((req, res, next) => next()),
+ attachCredentialReadAccess: jest.fn((req, res, next) => next()),
+ requireSyncRunCapability: jest.fn((req, res, next) => next()),
+ requirePlatformManageSkills: jest.fn((req, res, next) => next()),
+};
+
+jest.mock('@librechat/data-schemas', () => ({
+ SystemCapabilities: {
+ ACCESS_ADMIN: 'access:admin',
+ },
+}));
+
+jest.mock('@librechat/api', () => ({
+ createAdminSkillsSyncAccess: jest.fn(() => mockSyncAccess),
+ createAdminSkillsSyncHandlers: jest.fn(() => mockHandlers),
+}));
+
+jest.mock('~/server/middleware/roles/capabilities', () => ({
+ hasCapability: mockHasCapability,
+ requireCapability: mockRequireCapability,
+}));
+
+jest.mock('~/server/middleware', () => ({
+ requireJwtAuth: mockRequireJwtAuth,
+}));
+
+jest.mock('~/server/middleware/config/app', () => mockConfigMiddleware);
+
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: mockGetAppConfig,
+}));
+
+jest.mock('~/models', () => ({
+ upsertSkillSyncCredential: jest.fn(),
+ deleteSkillSyncCredential: jest.fn(),
+}));
+
+jest.mock('~/server/services/Skills/sync', () => ({
+ getGitHubSkillSyncRunnerForRequest: mockGetGitHubSkillSyncRunnerForRequest,
+}));
+
+describe('admin skills sync routes', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ function createApp() {
+ delete require.cache[require.resolve('./skills')];
+ const router = require('./skills');
+ const app = express();
+ app.use(express.json());
+ app.use('/api/admin/skills', router);
+ return app;
+ }
+
+ it('delegates skill sync access policy to the API package', async () => {
+ const app = createApp();
+
+ await request(app).get('/api/admin/skills/sync/status').expect(200);
+
+ const {
+ createAdminSkillsSyncAccess,
+ createAdminSkillsSyncHandlers,
+ } = require('@librechat/api');
+ expect(mockRequireCapability).toHaveBeenCalledWith('access:admin');
+ expect(createAdminSkillsSyncAccess).toHaveBeenCalledWith({
+ getAppConfig: mockGetAppConfig,
+ hasCapability: mockHasCapability,
+ });
+ expect(createAdminSkillsSyncHandlers).toHaveBeenCalledWith(
+ expect.objectContaining({ getRunner: mockGetGitHubSkillSyncRunnerForRequest }),
+ );
+ expect(mockRequireJwtAuth).toHaveBeenCalled();
+ expect(mockCapabilityMiddleware).toHaveBeenCalled();
+ expect(mockConfigMiddleware).toHaveBeenCalled();
+ expect(mockSyncAccess.attachBaseSkillSyncConfig).toHaveBeenCalled();
+ expect(mockSyncAccess.requireReadSkills).toHaveBeenCalled();
+ expect(mockSyncAccess.attachCredentialReadAccess).toHaveBeenCalled();
+ expect(mockHandlers.getSyncStatus).toHaveBeenCalled();
+ });
+
+ it('mounts package access middlewares before each sync endpoint handler', async () => {
+ const app = createApp();
+
+ await request(app).post('/api/admin/skills/sync/run').expect(200);
+ await request(app).put('/api/admin/skills/sync/credentials/default').send({}).expect(200);
+ await request(app).delete('/api/admin/skills/sync/credentials/default').expect(200);
+
+ expect(mockSyncAccess.requireSyncRunCapability).toHaveBeenCalled();
+ expect(mockHandlers.runSync).toHaveBeenCalled();
+ expect(mockSyncAccess.requirePlatformManageSkills).toHaveBeenCalledTimes(2);
+ expect(mockHandlers.setCredential).toHaveBeenCalled();
+ expect(mockHandlers.deleteCredential).toHaveBeenCalled();
+ });
+});
diff --git a/api/server/routes/admin/users.js b/api/server/routes/admin/users.js
new file mode 100644
index 0000000..20d4eb1
--- /dev/null
+++ b/api/server/routes/admin/users.js
@@ -0,0 +1,28 @@
+const express = require('express');
+const { createAdminUsersHandlers } = require('@librechat/api');
+const { SystemCapabilities } = require('@librechat/data-schemas');
+const { requireCapability } = require('~/server/middleware/roles/capabilities');
+const { requireJwtAuth } = require('~/server/middleware');
+const db = require('~/models');
+
+const router = express.Router();
+
+const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
+const requireReadUsers = requireCapability(SystemCapabilities.READ_USERS);
+// const requireManageUsers = requireCapability(SystemCapabilities.MANAGE_USERS);
+
+const handlers = createAdminUsersHandlers({
+ findUsers: db.findUsers,
+ countUsers: db.countUsers,
+ deleteUserById: db.deleteUserById,
+ deleteConfig: db.deleteConfig,
+ deleteAclEntries: db.deleteAclEntries,
+});
+
+router.use(requireJwtAuth, requireAdminAccess);
+
+router.get('/', requireReadUsers, handlers.listUsers);
+router.get('/search', requireReadUsers, handlers.searchUsers);
+// router.delete('/:id', requireManageUsers, handlers.deleteUser);
+
+module.exports = router;
diff --git a/api/server/routes/agents/__tests__/abort.spec.js b/api/server/routes/agents/__tests__/abort.spec.js
new file mode 100644
index 0000000..c9cc6b5
--- /dev/null
+++ b/api/server/routes/agents/__tests__/abort.spec.js
@@ -0,0 +1,485 @@
+/**
+ * Tests for the agent abort endpoint
+ *
+ * Tests the following fixes from PR #11462:
+ * 1. Authorization check - only job owner can abort
+ * 2. Early abort handling - skip save when no responseMessageId
+ * 3. Partial response saving - save message before returning
+ */
+
+const express = require('express');
+const request = require('supertest');
+
+const mockLogger = {
+ debug: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ info: jest.fn(),
+};
+
+const mockGenerationJobManager = {
+ getJob: jest.fn(),
+ abortJob: jest.fn(),
+ getActiveJobIdsForUser: jest.fn(),
+};
+
+const mockSaveMessage = jest.fn();
+
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ logger: mockLogger,
+}));
+
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ isEnabled: jest.fn().mockReturnValue(false),
+ GenerationJobManager: mockGenerationJobManager,
+}));
+
+jest.mock('~/models', () => ({
+ saveMessage: (...args) => mockSaveMessage(...args),
+}));
+
+jest.mock('~/server/middleware', () => ({
+ uaParser: (req, res, next) => next(),
+ checkBan: (req, res, next) => next(),
+ requireJwtAuth: (req, res, next) => {
+ req.user = { id: 'test-user-123' };
+ next();
+ },
+ messageIpLimiter: (req, res, next) => next(),
+ configMiddleware: (req, res, next) => next(),
+ messageUserLimiter: (req, res, next) => next(),
+}));
+
+// Mock the chat module - needs to be a router
+jest.mock('~/server/routes/agents/chat', () => require('express').Router());
+
+// Mock the v1 module - v1 is directly used as middleware
+jest.mock('~/server/routes/agents/v1', () => ({
+ v1: require('express').Router(),
+}));
+
+// Import after mocks
+const agentRoutes = require('~/server/routes/agents/index');
+
+describe('Agent Abort Endpoint', () => {
+ let app;
+
+ beforeAll(() => {
+ app = express();
+ app.use(express.json());
+ app.use('/api/agents', agentRoutes);
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGenerationJobManager.getJob.mockReset();
+ mockGenerationJobManager.abortJob.mockReset();
+ mockGenerationJobManager.getActiveJobIdsForUser.mockReset();
+ mockSaveMessage.mockReset();
+ });
+
+ describe('POST /chat/abort', () => {
+ describe('Authorization', () => {
+ it("should return 403 when user tries to abort another user's job", async () => {
+ const jobStreamId = 'test-stream-123';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'other-user-456' },
+ });
+
+ const response = await request(app)
+ .post('/api/agents/chat/abort')
+ .send({ conversationId: jobStreamId });
+
+ expect(response.status).toBe(403);
+ expect(response.body).toEqual({ error: 'Unauthorized' });
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('Unauthorized abort attempt'),
+ );
+ expect(mockGenerationJobManager.abortJob).not.toHaveBeenCalled();
+ });
+
+ it('should allow abort when user owns the job', async () => {
+ const jobStreamId = 'test-stream-123';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'test-user-123' },
+ });
+
+ mockGenerationJobManager.abortJob.mockResolvedValue({
+ success: true,
+ jobData: null,
+ content: [],
+ text: '',
+ });
+
+ const response = await request(app)
+ .post('/api/agents/chat/abort')
+ .send({ conversationId: jobStreamId });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ success: true, aborted: jobStreamId });
+ expect(mockGenerationJobManager.abortJob).toHaveBeenCalledWith(
+ jobStreamId,
+ expect.objectContaining({ transformAbortContent: expect.any(Function) }),
+ );
+ });
+
+ it('should allow abort when job has no userId metadata (backwards compatibility)', async () => {
+ const jobStreamId = 'test-stream-123';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: {},
+ });
+
+ mockGenerationJobManager.abortJob.mockResolvedValue({
+ success: true,
+ jobData: null,
+ content: [],
+ text: '',
+ });
+
+ const response = await request(app)
+ .post('/api/agents/chat/abort')
+ .send({ conversationId: jobStreamId });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ success: true, aborted: jobStreamId });
+ });
+ });
+
+ describe('Early Abort Handling', () => {
+ it('should skip message saving when responseMessageId is missing (early abort)', async () => {
+ const jobStreamId = 'test-stream-123';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'test-user-123' },
+ });
+
+ mockGenerationJobManager.abortJob.mockResolvedValue({
+ success: true,
+ jobData: {
+ userMessage: { messageId: 'user-msg-123' },
+ // No responseMessageId - early abort before generation started
+ conversationId: jobStreamId,
+ },
+ content: [],
+ text: '',
+ });
+
+ const response = await request(app)
+ .post('/api/agents/chat/abort')
+ .send({ conversationId: jobStreamId });
+
+ expect(response.status).toBe(200);
+ expect(mockSaveMessage).not.toHaveBeenCalled();
+ });
+
+ it('should skip message saving when userMessage is missing', async () => {
+ const jobStreamId = 'test-stream-123';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'test-user-123' },
+ });
+
+ mockGenerationJobManager.abortJob.mockResolvedValue({
+ success: true,
+ jobData: {
+ // No userMessage
+ responseMessageId: 'response-msg-123',
+ conversationId: jobStreamId,
+ },
+ content: [],
+ text: '',
+ });
+
+ const response = await request(app)
+ .post('/api/agents/chat/abort')
+ .send({ conversationId: jobStreamId });
+
+ expect(response.status).toBe(200);
+ expect(mockSaveMessage).not.toHaveBeenCalled();
+ });
+
+ it('should skip message saving when abort content is only an OAuth prompt', async () => {
+ const jobStreamId = 'test-stream-123';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'test-user-123' },
+ });
+
+ mockGenerationJobManager.abortJob.mockResolvedValue({
+ success: true,
+ jobData: {
+ userMessage: { messageId: 'user-msg-123' },
+ responseMessageId: 'response-msg-456',
+ conversationId: jobStreamId,
+ },
+ content: [
+ {
+ type: 'tool_call',
+ tool_call: {
+ type: 'tool_call',
+ id: 'oauth-call-1',
+ name: 'oauth_mcp_Google-Workspace',
+ args: '',
+ auth: 'https://auth.example.com/oauth',
+ },
+ },
+ ],
+ text: '',
+ });
+
+ const response = await request(app)
+ .post('/api/agents/chat/abort')
+ .send({ conversationId: jobStreamId });
+
+ expect(response.status).toBe(200);
+ expect(mockSaveMessage).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Partial Response Saving', () => {
+ it('should save partial response when both userMessage and responseMessageId exist', async () => {
+ const jobStreamId = 'test-stream-123';
+ const userMessageId = 'user-msg-123';
+ const responseMessageId = 'response-msg-456';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'test-user-123' },
+ });
+
+ mockGenerationJobManager.abortJob.mockResolvedValue({
+ success: true,
+ jobData: {
+ userMessage: { messageId: userMessageId },
+ responseMessageId,
+ conversationId: jobStreamId,
+ sender: 'TestAgent',
+ endpoint: 'anthropic',
+ iconURL: 'https://example.com/spec-icon.png',
+ model: 'claude-3',
+ },
+ content: [{ type: 'text', text: 'Partial response...' }],
+ text: 'Partial response...',
+ });
+
+ mockSaveMessage.mockResolvedValue();
+
+ const response = await request(app)
+ .post('/api/agents/chat/abort')
+ .send({ conversationId: jobStreamId });
+
+ expect(response.status).toBe(200);
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ messageId: responseMessageId,
+ parentMessageId: userMessageId,
+ conversationId: jobStreamId,
+ content: [{ type: 'text', text: 'Partial response...' }],
+ text: 'Partial response...',
+ sender: 'TestAgent',
+ endpoint: 'anthropic',
+ iconURL: 'https://example.com/spec-icon.png',
+ model: 'claude-3',
+ unfinished: true,
+ error: false,
+ isCreatedByUser: false,
+ user: 'test-user-123',
+ }),
+ expect.objectContaining({
+ context: 'api/server/routes/agents/index.js - abort endpoint',
+ }),
+ );
+ });
+
+ it('saves the aborted partial as temporary from job metadata, not the request body', async () => {
+ const jobStreamId = 'test-stream-123';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'test-user-123' },
+ });
+
+ // The job was a temporary chat; the stop button posts only conversationId.
+ mockGenerationJobManager.abortJob.mockResolvedValue({
+ success: true,
+ jobData: {
+ userMessage: { messageId: 'user-msg-123' },
+ responseMessageId: 'response-msg-456',
+ conversationId: jobStreamId,
+ isTemporary: true,
+ },
+ content: [{ type: 'text', text: 'Partial...' }],
+ text: 'Partial...',
+ });
+
+ mockSaveMessage.mockResolvedValue();
+
+ const response = await request(app)
+ .post('/api/agents/chat/abort')
+ .send({ conversationId: jobStreamId }); // no isTemporary in body
+
+ expect(response.status).toBe(200);
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ isTemporary: true }),
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('stamps a paused ask_user_question via transformAbortContent, before the final SSE emits', async () => {
+ const jobStreamId = 'test-stream-123';
+ const question = { question: 'Deploy where?', options: [{ label: 'Prod', value: 'prod' }] };
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: {
+ userId: 'test-user-123',
+ pendingAction: { payload: { type: 'ask_user_question', question } },
+ },
+ });
+
+ // abortJob applies the transform; capture it and echo the transformed
+ // content back as the result, mirroring the real (Redis) reconstruction
+ // where the ask tool_call arrives with empty args.
+ let capturedTransform;
+ mockGenerationJobManager.abortJob.mockImplementation(async (_streamId, options) => {
+ capturedTransform = options?.transformAbortContent;
+ const rawContent = [
+ { type: 'tool_call', tool_call: { id: 'tc1', name: 'ask_user_question', args: '' } },
+ ];
+ const content = capturedTransform ? capturedTransform(rawContent) : rawContent;
+ return {
+ success: true,
+ jobData: {
+ userMessage: { messageId: 'user-msg-123' },
+ responseMessageId: 'response-msg-456',
+ conversationId: jobStreamId,
+ },
+ content,
+ text: '',
+ };
+ });
+
+ mockSaveMessage.mockResolvedValue();
+
+ const response = await request(app)
+ .post('/api/agents/chat/abort')
+ .send({ conversationId: jobStreamId });
+
+ expect(response.status).toBe(200);
+ expect(capturedTransform).toEqual(expect.any(Function));
+ // The saved (and, in prod, emitted) content carries the stamped args.
+ // saveMessage(reqLike, responseMessage, opts) — the message is arg #2.
+ const savedMessage = mockSaveMessage.mock.calls[0][1];
+ const askPart = savedMessage.content.find(
+ (p) => p?.tool_call?.name === 'ask_user_question',
+ );
+ expect(JSON.parse(askPart.tool_call.args)).toMatchObject({ question: 'Deploy where?' });
+ });
+
+ it('should handle saveMessage errors gracefully', async () => {
+ const jobStreamId = 'test-stream-123';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'test-user-123' },
+ });
+
+ mockGenerationJobManager.abortJob.mockResolvedValue({
+ success: true,
+ jobData: {
+ userMessage: { messageId: 'user-msg-123' },
+ responseMessageId: 'response-msg-456',
+ conversationId: jobStreamId,
+ },
+ content: [{ type: 'text', text: 'Partial response...' }],
+ text: 'Partial response...',
+ });
+
+ mockSaveMessage.mockRejectedValue(new Error('Database error'));
+
+ const response = await request(app)
+ .post('/api/agents/chat/abort')
+ .send({ conversationId: jobStreamId });
+
+ // Should still return success even if save fails
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ success: true, aborted: jobStreamId });
+ expect(mockLogger.error).toHaveBeenCalledWith(
+ expect.stringContaining('Failed to save partial response'),
+ );
+ });
+ });
+
+ describe('Job Not Found', () => {
+ it('should skip paused fallback jobs and abort the running job', async () => {
+ mockGenerationJobManager.getJob
+ .mockResolvedValueOnce({
+ status: 'requires_action',
+ metadata: { userId: 'test-user-123' },
+ })
+ .mockResolvedValueOnce({
+ status: 'running',
+ metadata: { userId: 'test-user-123' },
+ });
+ mockGenerationJobManager.getActiveJobIdsForUser.mockResolvedValue([
+ 'paused-stream',
+ 'running-stream',
+ ]);
+ mockGenerationJobManager.abortJob.mockResolvedValue({
+ success: true,
+ jobData: null,
+ content: [],
+ text: '',
+ });
+
+ const response = await request(app)
+ .post('/api/agents/chat/abort')
+ .send({ conversationId: 'new' });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ success: true, aborted: 'running-stream' });
+ expect(mockGenerationJobManager.abortJob).toHaveBeenCalledWith(
+ 'running-stream',
+ expect.objectContaining({ transformAbortContent: expect.any(Function) }),
+ );
+ });
+
+ it('should not abort paused fallback jobs', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValueOnce({
+ status: 'requires_action',
+ metadata: { userId: 'test-user-123' },
+ });
+ mockGenerationJobManager.getActiveJobIdsForUser.mockResolvedValue(['paused-stream']);
+
+ const response = await request(app)
+ .post('/api/agents/chat/abort')
+ .send({ conversationId: 'new' });
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({
+ error: 'Job not found',
+ streamId: null,
+ });
+ expect(mockGenerationJobManager.abortJob).not.toHaveBeenCalled();
+ });
+
+ it('should return 404 when job is not found', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(null);
+ mockGenerationJobManager.getActiveJobIdsForUser.mockResolvedValue([]);
+
+ const response = await request(app)
+ .post('/api/agents/chat/abort')
+ .send({ conversationId: 'non-existent-job' });
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({
+ error: 'Job not found',
+ streamId: 'non-existent-job',
+ });
+ });
+ });
+ });
+});
diff --git a/api/server/routes/agents/__tests__/responses.spec.js b/api/server/routes/agents/__tests__/responses.spec.js
new file mode 100644
index 0000000..4d83219
--- /dev/null
+++ b/api/server/routes/agents/__tests__/responses.spec.js
@@ -0,0 +1,1125 @@
+/**
+ * Open Responses API Integration Tests
+ *
+ * Tests the /v1/responses endpoint against the Open Responses specification
+ * compliance tests. Uses real Anthropic API for LLM calls.
+ *
+ * @see https://openresponses.org/specification
+ * @see https://github.com/openresponses/openresponses/blob/main/src/lib/compliance-tests.ts
+ */
+
+// Load environment variables from root .env file for API keys
+require('dotenv').config({ path: require('path').resolve(__dirname, '../../../../../.env') });
+
+const originalEnv = {
+ CREDS_KEY: process.env.CREDS_KEY,
+ CREDS_IV: process.env.CREDS_IV,
+};
+
+process.env.CREDS_KEY = '0123456789abcdef0123456789abcdef';
+process.env.CREDS_IV = '0123456789abcdef';
+
+/** Skip tests if ANTHROPIC_API_KEY is not available */
+const SKIP_INTEGRATION_TESTS = !process.env.ANTHROPIC_API_KEY;
+if (SKIP_INTEGRATION_TESTS) {
+ console.warn('ANTHROPIC_API_KEY not found - skipping integration tests');
+}
+
+jest.mock('meilisearch', () => ({
+ MeiliSearch: jest.fn().mockImplementation(() => ({
+ getIndex: jest.fn().mockRejectedValue(new Error('mocked')),
+ index: jest.fn().mockReturnValue({
+ getRawInfo: jest.fn().mockResolvedValue({ primaryKey: 'id' }),
+ updateSettings: jest.fn().mockResolvedValue({}),
+ addDocuments: jest.fn().mockResolvedValue({}),
+ updateDocuments: jest.fn().mockResolvedValue({}),
+ deleteDocument: jest.fn().mockResolvedValue({}),
+ }),
+ })),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ loadCustomConfig: jest.fn(() => Promise.resolve({})),
+ getAppConfig: jest.fn().mockResolvedValue({
+ paths: {
+ uploads: '/tmp',
+ dist: '/tmp/dist',
+ fonts: '/tmp/fonts',
+ assets: '/tmp/assets',
+ },
+ fileStrategy: 'local',
+ imageOutputType: 'PNG',
+ endpoints: {
+ agents: {
+ allowedProviders: ['anthropic', 'openAI'],
+ },
+ },
+ }),
+ setCachedTools: jest.fn(),
+ getCachedTools: jest.fn(),
+ getMCPServerTools: jest.fn().mockReturnValue([]),
+}));
+
+jest.mock('~/app/clients/tools', () => ({
+ createOpenAIImageTools: jest.fn(() => []),
+ createYouTubeTools: jest.fn(() => []),
+ manifestToolMap: {},
+ toolkits: [],
+}));
+
+jest.mock('~/config', () => ({
+ createMCPServersRegistry: jest.fn(),
+ createMCPManager: jest.fn().mockResolvedValue({
+ getAppToolFunctions: jest.fn().mockResolvedValue({}),
+ }),
+}));
+
+const express = require('express');
+const request = require('supertest');
+const mongoose = require('mongoose');
+const { v4: uuidv4 } = require('uuid');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { hashToken, getRandomValues, createModels } = require('@librechat/data-schemas');
+const {
+ SystemRoles,
+ ResourceType,
+ AccessRoleIds,
+ PrincipalType,
+ PrincipalModel,
+ PermissionBits,
+ EModelEndpoint,
+} = require('librechat-data-provider');
+
+/** @type {import('mongoose').Model} */
+let Agent;
+/** @type {import('mongoose').Model} */
+let AgentApiKey;
+/** @type {import('mongoose').Model} */
+let User;
+/** @type {import('mongoose').Model} */
+let AclEntry;
+/** @type {import('mongoose').Model} */
+let AccessRole;
+
+/**
+ * Parse SSE stream into events
+ * @param {string} text - Raw SSE text
+ * @returns {Array<{event: string, data: unknown}>}
+ */
+function parseSSEEvents(text) {
+ const events = [];
+ const lines = text.split('\n');
+
+ let currentEvent = '';
+ let currentData = '';
+
+ for (const line of lines) {
+ if (line.startsWith('event:')) {
+ currentEvent = line.slice(6).trim();
+ } else if (line.startsWith('data:')) {
+ currentData = line.slice(5).trim();
+ } else if (line === '' && currentData) {
+ if (currentData === '[DONE]') {
+ events.push({ event: 'done', data: '[DONE]' });
+ } else {
+ try {
+ const parsed = JSON.parse(currentData);
+ events.push({
+ event: currentEvent || parsed.type || 'unknown',
+ data: parsed,
+ });
+ } catch {
+ // Skip unparseable data
+ }
+ }
+ currentEvent = '';
+ currentData = '';
+ }
+ }
+
+ return events;
+}
+
+/**
+ * Valid streaming event types per Open Responses specification
+ * @see https://github.com/openresponses/openresponses/blob/main/src/lib/sse-parser.ts
+ */
+const VALID_STREAMING_EVENT_TYPES = new Set([
+ // Standard Open Responses events
+ 'response.created',
+ 'response.queued',
+ 'response.in_progress',
+ 'response.completed',
+ 'response.failed',
+ 'response.incomplete',
+ 'response.output_item.added',
+ 'response.output_item.done',
+ 'response.content_part.added',
+ 'response.content_part.done',
+ 'response.output_text.delta',
+ 'response.output_text.done',
+ 'response.refusal.delta',
+ 'response.refusal.done',
+ 'response.function_call_arguments.delta',
+ 'response.function_call_arguments.done',
+ 'response.reasoning_summary_part.added',
+ 'response.reasoning_summary_part.done',
+ 'response.reasoning.delta',
+ 'response.reasoning.done',
+ 'response.reasoning_summary_text.delta',
+ 'response.reasoning_summary_text.done',
+ 'response.output_text.annotation.added',
+ 'error',
+ // LibreChat extension events (prefixed per Open Responses spec)
+ // @see https://openresponses.org/specification#extending-streaming-events
+ 'librechat:attachment',
+]);
+
+/**
+ * Validate a streaming event against Open Responses spec
+ * @param {Object} event - Parsed event with data
+ * @returns {string[]} Array of validation errors
+ */
+function validateStreamingEvent(event) {
+ const errors = [];
+ const data = event.data;
+
+ if (!data || typeof data !== 'object') {
+ return errors; // Skip non-object data (e.g., [DONE])
+ }
+
+ const eventType = data.type;
+
+ // Check event type is valid
+ if (!VALID_STREAMING_EVENT_TYPES.has(eventType)) {
+ errors.push(`Invalid event type: ${eventType}`);
+ return errors;
+ }
+
+ // Validate required fields based on event type
+ switch (eventType) {
+ case 'response.output_text.delta':
+ if (typeof data.sequence_number !== 'number') {
+ errors.push('response.output_text.delta: missing sequence_number');
+ }
+ if (typeof data.item_id !== 'string') {
+ errors.push('response.output_text.delta: missing item_id');
+ }
+ if (typeof data.output_index !== 'number') {
+ errors.push('response.output_text.delta: missing output_index');
+ }
+ if (typeof data.content_index !== 'number') {
+ errors.push('response.output_text.delta: missing content_index');
+ }
+ if (typeof data.delta !== 'string') {
+ errors.push('response.output_text.delta: missing delta');
+ }
+ if (!Array.isArray(data.logprobs)) {
+ errors.push('response.output_text.delta: missing logprobs array');
+ }
+ break;
+
+ case 'response.output_text.done':
+ if (typeof data.sequence_number !== 'number') {
+ errors.push('response.output_text.done: missing sequence_number');
+ }
+ if (typeof data.item_id !== 'string') {
+ errors.push('response.output_text.done: missing item_id');
+ }
+ if (typeof data.output_index !== 'number') {
+ errors.push('response.output_text.done: missing output_index');
+ }
+ if (typeof data.content_index !== 'number') {
+ errors.push('response.output_text.done: missing content_index');
+ }
+ if (typeof data.text !== 'string') {
+ errors.push('response.output_text.done: missing text');
+ }
+ if (!Array.isArray(data.logprobs)) {
+ errors.push('response.output_text.done: missing logprobs array');
+ }
+ break;
+
+ case 'response.reasoning.delta':
+ if (typeof data.sequence_number !== 'number') {
+ errors.push('response.reasoning.delta: missing sequence_number');
+ }
+ if (typeof data.item_id !== 'string') {
+ errors.push('response.reasoning.delta: missing item_id');
+ }
+ if (typeof data.output_index !== 'number') {
+ errors.push('response.reasoning.delta: missing output_index');
+ }
+ if (typeof data.content_index !== 'number') {
+ errors.push('response.reasoning.delta: missing content_index');
+ }
+ if (typeof data.delta !== 'string') {
+ errors.push('response.reasoning.delta: missing delta');
+ }
+ break;
+
+ case 'response.reasoning.done':
+ if (typeof data.sequence_number !== 'number') {
+ errors.push('response.reasoning.done: missing sequence_number');
+ }
+ if (typeof data.item_id !== 'string') {
+ errors.push('response.reasoning.done: missing item_id');
+ }
+ if (typeof data.output_index !== 'number') {
+ errors.push('response.reasoning.done: missing output_index');
+ }
+ if (typeof data.content_index !== 'number') {
+ errors.push('response.reasoning.done: missing content_index');
+ }
+ if (typeof data.text !== 'string') {
+ errors.push('response.reasoning.done: missing text');
+ }
+ break;
+
+ case 'response.in_progress':
+ case 'response.completed':
+ case 'response.failed':
+ if (!data.response || typeof data.response !== 'object') {
+ errors.push(`${eventType}: missing response object`);
+ }
+ break;
+
+ case 'response.output_item.added':
+ case 'response.output_item.done':
+ if (typeof data.output_index !== 'number') {
+ errors.push(`${eventType}: missing output_index`);
+ }
+ if (!data.item || typeof data.item !== 'object') {
+ errors.push(`${eventType}: missing item object`);
+ }
+ break;
+ }
+
+ return errors;
+}
+
+/**
+ * Validate all streaming events and return errors
+ * @param {Array} events - Array of parsed events
+ * @returns {string[]} Array of all validation errors
+ */
+function validateAllStreamingEvents(events) {
+ const allErrors = [];
+ for (const event of events) {
+ const errors = validateStreamingEvent(event);
+ allErrors.push(...errors);
+ }
+ return allErrors;
+}
+
+/**
+ * Create a test agent with Anthropic provider
+ * @param {Object} overrides
+ * @returns {Promise}
+ */
+async function createTestAgent(overrides = {}) {
+ const timestamp = new Date();
+ const agentData = {
+ id: `agent_${uuidv4().replace(/-/g, '').substring(0, 21)}`,
+ name: 'Test Anthropic Agent',
+ description: 'An agent for testing Open Responses API',
+ instructions: 'You are a helpful assistant. Be concise.',
+ provider: EModelEndpoint.anthropic,
+ model: 'claude-sonnet-4-5-20250929',
+ author: new mongoose.Types.ObjectId(),
+ tools: [],
+ model_parameters: {},
+ ...overrides,
+ };
+
+ const versionData = { ...agentData };
+ delete versionData.author;
+
+ const initialAgentData = {
+ ...agentData,
+ versions: [
+ {
+ ...versionData,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ },
+ ],
+ category: 'general',
+ };
+
+ return (await Agent.create(initialAgentData)).toObject();
+}
+
+/**
+ * Create an agent with extended thinking enabled
+ * @param {Object} overrides
+ * @returns {Promise}
+ */
+async function createThinkingAgent(overrides = {}) {
+ return createTestAgent({
+ name: 'Test Thinking Agent',
+ description: 'An agent with extended thinking enabled',
+ model_parameters: {
+ thinking: {
+ type: 'enabled',
+ budget_tokens: 5000,
+ },
+ },
+ ...overrides,
+ });
+}
+
+const describeWithApiKey = SKIP_INTEGRATION_TESTS ? describe.skip : describe;
+
+describeWithApiKey('Open Responses API Integration Tests', () => {
+ // Increase timeout for real API calls
+ jest.setTimeout(120000);
+
+ let mongoServer;
+ let app;
+ let testAgent;
+ let thinkingAgent;
+ let testUser;
+ let testApiKey; // The raw API key for Authorization header
+
+ afterAll(() => {
+ process.env.CREDS_KEY = originalEnv.CREDS_KEY;
+ process.env.CREDS_IV = originalEnv.CREDS_IV;
+ });
+
+ beforeAll(async () => {
+ // Start MongoDB Memory Server
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+
+ // Connect to MongoDB
+ await mongoose.connect(mongoUri);
+
+ // Register all models
+ const models = createModels(mongoose);
+
+ // Get models
+ Agent = models.Agent;
+ AgentApiKey = models.AgentApiKey;
+ User = models.User;
+ AclEntry = models.AclEntry;
+ AccessRole = models.AccessRole;
+
+ // Create minimal Express app with just the responses routes
+ app = express();
+ app.use(express.json());
+
+ // Mount the responses routes
+ const responsesRoutes = require('~/server/routes/agents/responses');
+ app.use('/api/agents/v1/responses', responsesRoutes);
+
+ // Create test user
+ testUser = await User.create({
+ name: 'Test API User',
+ username: 'testapiuser',
+ email: 'testapiuser@test.com',
+ emailVerified: true,
+ provider: 'local',
+ role: SystemRoles.ADMIN,
+ });
+
+ // Create REMOTE_AGENT access roles (if they don't exist)
+ const existingRoles = await AccessRole.find({
+ accessRoleId: {
+ $in: [
+ AccessRoleIds.REMOTE_AGENT_VIEWER,
+ AccessRoleIds.REMOTE_AGENT_EDITOR,
+ AccessRoleIds.REMOTE_AGENT_OWNER,
+ ],
+ },
+ });
+
+ if (existingRoles.length === 0) {
+ await AccessRole.create([
+ {
+ accessRoleId: AccessRoleIds.REMOTE_AGENT_VIEWER,
+ name: 'API Viewer',
+ description: 'Can query the agent via API',
+ resourceType: ResourceType.REMOTE_AGENT,
+ permBits: PermissionBits.VIEW,
+ },
+ {
+ accessRoleId: AccessRoleIds.REMOTE_AGENT_EDITOR,
+ name: 'API Editor',
+ description: 'Can view and modify the agent via API',
+ resourceType: ResourceType.REMOTE_AGENT,
+ permBits: PermissionBits.VIEW | PermissionBits.EDIT,
+ },
+ {
+ accessRoleId: AccessRoleIds.REMOTE_AGENT_OWNER,
+ name: 'API Owner',
+ description: 'Full API access + can grant remote access to others',
+ resourceType: ResourceType.REMOTE_AGENT,
+ permBits:
+ PermissionBits.VIEW |
+ PermissionBits.EDIT |
+ PermissionBits.DELETE |
+ PermissionBits.SHARE,
+ },
+ ]);
+ }
+
+ // Generate and create an API key for the test user
+ const rawKey = `sk-${await getRandomValues(32)}`;
+ const keyHash = await hashToken(rawKey);
+ const keyPrefix = rawKey.substring(0, 8);
+
+ await AgentApiKey.create({
+ userId: testUser._id,
+ name: 'Test API Key',
+ keyHash,
+ keyPrefix,
+ });
+
+ testApiKey = rawKey;
+
+ // Create test agents with the test user as author
+ testAgent = await createTestAgent({ author: testUser._id });
+ thinkingAgent = await createThinkingAgent({ author: testUser._id });
+
+ // Grant REMOTE_AGENT permissions for the test agents
+ await AclEntry.create([
+ {
+ principalType: PrincipalType.USER,
+ principalModel: PrincipalModel.USER,
+ principalId: testUser._id,
+ resourceType: ResourceType.REMOTE_AGENT,
+ resourceId: testAgent._id,
+ accessRoleId: AccessRoleIds.REMOTE_AGENT_OWNER,
+ permBits:
+ PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
+ },
+ {
+ principalType: PrincipalType.USER,
+ principalModel: PrincipalModel.USER,
+ principalId: testUser._id,
+ resourceType: ResourceType.REMOTE_AGENT,
+ resourceId: thinkingAgent._id,
+ accessRoleId: AccessRoleIds.REMOTE_AGENT_OWNER,
+ permBits:
+ PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
+ },
+ ]);
+ }, 60000);
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ // Clean up any test data between tests if needed
+ });
+
+ /* ===========================================================================
+ * COMPLIANCE TESTS
+ * Based on: https://github.com/openresponses/openresponses/blob/main/src/lib/compliance-tests.ts
+ * =========================================================================== */
+
+ /** Helper to add auth header to requests */
+ const authRequest = () => ({
+ post: (url) => request(app).post(url).set('Authorization', `Bearer ${testApiKey}`),
+ get: (url) => request(app).get(url).set('Authorization', `Bearer ${testApiKey}`),
+ });
+
+ describe('Compliance Tests', () => {
+ describe('basic-response', () => {
+ it('should return a valid ResponseResource for a simple text request', async () => {
+ const response = await authRequest()
+ .post('/api/agents/v1/responses')
+ .send({
+ model: testAgent.id,
+ input: [
+ {
+ type: 'message',
+ role: 'user',
+ content: 'Say hello in exactly 3 words.',
+ },
+ ],
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toBeDefined();
+
+ // Validate ResponseResource schema
+ const body = response.body;
+ expect(body.id).toMatch(/^resp_/);
+ expect(body.object).toBe('response');
+ expect(typeof body.created_at).toBe('number');
+ expect(body.status).toBe('completed');
+ expect(body.model).toBe(testAgent.id);
+
+ // Validate output
+ expect(Array.isArray(body.output)).toBe(true);
+ expect(body.output.length).toBeGreaterThan(0);
+
+ // Should have at least one message item
+ const messageItem = body.output.find((item) => item.type === 'message');
+ expect(messageItem).toBeDefined();
+ expect(messageItem.role).toBe('assistant');
+ expect(messageItem.status).toBe('completed');
+ expect(Array.isArray(messageItem.content)).toBe(true);
+ });
+ });
+
+ describe('streaming-response', () => {
+ it('should return valid SSE streaming events', async () => {
+ const response = await authRequest()
+ .post('/api/agents/v1/responses')
+ .send({
+ model: testAgent.id,
+ input: [
+ {
+ type: 'message',
+ role: 'user',
+ content: 'Count from 1 to 5.',
+ },
+ ],
+ stream: true,
+ })
+ .buffer(true)
+ .parse((res, callback) => {
+ let data = '';
+ res.on('data', (chunk) => {
+ data += chunk.toString();
+ });
+ res.on('end', () => {
+ callback(null, data);
+ });
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.headers['content-type']).toMatch(/text\/event-stream/);
+
+ const events = parseSSEEvents(response.body);
+ expect(events.length).toBeGreaterThan(0);
+
+ // Validate all streaming events against Open Responses spec
+ // This catches issues like:
+ // - Invalid event types (e.g., response.reasoning_text.delta instead of response.reasoning.delta)
+ // - Missing required fields (e.g., logprobs on output_text events)
+ const validationErrors = validateAllStreamingEvents(events);
+ if (validationErrors.length > 0) {
+ console.error('Streaming event validation errors:', validationErrors);
+ }
+ expect(validationErrors).toEqual([]);
+
+ // Validate streaming event types
+ const eventTypes = events.map((e) => e.event);
+
+ // Should have response.created first (per Open Responses spec)
+ expect(eventTypes).toContain('response.created');
+
+ // Should have response.in_progress
+ expect(eventTypes).toContain('response.in_progress');
+
+ // response.created should come before response.in_progress
+ const createdIdx = eventTypes.indexOf('response.created');
+ const inProgressIdx = eventTypes.indexOf('response.in_progress');
+ expect(createdIdx).toBeLessThan(inProgressIdx);
+
+ // Should have response.completed or response.failed
+ expect(eventTypes.some((t) => t === 'response.completed' || t === 'response.failed')).toBe(
+ true,
+ );
+
+ // Should have [DONE]
+ expect(eventTypes).toContain('done');
+
+ // Validate response.completed has full response
+ const completedEvent = events.find((e) => e.event === 'response.completed');
+ if (completedEvent) {
+ expect(completedEvent.data.response).toBeDefined();
+ expect(completedEvent.data.response.status).toBe('completed');
+ expect(completedEvent.data.response.output.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('should emit valid event types per Open Responses spec', async () => {
+ const response = await authRequest()
+ .post('/api/agents/v1/responses')
+ .send({
+ model: testAgent.id,
+ input: [
+ {
+ type: 'message',
+ role: 'user',
+ content: 'Say hi.',
+ },
+ ],
+ stream: true,
+ })
+ .buffer(true)
+ .parse((res, callback) => {
+ let data = '';
+ res.on('data', (chunk) => {
+ data += chunk.toString();
+ });
+ res.on('end', () => {
+ callback(null, data);
+ });
+ });
+
+ expect(response.status).toBe(200);
+
+ const events = parseSSEEvents(response.body);
+
+ // Check all event types are valid
+ for (const event of events) {
+ if (event.data && typeof event.data === 'object' && event.data.type) {
+ expect(VALID_STREAMING_EVENT_TYPES.has(event.data.type)).toBe(true);
+ }
+ }
+ });
+
+ it('should include logprobs array in output_text events', async () => {
+ const response = await authRequest()
+ .post('/api/agents/v1/responses')
+ .send({
+ model: testAgent.id,
+ input: [
+ {
+ type: 'message',
+ role: 'user',
+ content: 'Say one word.',
+ },
+ ],
+ stream: true,
+ })
+ .buffer(true)
+ .parse((res, callback) => {
+ let data = '';
+ res.on('data', (chunk) => {
+ data += chunk.toString();
+ });
+ res.on('end', () => {
+ callback(null, data);
+ });
+ });
+
+ expect(response.status).toBe(200);
+
+ const events = parseSSEEvents(response.body);
+
+ // Find output_text delta/done events and verify logprobs
+ const textDeltaEvents = events.filter(
+ (e) => e.data && e.data.type === 'response.output_text.delta',
+ );
+ const textDoneEvents = events.filter(
+ (e) => e.data && e.data.type === 'response.output_text.done',
+ );
+
+ // Should have at least one output_text event
+ expect(textDeltaEvents.length + textDoneEvents.length).toBeGreaterThan(0);
+
+ // All output_text.delta events must have logprobs array
+ for (const event of textDeltaEvents) {
+ expect(Array.isArray(event.data.logprobs)).toBe(true);
+ }
+
+ // All output_text.done events must have logprobs array
+ for (const event of textDoneEvents) {
+ expect(Array.isArray(event.data.logprobs)).toBe(true);
+ }
+ });
+ });
+
+ describe('system-prompt', () => {
+ it('should handle developer role messages in input (as system)', async () => {
+ // Note: For Anthropic, system messages must be first and there can only be one.
+ // Since the agent already has instructions, we use 'developer' role which
+ // gets merged into the system prompt, or we test with a simple user message
+ // that instructs the behavior.
+ const response = await authRequest()
+ .post('/api/agents/v1/responses')
+ .send({
+ model: testAgent.id,
+ input: [
+ {
+ type: 'message',
+ role: 'user',
+ content: 'Pretend you are a pirate and say hello in pirate speak.',
+ },
+ ],
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.status).toBe('completed');
+ expect(response.body.output.length).toBeGreaterThan(0);
+
+ // The response should reflect the pirate persona
+ const messageItem = response.body.output.find((item) => item.type === 'message');
+ expect(messageItem).toBeDefined();
+ expect(messageItem.content.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('multi-turn', () => {
+ it('should handle multi-turn conversation history', async () => {
+ const response = await authRequest()
+ .post('/api/agents/v1/responses')
+ .send({
+ model: testAgent.id,
+ input: [
+ {
+ type: 'message',
+ role: 'user',
+ content: 'My name is Alice.',
+ },
+ {
+ type: 'message',
+ role: 'assistant',
+ content: 'Hello Alice! Nice to meet you. How can I help you today?',
+ },
+ {
+ type: 'message',
+ role: 'user',
+ content: 'What is my name?',
+ },
+ ],
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.status).toBe('completed');
+
+ // The response should reference "Alice"
+ const messageItem = response.body.output.find((item) => item.type === 'message');
+ expect(messageItem).toBeDefined();
+
+ const textContent = messageItem.content.find((c) => c.type === 'output_text');
+ expect(textContent).toBeDefined();
+ expect(textContent.text.toLowerCase()).toContain('alice');
+ });
+ });
+
+ // Note: tool-calling test requires tool setup which may need additional configuration
+ // Note: image-input test requires vision-capable model
+
+ describe('string-input', () => {
+ it('should accept simple string input', async () => {
+ const response = await authRequest().post('/api/agents/v1/responses').send({
+ model: testAgent.id,
+ input: 'Hello!',
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.status).toBe('completed');
+ expect(response.body.output.length).toBeGreaterThan(0);
+ });
+ });
+ });
+
+ /* ===========================================================================
+ * EXTENDED THINKING TESTS
+ * Tests reasoning output from Claude models with extended thinking enabled
+ * =========================================================================== */
+
+ describe('Extended Thinking', () => {
+ it('should return reasoning output when thinking is enabled', async () => {
+ const response = await authRequest()
+ .post('/api/agents/v1/responses')
+ .send({
+ model: thinkingAgent.id,
+ input: [
+ {
+ type: 'message',
+ role: 'user',
+ content: 'What is 15 * 7? Think step by step.',
+ },
+ ],
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.status).toBe('completed');
+
+ // Check for reasoning item in output
+ const reasoningItem = response.body.output.find((item) => item.type === 'reasoning');
+ // If reasoning is present, validate its structure per Open Responses spec
+ // Note: reasoning items do NOT have a 'status' field per the spec
+ // @see https://github.com/openresponses/openresponses/blob/main/src/generated/kubb/zod/reasoningBodySchema.ts
+ if (reasoningItem) {
+ expect(reasoningItem).toHaveProperty('id');
+ expect(reasoningItem).toHaveProperty('type', 'reasoning');
+ // Note: 'status' is NOT a field on reasoning items per the spec
+ expect(reasoningItem).toHaveProperty('summary');
+ expect(Array.isArray(reasoningItem.summary)).toBe(true);
+
+ // Validate content items
+ if (reasoningItem.content && reasoningItem.content.length > 0) {
+ const reasoningContent = reasoningItem.content[0];
+ expect(reasoningContent).toHaveProperty('type', 'reasoning_text');
+ expect(reasoningContent).toHaveProperty('text');
+ }
+ }
+
+ const messageItem = response.body.output.find((item) => item.type === 'message');
+ expect(messageItem).toBeDefined();
+ });
+
+ it('should stream reasoning events when thinking is enabled', async () => {
+ const response = await authRequest()
+ .post('/api/agents/v1/responses')
+ .send({
+ model: thinkingAgent.id,
+ input: [
+ {
+ type: 'message',
+ role: 'user',
+ content: 'What is 12 + 8? Think step by step.',
+ },
+ ],
+ stream: true,
+ })
+ .buffer(true)
+ .parse((res, callback) => {
+ let data = '';
+ res.on('data', (chunk) => {
+ data += chunk.toString();
+ });
+ res.on('end', () => {
+ callback(null, data);
+ });
+ });
+
+ expect(response.status).toBe(200);
+
+ const events = parseSSEEvents(response.body);
+
+ // Validate all events against Open Responses spec
+ const validationErrors = validateAllStreamingEvents(events);
+ if (validationErrors.length > 0) {
+ console.error('Reasoning streaming event validation errors:', validationErrors);
+ }
+ expect(validationErrors).toEqual([]);
+
+ // Check for reasoning-related events using correct event types per Open Responses spec
+ // Note: The spec uses response.reasoning.delta NOT response.reasoning_text.delta
+ const reasoningDeltaEvents = events.filter(
+ (e) => e.data && e.data.type === 'response.reasoning.delta',
+ );
+ const reasoningDoneEvents = events.filter(
+ (e) => e.data && e.data.type === 'response.reasoning.done',
+ );
+
+ // If reasoning events are present, validate their structure
+ if (reasoningDeltaEvents.length > 0) {
+ const deltaEvent = reasoningDeltaEvents[0];
+ expect(deltaEvent.data).toHaveProperty('item_id');
+ expect(deltaEvent.data).toHaveProperty('delta');
+ expect(deltaEvent.data).toHaveProperty('output_index');
+ expect(deltaEvent.data).toHaveProperty('content_index');
+ expect(deltaEvent.data).toHaveProperty('sequence_number');
+ }
+
+ if (reasoningDoneEvents.length > 0) {
+ const doneEvent = reasoningDoneEvents[0];
+ expect(doneEvent.data).toHaveProperty('item_id');
+ expect(doneEvent.data).toHaveProperty('text');
+ expect(doneEvent.data).toHaveProperty('output_index');
+ expect(doneEvent.data).toHaveProperty('content_index');
+ expect(doneEvent.data).toHaveProperty('sequence_number');
+ }
+
+ // Verify stream completed properly
+ const eventTypes = events.map((e) => e.event);
+ expect(eventTypes).toContain('response.completed');
+ });
+ });
+
+ /* ===========================================================================
+ * SCHEMA VALIDATION TESTS
+ * Verify response schema compliance
+ * =========================================================================== */
+
+ describe('Schema Validation', () => {
+ it('should include all required fields in response', async () => {
+ const response = await authRequest().post('/api/agents/v1/responses').send({
+ model: testAgent.id,
+ input: 'Test',
+ });
+
+ expect(response.status).toBe(200);
+ const body = response.body;
+
+ // Required fields per Open Responses spec
+ expect(body).toHaveProperty('id');
+ expect(body).toHaveProperty('object', 'response');
+ expect(body).toHaveProperty('created_at');
+ expect(body).toHaveProperty('completed_at');
+ expect(body).toHaveProperty('status');
+ expect(body).toHaveProperty('model');
+ expect(body).toHaveProperty('output');
+ expect(body).toHaveProperty('tools');
+ expect(body).toHaveProperty('tool_choice');
+ expect(body).toHaveProperty('truncation');
+ expect(body).toHaveProperty('parallel_tool_calls');
+ expect(body).toHaveProperty('text');
+ expect(body).toHaveProperty('temperature');
+ expect(body).toHaveProperty('top_p');
+ expect(body).toHaveProperty('presence_penalty');
+ expect(body).toHaveProperty('frequency_penalty');
+ expect(body).toHaveProperty('top_logprobs');
+ expect(body).toHaveProperty('store');
+ expect(body).toHaveProperty('background');
+ expect(body).toHaveProperty('service_tier');
+ expect(body).toHaveProperty('metadata');
+
+ // top_logprobs must be a number (not null)
+ expect(typeof body.top_logprobs).toBe('number');
+
+ // Usage must have required detail fields
+ expect(body).toHaveProperty('usage');
+ expect(body.usage).toHaveProperty('input_tokens');
+ expect(body.usage).toHaveProperty('output_tokens');
+ expect(body.usage).toHaveProperty('total_tokens');
+ expect(body.usage).toHaveProperty('input_tokens_details');
+ expect(body.usage).toHaveProperty('output_tokens_details');
+ expect(body.usage.input_tokens_details).toHaveProperty('cached_tokens');
+ expect(body.usage.output_tokens_details).toHaveProperty('reasoning_tokens');
+ });
+
+ it('should have valid message item structure', async () => {
+ const response = await authRequest().post('/api/agents/v1/responses').send({
+ model: testAgent.id,
+ input: 'Hello',
+ });
+
+ expect(response.status).toBe(200);
+
+ const messageItem = response.body.output.find((item) => item.type === 'message');
+ expect(messageItem).toBeDefined();
+
+ // Message item required fields
+ expect(messageItem).toHaveProperty('type', 'message');
+ expect(messageItem).toHaveProperty('id');
+ expect(messageItem).toHaveProperty('status');
+ expect(messageItem).toHaveProperty('role', 'assistant');
+ expect(messageItem).toHaveProperty('content');
+ expect(Array.isArray(messageItem.content)).toBe(true);
+
+ // Content part structure - verify all required fields
+ if (messageItem.content.length > 0) {
+ const textContent = messageItem.content.find((c) => c.type === 'output_text');
+ if (textContent) {
+ expect(textContent).toHaveProperty('type', 'output_text');
+ expect(textContent).toHaveProperty('text');
+ expect(textContent).toHaveProperty('annotations');
+ expect(textContent).toHaveProperty('logprobs');
+ expect(Array.isArray(textContent.annotations)).toBe(true);
+ expect(Array.isArray(textContent.logprobs)).toBe(true);
+ }
+ }
+
+ // Verify reasoning item has required summary field
+ const reasoningItem = response.body.output.find((item) => item.type === 'reasoning');
+ if (reasoningItem) {
+ expect(reasoningItem).toHaveProperty('type', 'reasoning');
+ expect(reasoningItem).toHaveProperty('id');
+ expect(reasoningItem).toHaveProperty('summary');
+ expect(Array.isArray(reasoningItem.summary)).toBe(true);
+ }
+ });
+ });
+
+ /* ===========================================================================
+ * RESPONSE STORAGE TESTS
+ * Tests for store: true and GET /v1/responses/:id
+ * =========================================================================== */
+
+ describe('Response Storage', () => {
+ it('should store response when store: true and retrieve it', async () => {
+ // Create a stored response
+ const createResponse = await authRequest().post('/api/agents/v1/responses').send({
+ model: testAgent.id,
+ input: 'Remember this: The answer is 42.',
+ store: true,
+ });
+
+ expect(createResponse.status).toBe(200);
+ expect(createResponse.body.status).toBe('completed');
+
+ const responseId = createResponse.body.id;
+ expect(responseId).toMatch(/^resp_/);
+
+ // Small delay to ensure database write completes
+ await new Promise((resolve) => setTimeout(resolve, 500));
+
+ // Retrieve the stored response
+ const getResponseResult = await authRequest().get(`/api/agents/v1/responses/${responseId}`);
+
+ // Note: The response might be stored under conversationId, not responseId
+ // If we get 404, that's expected behavior for now since we store by conversationId
+ if (getResponseResult.status === 200) {
+ expect(getResponseResult.body.object).toBe('response');
+ expect(getResponseResult.body.status).toBe('completed');
+ expect(getResponseResult.body.output.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('should return 404 for non-existent response', async () => {
+ const response = await authRequest().get('/api/agents/v1/responses/resp_nonexistent123');
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toBeDefined();
+ });
+ });
+
+ /* ===========================================================================
+ * ERROR HANDLING TESTS
+ * =========================================================================== */
+
+ describe('Error Handling', () => {
+ it('should return error for missing model', async () => {
+ const response = await authRequest().post('/api/agents/v1/responses').send({
+ input: 'Hello',
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('should return error for missing input', async () => {
+ const response = await authRequest().post('/api/agents/v1/responses').send({
+ model: testAgent.id,
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('should return error for non-existent agent', async () => {
+ const response = await authRequest().post('/api/agents/v1/responses').send({
+ model: 'agent_nonexistent123456789',
+ input: 'Hello',
+ });
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toBeDefined();
+ });
+ });
+
+ /* ===========================================================================
+ * MODELS ENDPOINT TESTS
+ * =========================================================================== */
+
+ describe('GET /v1/responses/models', () => {
+ it('should list available agents as models', async () => {
+ const response = await authRequest().get('/api/agents/v1/responses/models');
+
+ expect(response.status).toBe(200);
+ expect(response.body.object).toBe('list');
+ expect(Array.isArray(response.body.data)).toBe(true);
+
+ // Should include our test agent
+ const foundAgent = response.body.data.find((m) => m.id === testAgent.id);
+ expect(foundAgent).toBeDefined();
+ expect(foundAgent.object).toBe('model');
+ expect(foundAgent.name).toBe(testAgent.name);
+ });
+ });
+});
diff --git a/api/server/routes/agents/__tests__/streamTenant.spec.js b/api/server/routes/agents/__tests__/streamTenant.spec.js
new file mode 100644
index 0000000..708a071
--- /dev/null
+++ b/api/server/routes/agents/__tests__/streamTenant.spec.js
@@ -0,0 +1,188 @@
+const express = require('express');
+const request = require('supertest');
+
+const mockGenerationJobManager = {
+ getJob: jest.fn(),
+ subscribe: jest.fn(),
+ getResumeState: jest.fn(),
+ abortJob: jest.fn(),
+ getActiveJobIdsForUser: jest.fn().mockResolvedValue([]),
+};
+
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ logger: {
+ debug: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ info: jest.fn(),
+ },
+}));
+
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ isEnabled: jest.fn().mockReturnValue(false),
+ GenerationJobManager: mockGenerationJobManager,
+}));
+
+jest.mock('~/models', () => ({
+ saveMessage: jest.fn(),
+}));
+
+let mockUserId = 'user-123';
+let mockTenantId;
+
+jest.mock('~/server/middleware', () => ({
+ uaParser: (req, res, next) => next(),
+ checkBan: (req, res, next) => next(),
+ requireJwtAuth: (req, res, next) => {
+ req.user = { id: mockUserId, tenantId: mockTenantId };
+ next();
+ },
+ messageIpLimiter: (req, res, next) => next(),
+ configMiddleware: (req, res, next) => next(),
+ messageUserLimiter: (req, res, next) => next(),
+}));
+
+jest.mock('~/server/routes/agents/chat', () => require('express').Router());
+jest.mock('~/server/routes/agents/v1', () => {
+ const router = require('express').Router();
+ router.use((req, res) => res.status(418).json({ error: 'v1 caught stream route' }));
+ return { v1: router };
+});
+jest.mock('~/server/routes/agents/openai', () => require('express').Router());
+jest.mock('~/server/routes/agents/responses', () => require('express').Router());
+
+const agentsRouter = require('../index');
+const app = express();
+app.use(express.json());
+app.use('/agents', agentsRouter);
+
+function mockSubscribeSuccess() {
+ mockGenerationJobManager.subscribe.mockImplementation((_streamId, _writeEvent, onDone) => {
+ process.nextTick(() => onDone({ done: true }));
+ return { unsubscribe: jest.fn() };
+ });
+}
+
+describe('SSE stream tenant isolation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUserId = 'user-123';
+ mockTenantId = undefined;
+ });
+
+ describe('GET /chat/stream/:streamId', () => {
+ it('returns 403 when a user from a different tenant accesses a stream', async () => {
+ mockUserId = 'user-456';
+ mockTenantId = 'tenant-b';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'user-456', tenantId: 'tenant-a' },
+ status: 'running',
+ });
+
+ const res = await request(app).get('/agents/chat/stream/stream-123');
+ expect(res.status).toBe(403);
+ expect(res.body.error).toBe('Unauthorized');
+ });
+
+ it('returns 404 when stream does not exist', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(null);
+
+ const res = await request(app).get('/agents/chat/stream/nonexistent');
+ expect(res.status).toBe(404);
+ });
+
+ it('proceeds past tenant guard when tenant matches', async () => {
+ mockUserId = 'user-123';
+ mockTenantId = 'tenant-a';
+ mockSubscribeSuccess();
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'user-123', tenantId: 'tenant-a' },
+ status: 'running',
+ });
+
+ const res = await request(app).get('/agents/chat/stream/stream-123');
+ expect(res.status).toBe(200);
+ expect(mockGenerationJobManager.subscribe).toHaveBeenCalledTimes(1);
+ });
+
+ it('proceeds past tenant guard when job has no tenantId (single-tenant mode)', async () => {
+ mockUserId = 'user-123';
+ mockTenantId = undefined;
+ mockSubscribeSuccess();
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'user-123' },
+ status: 'running',
+ });
+
+ const res = await request(app).get('/agents/chat/stream/stream-123');
+ expect(res.status).toBe(200);
+ expect(mockGenerationJobManager.subscribe).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns 403 when job has tenantId but user has no tenantId', async () => {
+ mockUserId = 'user-123';
+ mockTenantId = undefined;
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'user-123', tenantId: 'some-tenant' },
+ status: 'running',
+ });
+
+ const res = await request(app).get('/agents/chat/stream/stream-123');
+ expect(res.status).toBe(403);
+ });
+ });
+
+ describe('GET /chat/status/:conversationId', () => {
+ it('returns 403 when tenant does not match', async () => {
+ mockUserId = 'user-123';
+ mockTenantId = 'tenant-b';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'user-123', tenantId: 'tenant-a' },
+ status: 'running',
+ });
+
+ const res = await request(app).get('/agents/chat/status/conv-123');
+ expect(res.status).toBe(403);
+ expect(res.body.error).toBe('Unauthorized');
+ });
+
+ it('returns status when tenant matches', async () => {
+ mockUserId = 'user-123';
+ mockTenantId = 'tenant-a';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'user-123', tenantId: 'tenant-a' },
+ status: 'running',
+ createdAt: Date.now(),
+ });
+ mockGenerationJobManager.getResumeState.mockResolvedValue(null);
+
+ const res = await request(app).get('/agents/chat/status/conv-123');
+ expect(res.status).toBe(200);
+ expect(res.body.active).toBe(true);
+ });
+ });
+
+ describe('POST /chat/abort', () => {
+ it('returns 403 when tenant does not match', async () => {
+ mockUserId = 'user-123';
+ mockTenantId = 'tenant-b';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'user-123', tenantId: 'tenant-a' },
+ status: 'running',
+ });
+
+ const res = await request(app).post('/agents/chat/abort').send({ streamId: 'stream-123' });
+ expect(res.status).toBe(403);
+ expect(res.body.error).toBe('Unauthorized');
+ });
+ });
+});
diff --git a/api/server/routes/agents/actions.js b/api/server/routes/agents/actions.js
new file mode 100644
index 0000000..e2e6ff1
--- /dev/null
+++ b/api/server/routes/agents/actions.js
@@ -0,0 +1,313 @@
+const express = require('express');
+const { nanoid } = require('nanoid');
+const { logger } = require('@librechat/data-schemas');
+const {
+ generateCheckAccess,
+ planAgentActionUpdate,
+ isActionDomainAllowed,
+ legacyActionDomainEncode,
+ validateActionOAuthMetadata,
+ ACTION_CREDENTIAL_REFRESH_MESSAGE,
+ buildActionOAuthTokenDeleteQueries,
+} = require('@librechat/api');
+const {
+ Permissions,
+ ResourceType,
+ PermissionBits,
+ PermissionTypes,
+ actionDelimiter,
+ removeNullishValues,
+ validateActionDomain,
+ validateAndParseOpenAPISpec,
+} = require('librechat-data-provider');
+const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
+const { findAccessibleResources } = require('~/server/services/PermissionService');
+const { attachOwnerContacts } = require('~/server/services/Agents/ownerContact');
+const db = require('~/models');
+const { canAccessAgentResource } = require('~/server/middleware');
+
+const router = express.Router();
+
+async function deleteActionOAuthTokens(action_id) {
+ await Promise.all(
+ buildActionOAuthTokenDeleteQueries(action_id).map((query) => db.deleteTokens(query)),
+ );
+}
+
+const checkAgentCreate = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE, Permissions.CREATE],
+ getRoleByName: db.getRoleByName,
+});
+
+/**
+ * Retrieves all user's actions
+ * @route GET /actions/
+ * @param {string} req.params.id - Assistant identifier.
+ * @returns {Action[]} 200 - success response - application/json
+ */
+router.get('/', async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const editableAgentObjectIds = await findAccessibleResources({
+ userId,
+ role: req.user.role,
+ resourceType: ResourceType.AGENT,
+ requiredPermissions: PermissionBits.EDIT,
+ });
+
+ const agentsResponse = await db.getListAgentsByAccess({
+ accessibleIds: editableAgentObjectIds,
+ limit: null,
+ });
+
+ const editableAgentIds = agentsResponse.data.map((agent) => agent.id);
+ const actions =
+ editableAgentIds.length > 0
+ ? await db.getActions({ agent_id: { $in: editableAgentIds } })
+ : [];
+
+ res.json(actions);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+/**
+ * Adds or updates actions for a specific agent.
+ * @route POST /actions/:agent_id
+ * @param {string} req.params.agent_id - The ID of the agent.
+ * @param {FunctionTool[]} req.body.functions - The functions to be added or updated.
+ * @param {string} [req.body.action_id] - Optional ID for the action.
+ * @param {ActionMetadata} req.body.metadata - Metadata for the action.
+ * @returns {Object} 200 - success response - application/json
+ */
+router.post(
+ '/:agent_id',
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'agent_id',
+ }),
+ checkAgentCreate,
+ async (req, res) => {
+ try {
+ const { agent_id } = req.params;
+
+ /** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */
+ const { functions, action_id: _action_id, metadata: _metadata } = req.body;
+ if (!functions.length) {
+ return res.status(400).json({ message: 'No functions provided' });
+ }
+
+ const metadata = await encryptMetadata(removeNullishValues(_metadata, true));
+ const appConfig = req.config;
+
+ // SECURITY: Validate the OpenAPI spec and extract the server URL
+ if (metadata.raw_spec) {
+ const validationResult = validateAndParseOpenAPISpec(metadata.raw_spec);
+ if (!validationResult.status || !validationResult.serverUrl) {
+ return res.status(400).json({
+ message: validationResult.message || 'Invalid OpenAPI specification',
+ });
+ }
+
+ // SECURITY: Validate the client-provided domain matches the spec's server URL domain
+ // This prevents SSRF attacks where an attacker provides a whitelisted domain
+ // but uses a different (potentially internal) URL in the raw_spec
+ const domainValidation = validateActionDomain(metadata.domain, validationResult.serverUrl);
+ if (!domainValidation.isValid) {
+ logger.warn(`Domain mismatch detected: ${domainValidation.message}`, {
+ userId: req.user.id,
+ agent_id,
+ });
+ return res.status(400).json({
+ message:
+ 'Domain mismatch: The domain in the OpenAPI spec does not match the provided domain',
+ });
+ }
+ }
+
+ const isDomainAllowed = await isActionDomainAllowed(
+ metadata.domain,
+ appConfig?.actions?.allowedDomains,
+ appConfig?.actions?.allowedAddresses,
+ );
+ if (!isDomainAllowed) {
+ return res.status(400).json({ message: 'Domain not allowed' });
+ }
+
+ const encodedDomain = await domainParser(metadata.domain, true);
+
+ if (!encodedDomain) {
+ return res.status(400).json({ message: 'No domain provided' });
+ }
+
+ const legacyDomain = legacyActionDomainEncode(metadata.domain);
+
+ const requestedActionId = _action_id;
+ const action_id = requestedActionId ?? nanoid();
+ const initialPromises = [];
+
+ // Permissions already validated by middleware - load agent directly
+ initialPromises.push(db.getAgent({ id: agent_id }));
+ if (requestedActionId) {
+ initialPromises.push(db.getActions({ action_id: requestedActionId }, true));
+ }
+
+ /** @type {[Agent, [Action|undefined]]} */
+ const [agent, actions_result] = await Promise.all(initialPromises);
+ if (!agent) {
+ return res.status(404).json({ message: 'Agent not found for adding action' });
+ }
+
+ const storedAction = actions_result?.[0];
+ if (storedAction) {
+ if (storedAction.agent_id !== agent_id) {
+ return res.status(403).json({ message: 'Action does not belong to this agent' });
+ }
+ }
+
+ const { actions: agentActions = [], tools: agentTools = [], author: agent_author } = agent;
+ const plannedUpdate = planAgentActionUpdate({
+ agentActions,
+ agentTools,
+ incomingFunctions: functions,
+ incomingMetadata: metadata,
+ actionId: action_id,
+ requestedActionId,
+ encodedDomain,
+ legacyDomain,
+ previousLegacyDomain: legacyActionDomainEncode(storedAction?.metadata?.domain),
+ storedAction,
+ });
+
+ if (plannedUpdate.requiresCredentialRefresh) {
+ return res.status(400).json({
+ message: ACTION_CREDENTIAL_REFRESH_MESSAGE,
+ });
+ }
+
+ try {
+ await validateActionOAuthMetadata(
+ plannedUpdate.metadata.auth,
+ appConfig?.actions?.allowedAddresses,
+ );
+ } catch (error) {
+ return res.status(400).json({ message: error.message });
+ }
+
+ if (plannedUpdate.deleteOAuthTokens && requestedActionId) {
+ // Keep the callback URL stable while preventing old OAuth tokens from following a new target.
+ await deleteActionOAuthTokens(requestedActionId);
+ }
+
+ // Force version update since actions are changing
+ const updatedAgent = await db.updateAgent(
+ { id: agent_id },
+ { tools: plannedUpdate.tools, actions: plannedUpdate.actions },
+ {
+ updatingUserId: req.user.id,
+ forceVersion: true,
+ },
+ );
+ await attachOwnerContacts([updatedAgent]);
+
+ // Only update user field for new actions
+ const actionUpdateData = {
+ action_id: plannedUpdate.actionId,
+ metadata: plannedUpdate.metadata,
+ agent_id,
+ };
+ if (!actions_result || !actions_result.length) {
+ // For new actions, use the agent owner's user ID
+ actionUpdateData.user = agent_author || req.user.id;
+ }
+
+ /** @type {Action} */
+ const updatedAction = await db.updateAction(
+ { action_id: requestedActionId ?? action_id, agent_id },
+ actionUpdateData,
+ );
+
+ const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
+ for (let field of sensitiveFields) {
+ if (updatedAction.metadata[field]) {
+ delete updatedAction.metadata[field];
+ }
+ }
+
+ res.json([updatedAgent, updatedAction]);
+ } catch (error) {
+ const message = 'Trouble updating the Agent Action';
+ logger.error(message, error);
+ res.status(500).json({ message });
+ }
+ },
+);
+
+/**
+ * Deletes an action for a specific agent.
+ * @route DELETE /actions/:agent_id/:action_id
+ * @param {string} req.params.agent_id - The ID of the agent.
+ * @param {string} req.params.action_id - The ID of the action to delete.
+ * @returns {Object} 200 - success response - application/json
+ */
+router.delete(
+ '/:agent_id/:action_id',
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'agent_id',
+ }),
+ checkAgentCreate,
+ async (req, res) => {
+ try {
+ const { agent_id, action_id } = req.params;
+
+ // Permissions already validated by middleware - load agent directly
+ const agent = await db.getAgent({ id: agent_id });
+ if (!agent) {
+ return res.status(404).json({ message: 'Agent not found for deleting action' });
+ }
+
+ const { tools = [], actions = [] } = agent;
+
+ let storedDomain = '';
+ const updatedActions = actions.filter((action) => {
+ if (action.includes(action_id)) {
+ [storedDomain] = action.split(actionDelimiter);
+ return false;
+ }
+ return true;
+ });
+
+ if (!storedDomain) {
+ return res.status(400).json({ message: 'No domain provided' });
+ }
+
+ const updatedTools = tools.filter(
+ (tool) => !(tool && (tool.includes(storedDomain) || tool.includes(action_id))),
+ );
+
+ // Force version update since actions are being removed
+ await db.updateAgent(
+ { id: agent_id },
+ { tools: updatedTools, actions: updatedActions },
+ { updatingUserId: req.user.id, forceVersion: true },
+ );
+ const deleted = await db.deleteAction({ action_id, agent_id });
+ if (!deleted) {
+ logger.warn('[Agent Action Delete] No matching action document found', {
+ action_id,
+ agent_id,
+ });
+ }
+ res.status(200).json({ message: 'Action deleted successfully' });
+ } catch (error) {
+ const message = 'Trouble deleting the Agent Action';
+ logger.error(message, error);
+ res.status(500).json({ message });
+ }
+ },
+);
+
+module.exports = router;
diff --git a/api/server/routes/agents/chat.js b/api/server/routes/agents/chat.js
new file mode 100644
index 0000000..9463406
--- /dev/null
+++ b/api/server/routes/agents/chat.js
@@ -0,0 +1,121 @@
+const express = require('express');
+const { logger } = require('@librechat/data-schemas');
+const {
+ createMessageFilterPii,
+ generateCheckAccess,
+ skipAgentCheck,
+ applyResumeContext,
+ GenerationJobManager,
+} = require('@librechat/api');
+const { PermissionTypes, Permissions, PermissionBits } = require('librechat-data-provider');
+const {
+ moderateText,
+ // validateModel,
+ validateConvoAccess,
+ buildEndpointOption,
+ canAccessAgentFromBody,
+} = require('~/server/middleware');
+const { initializeClient } = require('~/server/services/Endpoints/agents');
+const AgentController = require('~/server/controllers/agents/request');
+const ResumeController = require('~/server/controllers/agents/resume');
+const addTitle = require('~/server/services/Endpoints/agents/title');
+const { getRoleByName } = require('~/models');
+
+const router = express.Router();
+
+const checkAgentAccess = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ skipCheck: skipAgentCheck,
+ getRoleByName,
+});
+const checkAgentResourceAccess = canAccessAgentFromBody({
+ requiredPermission: PermissionBits.VIEW,
+});
+
+/**
+ * Replay the paused turn's graph-determining config onto a resume request BEFORE the
+ * rest of the chain (PII filter, agent-access, buildEndpointOption) reads it. The client
+ * can't reliably re-send the ephemeral-agent config after a reload/cross-session, so the
+ * server restores it from the pending action — the resume then rebuilds the SAME
+ * agent/graph the run paused on (and a crafted resume can't swap the tool set). No-op for
+ * every non-resume route.
+ */
+const restoreResumeContext = async (req, res, next) => {
+ if (req.path !== '/resume') {
+ return next();
+ }
+ try {
+ const streamId = req.body?.conversationId;
+ if (streamId) {
+ const job = await GenerationJobManager.getJob(streamId);
+ const resumeContext = job?.metadata?.pendingAction?.resumeContext;
+ applyResumeContext(req.body, resumeContext);
+ // Replay the paused turn's resolved model parameters. Ephemeral agents derive these
+ // (temperature, max tokens, custom endpoint params) from the request body, which the
+ // resume payload omits — without this the continuation runs with defaults. They're
+ // scattered top-level fields (folded into model_parameters by buildOptions' rest
+ // spread), not part of the RESUME_CONTEXT_KEYS allowlist, so merge them back here.
+ // Authoritative: overwrites any client-supplied values with the captured set.
+ // `model` is excluded — it's replayed via RESUME_CONTEXT_KEYS to the exact value the
+ // resume fingerprint was pinned on, so overwriting it here could trip that check.
+ const resumedModelParameters = resumeContext?.model_parameters;
+ if (resumedModelParameters && typeof resumedModelParameters === 'object') {
+ const { model: _replayedModel, ...replayParams } = resumedModelParameters;
+ Object.assign(req.body, replayParams);
+ }
+ }
+ } catch (err) {
+ logger.warn('[agents/chat] Failed to restore resume context', err?.message ?? err);
+ }
+ next();
+};
+
+router.use(restoreResumeContext);
+router.use(createMessageFilterPii({ getConfig: (req) => req.config?.messageFilter?.pii }));
+router.use(moderateText);
+router.use(checkAgentAccess);
+router.use(checkAgentResourceAccess);
+router.use(validateConvoAccess);
+router.use(buildEndpointOption);
+
+const controller = async (req, res, next) => {
+ await AgentController(req, res, next, initializeClient, addTitle);
+};
+
+const resumeController = async (req, res, next) => {
+ await ResumeController(req, res, next, initializeClient, addTitle);
+};
+
+/**
+ * @route POST /resume
+ * @desc Resume a generation paused for human-in-the-loop review (tool approval or
+ * ask-user answer). Shares this router's middleware so the agent/endpoint are
+ * reconstructed from the request exactly like a normal turn. Declared before
+ * `/:endpoint` so it is not captured as an ephemeral endpoint name.
+ * @access Private
+ * @returns {void}
+ */
+router.post('/resume', resumeController);
+
+/**
+ * @route POST / (regular endpoint)
+ * @desc Chat with an assistant
+ * @access Public
+ * @param {express.Request} req - The request object, containing the request data.
+ * @param {express.Response} res - The response object, used to send back a response.
+ * @returns {void}
+ */
+router.post('/', controller);
+
+/**
+ * @route POST /:endpoint (ephemeral agents)
+ * @desc Chat with an assistant
+ * @access Public
+ * @param {express.Request} req - The request object, containing the request data.
+ * @param {express.Response} res - The response object, used to send back a response.
+ * @returns {void}
+ */
+router.post('/:endpoint', controller);
+
+module.exports = router;
diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js
new file mode 100644
index 0000000..8161219
--- /dev/null
+++ b/api/server/routes/agents/index.js
@@ -0,0 +1,405 @@
+const express = require('express');
+const {
+ isEnabled,
+ GenerationJobManager,
+ hasPersistableAbortContent,
+ buildAbortedResponseMetadata,
+ isPendingActionStale,
+ toClientPendingAction,
+ isHITLEnabled,
+ deleteAgentCheckpoint,
+ attachAskUserQuestionArgs,
+} = require('@librechat/api');
+const { createSseStreamTelemetry } = require('@librechat/api/telemetry');
+const { logger } = require('@librechat/data-schemas');
+const {
+ uaParser,
+ checkBan,
+ requireJwtAuth,
+ messageIpLimiter,
+ configMiddleware,
+ messageUserLimiter,
+} = require('~/server/middleware');
+const { saveMessage } = require('~/models');
+const responses = require('./responses');
+const openai = require('./openai');
+const { v1 } = require('./v1');
+const chat = require('./chat');
+
+const { LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
+
+/** Untenanted jobs (pre-multi-tenancy) remain accessible if the userId check passes. */
+function hasTenantMismatch(job, user) {
+ return job.metadata?.tenantId != null && job.metadata.tenantId !== user.tenantId;
+}
+
+const router = express.Router();
+
+/**
+ * Open Responses API routes (API key authentication handled in route file)
+ * Mounted at /agents/v1/responses (full path: /api/agents/v1/responses)
+ * NOTE: Must be mounted BEFORE /v1 to avoid being caught by the less specific route
+ * @see https://openresponses.org/specification
+ */
+router.use('/v1/responses', responses);
+
+/**
+ * OpenAI-compatible API routes (API key authentication handled in route file)
+ * Mounted at /agents/v1 (full path: /api/agents/v1/chat/completions)
+ */
+router.use('/v1', openai);
+
+router.use(requireJwtAuth);
+router.use(checkBan);
+router.use(uaParser);
+
+/**
+ * Stream endpoints - mounted before chatRouter to bypass rate limiters
+ * These are GET requests and don't need message body validation or rate limiting
+ */
+
+/**
+ * @route GET /chat/stream/:streamId
+ * @desc Subscribe to an ongoing generation job's SSE stream with replay support
+ * @access Private
+ * @description Sends sync event with resume state, replays missed chunks, then streams live
+ * @query resume=true - Indicates this is a reconnection (sends sync event)
+ */
+router.get('/chat/stream/:streamId', async (req, res) => {
+ const { streamId } = req.params;
+ const isResume = req.query.resume === 'true';
+
+ const job = await GenerationJobManager.getJob(streamId);
+ if (!job) {
+ return res.status(404).json({
+ error: 'Stream not found',
+ message: 'The generation job does not exist or has expired.',
+ });
+ }
+
+ if (job.metadata?.userId && job.metadata.userId !== req.user.id) {
+ return res.status(403).json({ error: 'Unauthorized' });
+ }
+
+ if (hasTenantMismatch(job, req.user)) {
+ return res.status(403).json({ error: 'Unauthorized' });
+ }
+
+ const streamTelemetry = createSseStreamTelemetry({ req, res, streamId, isResume });
+
+ res.setHeader('Content-Encoding', 'identity');
+ res.setHeader('Content-Type', 'text/event-stream');
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
+ res.setHeader('Connection', 'keep-alive');
+ res.setHeader('X-Accel-Buffering', 'no');
+ res.flushHeaders();
+ streamTelemetry.recordHeadersFlushed();
+
+ logger.debug(`[AgentStream] Client subscribed to ${streamId}, resume: ${isResume}`);
+
+ const writeEvent = (event, options = {}) => {
+ if (!res.writableEnded) {
+ const eventName = options.eventName ?? 'message';
+ const payload = `event: ${eventName}\ndata: ${JSON.stringify(event)}\n\n`;
+ res.write(payload);
+ streamTelemetry.recordWrite(payload, { final: options.final });
+ if (typeof res.flush === 'function') {
+ res.flush();
+ }
+ return true;
+ }
+
+ return false;
+ };
+
+ const onDone = (event) => {
+ streamTelemetry.recordFinalEventEmitted();
+ writeEvent(event, { final: true });
+ res.end();
+ };
+
+ const onError = (error) => {
+ if (!res.writableEnded) {
+ streamTelemetry.recordErrorEventEmitted();
+ writeEvent({ error }, { eventName: 'error' });
+ res.end();
+ }
+ };
+
+ let result;
+
+ if (isResume) {
+ const { subscription, resumeState, pendingEvents } =
+ await GenerationJobManager.subscribeWithResume(streamId, writeEvent, onDone, onError);
+
+ if (!res.writableEnded) {
+ if (resumeState) {
+ writeEvent({ sync: true, resumeState, pendingEvents });
+ GenerationJobManager.markSyncSent(streamId);
+ logger.debug(
+ `[AgentStream] Sent sync event for ${streamId} with ${resumeState.runSteps.length} run steps, ${pendingEvents.length} pending events`,
+ );
+ } else if (pendingEvents.length > 0) {
+ for (const event of pendingEvents) {
+ writeEvent(event);
+ }
+ logger.warn(
+ `[AgentStream] Resume state null for ${streamId}, replayed ${pendingEvents.length} gap events directly`,
+ );
+ }
+ }
+
+ result = subscription;
+ } else {
+ result = await GenerationJobManager.subscribe(streamId, writeEvent, onDone, onError);
+ }
+
+ if (!result) {
+ streamTelemetry.recordSubscribeFailed();
+ onError('Failed to subscribe to stream');
+ return;
+ }
+
+ req.on('close', () => {
+ logger.debug(`[AgentStream] Client disconnected from ${streamId}`);
+ result.unsubscribe();
+ });
+});
+
+/**
+ * @route GET /chat/active
+ * @desc Get all active generation job IDs for the current user
+ * @access Private
+ * @returns { activeJobIds: string[] }
+ */
+router.get('/chat/active', async (req, res) => {
+ const activeJobIds = await GenerationJobManager.getActiveJobIdsForUser(
+ req.user.id,
+ req.user.tenantId,
+ );
+ res.json({ activeJobIds });
+});
+
+/**
+ * @route GET /chat/status/:conversationId
+ * @desc Check if there's an active generation job for a conversation
+ * @access Private
+ * @returns { active, streamId, status, aggregatedContent, createdAt, resumeState }
+ */
+router.get('/chat/status/:conversationId', async (req, res) => {
+ const { conversationId } = req.params;
+
+ // streamId === conversationId, so we can use getJob directly
+ const job = await GenerationJobManager.getJob(conversationId);
+
+ if (!job) {
+ return res.json({ active: false });
+ }
+
+ if (job.metadata.userId !== req.user.id) {
+ return res.status(403).json({ error: 'Unauthorized' });
+ }
+
+ if (hasTenantMismatch(job, req.user)) {
+ return res.status(403).json({ error: 'Unauthorized' });
+ }
+
+ // Get resume state which contains aggregatedContent
+ // Avoid calling both getStreamInfo and getResumeState (both fetch content)
+ const resumeState = await GenerationJobManager.getResumeState(conversationId);
+ // A job paused for human review is still active (consistent with /chat/active),
+ // so the client resumes/subscribes rather than treating it as finished — but
+ // only while it has a live, resolvable prompt: a missing/malformed or
+ // past-expiry pendingAction reads as inactive (cleanup/expiry will finalize it).
+ const pendingAction = job.metadata.pendingAction;
+ const pendingLive = job.status === 'requires_action' && !isPendingActionStale({ pendingAction });
+ const isActive = job.status === 'running' || pendingLive;
+
+ res.json({
+ active: isActive,
+ streamId: conversationId,
+ status: job.status,
+ aggregatedContent: resumeState?.aggregatedContent ?? [],
+ createdAt: job.createdAt,
+ resumeState,
+ // Surface the live pending approval so a client rebuilding from /chat/status
+ // (reload / cross-replica) has the action id + payload to render and submit
+ // the prompt, not just the knowledge that the stream is paused. Client-safe
+ // projection only — resumeContext/requestFingerprint stay server-side.
+ pendingAction:
+ job.status === 'requires_action' && pendingLive
+ ? toClientPendingAction(pendingAction)
+ : undefined,
+ });
+});
+
+/**
+ * @route POST /chat/abort
+ * @desc Abort an ongoing generation job
+ * @access Private
+ * @description Mounted before chatRouter to bypass buildEndpointOption middleware
+ */
+router.post('/chat/abort', configMiddleware, async (req, res) => {
+ logger.debug(`[AgentStream] ========== ABORT ENDPOINT HIT ==========`);
+ logger.debug(`[AgentStream] Method: ${req.method}, Path: ${req.path}`);
+ logger.debug(`[AgentStream] Body:`, req.body);
+
+ const { streamId, conversationId, abortKey } = req.body;
+ const userId = req.user?.id;
+
+ // streamId === conversationId, so try any of the provided IDs
+ // Skip "new" as it's a placeholder for new conversations, not an actual ID
+ let jobStreamId =
+ streamId ||
+ (conversationId !== 'new' ? conversationId : null) ||
+ abortKey?.split(':')[0] ||
+ null;
+ let job = jobStreamId ? await GenerationJobManager.getJob(jobStreamId) : null;
+
+ // Fallback: if job not found and we have a userId, look up active jobs for user
+ // This handles the case where frontend sends "new" but job was created with a UUID
+ if (!job && userId) {
+ logger.debug(`[AgentStream] Job not found by ID, checking active jobs for user: ${userId}`);
+ const activeJobIds = await GenerationJobManager.getActiveJobIdsForUser(
+ userId,
+ req.user.tenantId,
+ );
+ for (const activeJobId of activeJobIds) {
+ const activeJob = await GenerationJobManager.getJob(activeJobId);
+ if (activeJob?.status !== 'running') {
+ continue;
+ }
+ jobStreamId = activeJobId;
+ job = activeJob;
+ logger.debug(`[AgentStream] Found active job for user: ${jobStreamId}`);
+ break;
+ }
+ }
+
+ logger.debug(`[AgentStream] Computed jobStreamId: ${jobStreamId}`);
+
+ if (job && jobStreamId) {
+ if (job.metadata?.userId && job.metadata.userId !== userId) {
+ logger.warn(`[AgentStream] Unauthorized abort attempt for ${jobStreamId} by user ${userId}`);
+ return res.status(403).json({ error: 'Unauthorized' });
+ }
+
+ if (hasTenantMismatch(job, req.user)) {
+ return res.status(403).json({ error: 'Unauthorized' });
+ }
+
+ logger.debug(`[AgentStream] Job found, aborting: ${jobStreamId}`);
+ // Re-attach a paused ask_user_question's args to the abort content BEFORE
+ // abortJob emits the final SSE. Redis reconstructs abort content from the
+ // chunk log, which never saw the pause-time stamp applied to the in-process
+ // contentParts — stamping inside abortJob (not after) means the LIVE client
+ // gets the question too, not just the saved message on reload.
+ const abortedAskPayload = job.metadata?.pendingAction?.payload;
+ const abortResult = await GenerationJobManager.abortJob(jobStreamId, {
+ transformAbortContent: (content) =>
+ abortedAskPayload?.type === 'ask_user_question' && Array.isArray(content)
+ ? attachAskUserQuestionArgs(content, abortedAskPayload.question)
+ : content,
+ });
+ logger.debug(`[AgentStream] Job aborted successfully: ${jobStreamId}`, {
+ abortResultSuccess: abortResult.success,
+ abortResultUserMessageId: abortResult.jobData?.userMessage?.messageId,
+ abortResultResponseMessageId: abortResult.jobData?.responseMessageId,
+ });
+
+ // HITL: prune the durable checkpoint of a run aborted while paused, so a new turn
+ // in this conversation can't rehydrate the stale interrupt before the Mongo TTL
+ // reclaims it (thread_id is the stable conversationId). Idempotent / no-op when
+ // HITL is off or nothing was written. The pendingAction check covers ask-only
+ // pauses (ask_user_question attaches a checkpointer WITHOUT the approval policy):
+ // a job aborted while paused still carries its pendingAction in metadata, which is
+ // exactly the case whose checkpoint would otherwise go stale.
+ const agentsCfg = req.config?.endpoints?.agents;
+ if (isHITLEnabled(agentsCfg?.toolApproval) || job.metadata?.pendingAction != null) {
+ await deleteAgentCheckpoint(jobStreamId, agentsCfg?.checkpointer).catch((err) =>
+ logger.error(`[AgentStream] Failed to prune checkpoint on abort: ${jobStreamId}`, err),
+ );
+ }
+
+ // CRITICAL: Save partial response BEFORE returning to prevent race condition.
+ // If user sends a follow-up immediately after abort, the parentMessageId must exist in DB.
+ // Only save if we have a valid responseMessageId (skip early aborts before generation started)
+ if (
+ abortResult.success &&
+ abortResult.jobData?.userMessage?.messageId &&
+ abortResult.jobData?.responseMessageId &&
+ hasPersistableAbortContent(abortResult.content)
+ ) {
+ const { jobData, text } = abortResult;
+ // `abortResult.content` is already stamped by `transformAbortContent`
+ // above (same content the final SSE carried), so the saved message and
+ // the live client agree.
+ const { content } = abortResult;
+ const responseMessage = {
+ messageId: jobData.responseMessageId,
+ parentMessageId: jobData.userMessage.messageId,
+ conversationId: jobData.conversationId,
+ content: content || [],
+ text: text || '',
+ sender: jobData.sender || 'AI',
+ endpoint: jobData.endpoint,
+ iconURL: jobData.iconURL,
+ model: jobData.model,
+ unfinished: true,
+ error: false,
+ isCreatedByUser: false,
+ user: userId,
+ };
+
+ /** Persist the usage/cost rollup + context breakdown for the stopped
+ * response (from the job's tracked tokenUsage/contextUsage) so its
+ * branch/total cost and granular rows survive a reload — parity with the
+ * normal completion path. */
+ const abortMetadata = buildAbortedResponseMetadata(jobData);
+ if (abortMetadata) {
+ responseMessage.metadata = abortMetadata;
+ }
+
+ try {
+ await saveMessage(
+ {
+ userId: req?.user?.id,
+ // Source from the job, not the request: the stop button posts only the
+ // conversationId, so trusting req.body.isTemporary would persist an aborted
+ // temporary-chat partial as a normal (orphaned) message.
+ isTemporary: jobData?.isTemporary ?? req?.body?.isTemporary,
+ interfaceConfig: req?.config?.interfaceConfig,
+ },
+ responseMessage,
+ { context: 'api/server/routes/agents/index.js - abort endpoint' },
+ );
+ logger.debug(`[AgentStream] Saved partial response for: ${jobStreamId}`);
+ } catch (saveError) {
+ logger.error(`[AgentStream] Failed to save partial response: ${saveError.message}`);
+ }
+ }
+
+ return res.json({ success: true, aborted: jobStreamId });
+ }
+
+ logger.warn(`[AgentStream] Job not found for streamId: ${jobStreamId}`);
+ return res.status(404).json({ error: 'Job not found', streamId: jobStreamId });
+});
+
+router.use('/', v1);
+
+const chatRouter = express.Router();
+chatRouter.use(configMiddleware);
+
+if (isEnabled(LIMIT_MESSAGE_IP)) {
+ chatRouter.use(messageIpLimiter);
+}
+
+if (isEnabled(LIMIT_MESSAGE_USER)) {
+ chatRouter.use(messageUserLimiter);
+}
+
+chatRouter.use('/', chat);
+router.use('/chat', chatRouter);
+
+module.exports = router;
diff --git a/api/server/routes/agents/middleware.js b/api/server/routes/agents/middleware.js
new file mode 100644
index 0000000..efb4a8c
--- /dev/null
+++ b/api/server/routes/agents/middleware.js
@@ -0,0 +1,42 @@
+const { PermissionTypes, Permissions } = require('librechat-data-provider');
+const {
+ generateCheckAccess,
+ preAuthTenantMiddleware,
+ createRequireApiKeyAuth,
+ createRemoteAgentAuth,
+ createCheckRemoteAgentAccess,
+} = require('@librechat/api');
+const { getEffectivePermissions } = require('~/server/services/PermissionService');
+const { getAppConfig } = require('~/server/services/Config');
+const db = require('~/models');
+
+const apiKeyMiddleware = createRequireApiKeyAuth({
+ validateAgentApiKey: db.validateAgentApiKey,
+ findUser: db.findUser,
+});
+
+const requireRemoteAgentAuth = createRemoteAgentAuth({
+ apiKeyMiddleware,
+ findUser: db.findUser,
+ getRolesByNames: db.findRolesByNames,
+ updateUser: db.updateUser,
+ getAppConfig,
+});
+
+const checkRemoteAgentsFeature = generateCheckAccess({
+ permissionType: PermissionTypes.REMOTE_AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName: db.getRoleByName,
+});
+
+const checkAgentPermission = createCheckRemoteAgentAccess({
+ getAgent: db.getAgent,
+ getEffectivePermissions,
+});
+
+module.exports = {
+ checkAgentPermission,
+ preAuthTenantMiddleware,
+ requireRemoteAgentAuth,
+ checkRemoteAgentsFeature,
+};
diff --git a/api/server/routes/agents/openai.js b/api/server/routes/agents/openai.js
new file mode 100644
index 0000000..fa7f9b2
--- /dev/null
+++ b/api/server/routes/agents/openai.js
@@ -0,0 +1,91 @@
+/**
+ * OpenAI-compatible API routes for LibreChat agents.
+ *
+ * Provides a /v1/chat/completions compatible interface for
+ * interacting with LibreChat agents remotely via API.
+ *
+ * Usage:
+ * POST /v1/chat/completions - Chat with an agent
+ * GET /v1/models - List available agents
+ * GET /v1/models/:model - Get agent details
+ *
+ * Request format:
+ * {
+ * "model": "agent_id_here",
+ * "messages": [{"role": "user", "content": "Hello!"}],
+ * "stream": true
+ * }
+ */
+const express = require('express');
+const {
+ OpenAIChatCompletionController,
+ ListModelsController,
+ GetModelController,
+} = require('~/server/controllers/agents/openai');
+const { configMiddleware } = require('~/server/middleware');
+const {
+ checkAgentPermission,
+ preAuthTenantMiddleware,
+ requireRemoteAgentAuth,
+ checkRemoteAgentsFeature,
+} = require('./middleware');
+
+const router = express.Router();
+
+router.use(preAuthTenantMiddleware);
+router.use(requireRemoteAgentAuth);
+router.use(configMiddleware);
+router.use(checkRemoteAgentsFeature);
+
+/**
+ * @route POST /v1/chat/completions
+ * @desc OpenAI-compatible chat completions with agents
+ * @access Private (API key auth required)
+ *
+ * Request body:
+ * {
+ * "model": "agent_id", // Required: The agent ID to use
+ * "messages": [...], // Required: Array of chat messages
+ * "stream": true, // Optional: Whether to stream (default: false)
+ * "conversation_id": "...", // Optional: Conversation ID for context
+ * "parent_message_id": "..." // Optional: Parent message for threading
+ * }
+ *
+ * Response (streaming):
+ * - SSE stream with OpenAI chat.completion.chunk format
+ * - Includes delta.reasoning for thinking/reasoning content
+ *
+ * Response (non-streaming):
+ * - Standard OpenAI chat.completion format
+ */
+router.post('/chat/completions', checkAgentPermission, OpenAIChatCompletionController);
+
+/**
+ * @route GET /v1/models
+ * @desc List available agents as models
+ * @access Private (API key auth required)
+ *
+ * Response:
+ * {
+ * "object": "list",
+ * "data": [
+ * {
+ * "id": "agent_id",
+ * "object": "model",
+ * "name": "Agent Name",
+ * "provider": "openai",
+ * ...
+ * }
+ * ]
+ * }
+ */
+router.get('/models', ListModelsController);
+
+/**
+ * @route GET /v1/models/:model
+ * @desc Get details for a specific agent/model
+ * @access Private (API key auth required)
+ */
+router.get('/models/:model', GetModelController);
+
+module.exports = router;
diff --git a/api/server/routes/agents/responses.js b/api/server/routes/agents/responses.js
new file mode 100644
index 0000000..401025b
--- /dev/null
+++ b/api/server/routes/agents/responses.js
@@ -0,0 +1,125 @@
+/**
+ * Open Responses API routes for LibreChat agents.
+ *
+ * Implements the Open Responses specification for a forward-looking,
+ * agentic API that uses items as the fundamental unit and semantic
+ * streaming events.
+ *
+ * Usage:
+ * POST /v1/responses - Create a response
+ * GET /v1/models - List available agents
+ *
+ * Request format:
+ * {
+ * "model": "agent_id_here",
+ * "input": "Hello!" or [{ type: "message", role: "user", content: "Hello!" }],
+ * "stream": true,
+ * "previous_response_id": "optional_conversation_id"
+ * }
+ *
+ * @see https://openresponses.org/specification
+ */
+const express = require('express');
+const {
+ createResponse,
+ getResponse,
+ listModels,
+} = require('~/server/controllers/agents/responses');
+const { configMiddleware } = require('~/server/middleware');
+const {
+ checkAgentPermission,
+ preAuthTenantMiddleware,
+ requireRemoteAgentAuth,
+ checkRemoteAgentsFeature,
+} = require('./middleware');
+
+const router = express.Router();
+
+router.use(preAuthTenantMiddleware);
+router.use(requireRemoteAgentAuth);
+router.use(configMiddleware);
+router.use(checkRemoteAgentsFeature);
+
+/**
+ * @route POST /v1/responses
+ * @desc Create a model response following Open Responses specification
+ * @access Private (API key auth required)
+ *
+ * Request body:
+ * {
+ * "model": "agent_id", // Required: The agent ID to use
+ * "input": "..." | [...], // Required: String or array of input items
+ * "stream": true, // Optional: Whether to stream (default: false)
+ * "previous_response_id": "...", // Optional: Previous response for continuation
+ * "instructions": "...", // Optional: Additional instructions
+ * "tools": [...], // Optional: Additional tools
+ * "tool_choice": "auto", // Optional: Tool choice mode
+ * "max_output_tokens": 4096, // Optional: Max tokens
+ * "temperature": 0.7 // Optional: Temperature
+ * }
+ *
+ * Response (streaming):
+ * - SSE stream with semantic events:
+ * - response.in_progress
+ * - response.output_item.added
+ * - response.content_part.added
+ * - response.output_text.delta
+ * - response.output_text.done
+ * - response.function_call_arguments.delta
+ * - response.output_item.done
+ * - response.completed
+ * - [DONE]
+ *
+ * Response (non-streaming):
+ * {
+ * "id": "resp_xxx",
+ * "object": "response",
+ * "created_at": 1234567890,
+ * "status": "completed",
+ * "model": "agent_id",
+ * "output": [...], // Array of output items
+ * "usage": { ... }
+ * }
+ */
+router.post('/', checkAgentPermission, createResponse);
+
+/**
+ * @route GET /v1/responses/models
+ * @desc List available agents as models
+ * @access Private (API key auth required)
+ *
+ * Response:
+ * {
+ * "object": "list",
+ * "data": [
+ * {
+ * "id": "agent_id",
+ * "object": "model",
+ * "name": "Agent Name",
+ * "provider": "openai",
+ * ...
+ * }
+ * ]
+ * }
+ */
+router.get('/models', listModels);
+
+/**
+ * @route GET /v1/responses/:id
+ * @desc Retrieve a stored response by ID
+ * @access Private (API key auth required)
+ *
+ * Response:
+ * {
+ * "id": "resp_xxx",
+ * "object": "response",
+ * "created_at": 1234567890,
+ * "status": "completed",
+ * "model": "agent_id",
+ * "output": [...],
+ * "usage": { ... }
+ * }
+ */
+router.get('/:id', getResponse);
+
+module.exports = router;
diff --git a/api/server/routes/agents/tools.js b/api/server/routes/agents/tools.js
new file mode 100644
index 0000000..ca512e9
--- /dev/null
+++ b/api/server/routes/agents/tools.js
@@ -0,0 +1,39 @@
+const express = require('express');
+const { callTool, verifyToolAuth, getToolCalls } = require('~/server/controllers/tools');
+const { getAvailableTools } = require('~/server/controllers/PluginController');
+const { toolCallLimiter } = require('~/server/middleware');
+
+const router = express.Router();
+
+/**
+ * Get a list of available tools for agents.
+ * @route GET /agents/tools
+ * @returns {TPlugin[]} 200 - application/json
+ */
+router.get('/', getAvailableTools);
+
+/**
+ * Get a list of tool calls.
+ * @route GET /agents/tools/calls
+ * @returns {ToolCallData[]} 200 - application/json
+ */
+router.get('/calls', getToolCalls);
+
+/**
+ * Verify authentication for a specific tool
+ * @route GET /agents/tools/:toolId/auth
+ * @param {string} toolId - The ID of the tool to verify
+ * @returns {{ authenticated?: boolean; message?: string }}
+ */
+router.get('/:toolId/auth', verifyToolAuth);
+
+/**
+ * Execute code for a specific tool
+ * @route POST /agents/tools/:toolId/call
+ * @param {string} toolId - The ID of the tool to execute
+ * @param {object} req.body - Request body
+ * @returns {object} Result of code execution
+ */
+router.post('/:toolId/call', toolCallLimiter, callTool);
+
+module.exports = router;
diff --git a/api/server/routes/agents/v1.js b/api/server/routes/agents/v1.js
new file mode 100644
index 0000000..233839d
--- /dev/null
+++ b/api/server/routes/agents/v1.js
@@ -0,0 +1,193 @@
+const express = require('express');
+const { generateCheckAccess } = require('@librechat/api');
+const { PermissionTypes, Permissions, PermissionBits } = require('librechat-data-provider');
+const { requireJwtAuth, configMiddleware, canAccessAgentResource } = require('~/server/middleware');
+const v1 = require('~/server/controllers/agents/v1');
+const { getRoleByName } = require('~/models');
+const actions = require('./actions');
+const tools = require('./tools');
+
+const router = express.Router();
+const avatar = express.Router();
+
+const checkAgentAccess = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+});
+const checkAgentCreate = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE, Permissions.CREATE],
+ getRoleByName,
+});
+
+router.use(requireJwtAuth);
+
+/**
+ * Agent actions route.
+ * @route GET|POST /agents/actions
+ */
+router.use('/actions', configMiddleware, actions);
+
+/**
+ * Get a list of available tools for agents.
+ * @route GET /agents/tools
+ */
+router.use('/tools', configMiddleware, tools);
+
+/**
+ * Get all agent categories with counts
+ * @route GET /agents/categories
+ */
+router.get('/categories', v1.getAgentCategories);
+/**
+ * Creates an agent.
+ * @route POST /agents
+ * @param {AgentCreateParams} req.body - The agent creation parameters.
+ * @returns {Agent} 201 - Success response - application/json
+ */
+router.post('/', checkAgentCreate, v1.createAgent);
+
+/**
+ * Retrieves basic agent information (VIEW permission required).
+ * Returns safe, non-sensitive agent data for viewing purposes.
+ * @route GET /agents/:id
+ * @param {string} req.params.id - Agent identifier.
+ * @returns {Agent} 200 - Basic agent info - application/json
+ */
+router.get(
+ '/:id',
+ checkAgentAccess,
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.VIEW,
+ resourceIdParam: 'id',
+ }),
+ v1.getAgent,
+);
+
+/**
+ * Retrieves full agent details including sensitive configuration (EDIT permission required).
+ * Returns complete agent data for editing/configuration purposes.
+ * @route GET /agents/:id/expanded
+ * @param {string} req.params.id - Agent identifier.
+ * @returns {Agent} 200 - Full agent details - application/json
+ */
+router.get(
+ '/:id/expanded',
+ checkAgentAccess,
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'id',
+ }),
+ (req, res) => v1.getAgent(req, res, true), // Expanded version
+);
+
+/**
+ * Retrieves an agent's version history (EDIT permission required).
+ * Loaded lazily so the editor doesn't transfer large histories up front.
+ * @route GET /agents/:id/versions
+ * @param {string} req.params.id - Agent identifier.
+ * @returns {Agent[]} 200 - Agent version history - application/json
+ */
+router.get(
+ '/:id/versions',
+ checkAgentAccess,
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'id',
+ }),
+ v1.getAgentVersions,
+);
+/**
+ * Updates an agent.
+ * @route PATCH /agents/:id
+ * @param {string} req.params.id - Agent identifier.
+ * @param {AgentUpdateParams} req.body - The agent update parameters.
+ * @returns {Agent} 200 - Success response - application/json
+ */
+router.patch(
+ '/:id',
+ checkAgentCreate,
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'id',
+ }),
+ v1.updateAgent,
+);
+
+/**
+ * Duplicates an agent.
+ * @route POST /agents/:id/duplicate
+ * @param {string} req.params.id - Agent identifier.
+ * @returns {Agent} 201 - Success response - application/json
+ */
+router.post(
+ '/:id/duplicate',
+ checkAgentCreate,
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'id',
+ }),
+ v1.duplicateAgent,
+);
+
+/**
+ * Deletes an agent.
+ * @route DELETE /agents/:id
+ * @param {string} req.params.id - Agent identifier.
+ * @returns {Agent} 200 - success response - application/json
+ */
+router.delete(
+ '/:id',
+ checkAgentCreate,
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.DELETE,
+ resourceIdParam: 'id',
+ }),
+ v1.deleteAgent,
+);
+
+/**
+ * Reverts an agent to a previous version.
+ * @route POST /agents/:id/revert
+ * @param {string} req.params.id - Agent identifier.
+ * @param {number} req.body.version_index - Index of the version to revert to.
+ * @returns {Agent} 200 - success response - application/json
+ */
+router.post(
+ '/:id/revert',
+ checkAgentCreate,
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'id',
+ }),
+ v1.revertAgentVersion,
+);
+
+/**
+ * Returns a list of agents.
+ * @route GET /agents
+ * @param {AgentListParams} req.query - The agent list parameters for pagination and sorting.
+ * @returns {AgentListResponse} 200 - success response - application/json
+ */
+router.get('/', checkAgentAccess, v1.getListAgents);
+
+/**
+ * Uploads and updates an avatar for a specific agent.
+ * @route POST /agents/:agent_id/avatar
+ * @param {string} req.params.agent_id - The ID of the agent.
+ * @param {Express.Multer.File} req.file - The avatar image file.
+ * @param {string} [req.body.metadata] - Optional metadata for the agent's avatar.
+ * @returns {Object} 200 - success response - application/json
+ */
+avatar.post(
+ '/:agent_id/avatar/',
+ checkAgentAccess,
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'agent_id',
+ }),
+ v1.uploadAgentAvatar,
+);
+
+module.exports = { v1: router, avatar };
diff --git a/api/server/routes/apiKeys.js b/api/server/routes/apiKeys.js
new file mode 100644
index 0000000..ee11a8b
--- /dev/null
+++ b/api/server/routes/apiKeys.js
@@ -0,0 +1,36 @@
+const express = require('express');
+const { generateCheckAccess, createApiKeyHandlers } = require('@librechat/api');
+const { PermissionTypes, Permissions } = require('librechat-data-provider');
+const {
+ getAgentApiKeyById,
+ createAgentApiKey,
+ deleteAgentApiKey,
+ listAgentApiKeys,
+ getRoleByName,
+} = require('~/models');
+const { requireJwtAuth } = require('~/server/middleware');
+
+const router = express.Router();
+
+const handlers = createApiKeyHandlers({
+ createAgentApiKey,
+ listAgentApiKeys,
+ deleteAgentApiKey,
+ getAgentApiKeyById,
+});
+
+const checkRemoteAgentsUse = generateCheckAccess({
+ permissionType: PermissionTypes.REMOTE_AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+});
+
+router.post('/', requireJwtAuth, checkRemoteAgentsUse, handlers.createApiKey);
+
+router.get('/', requireJwtAuth, checkRemoteAgentsUse, handlers.listApiKeys);
+
+router.get('/:id', requireJwtAuth, checkRemoteAgentsUse, handlers.getApiKey);
+
+router.delete('/:id', requireJwtAuth, checkRemoteAgentsUse, handlers.deleteApiKey);
+
+module.exports = router;
diff --git a/api/server/routes/assistants/actions.js b/api/server/routes/assistants/actions.js
new file mode 100644
index 0000000..7d35cc6
--- /dev/null
+++ b/api/server/routes/assistants/actions.js
@@ -0,0 +1,236 @@
+const express = require('express');
+const { nanoid } = require('nanoid');
+const { logger } = require('@librechat/data-schemas');
+const { isActionDomainAllowed, validateActionOAuthMetadata } = require('@librechat/api');
+const { actionDelimiter, EModelEndpoint, removeNullishValues } = require('librechat-data-provider');
+const {
+ legacyDomainEncode,
+ encryptMetadata,
+ domainParser,
+} = require('~/server/services/ActionService');
+const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
+const db = require('~/models');
+
+const router = express.Router();
+
+/**
+ * Adds or updates actions for a specific assistant.
+ * @route POST /actions/:assistant_id
+ * @param {string} req.params.assistant_id - The ID of the assistant.
+ * @param {FunctionTool[]} req.body.functions - The functions to be added or updated.
+ * @param {string} [req.body.action_id] - Optional ID for the action.
+ * @param {ActionMetadata} req.body.metadata - Metadata for the action.
+ * @returns {Object} 200 - success response - application/json
+ */
+router.post('/:assistant_id', async (req, res) => {
+ try {
+ const appConfig = req.config;
+ const { assistant_id } = req.params;
+
+ /** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */
+ const { functions, action_id: _action_id, metadata: _metadata } = req.body;
+ if (!functions.length) {
+ return res.status(400).json({ message: 'No functions provided' });
+ }
+
+ let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
+ const isDomainAllowed = await isActionDomainAllowed(
+ metadata.domain,
+ appConfig?.actions?.allowedDomains,
+ appConfig?.actions?.allowedAddresses,
+ );
+ if (!isDomainAllowed) {
+ return res.status(400).json({ message: 'Domain not allowed' });
+ }
+
+ const encodedDomain = await domainParser(metadata.domain, true);
+
+ if (!encodedDomain) {
+ return res.status(400).json({ message: 'No domain provided' });
+ }
+
+ const legacyDomain = legacyDomainEncode(metadata.domain);
+
+ const action_id = _action_id ?? nanoid();
+ const initialPromises = [];
+
+ const { openai } = await getOpenAIClient({ req, res });
+
+ initialPromises.push(db.getAssistant({ assistant_id }));
+ initialPromises.push(openai.beta.assistants.retrieve(assistant_id));
+ !!_action_id && initialPromises.push(db.getActions({ action_id }, true));
+
+ /** @type {[AssistantDocument, Assistant, [Action|undefined]]} */
+ const [assistant_data, assistant, actions_result] = await Promise.all(initialPromises);
+
+ if (actions_result && actions_result.length) {
+ const action = actions_result[0];
+ if (action.assistant_id !== assistant_id) {
+ return res.status(403).json({ message: 'Action does not belong to this assistant' });
+ }
+ metadata = { ...action.metadata, ...metadata };
+ }
+
+ try {
+ await validateActionOAuthMetadata(metadata.auth, appConfig?.actions?.allowedAddresses);
+ } catch (error) {
+ return res.status(400).json({ message: error.message });
+ }
+
+ if (!assistant) {
+ return res.status(404).json({ message: 'Assistant not found' });
+ }
+
+ const { actions: _actions = [], user: assistant_user } = assistant_data ?? {};
+ const actions = [];
+ for (const action of _actions) {
+ const [_action_domain, current_action_id] = action.split(actionDelimiter);
+ if (current_action_id === action_id) {
+ continue;
+ }
+
+ actions.push(action);
+ }
+
+ actions.push(`${encodedDomain}${actionDelimiter}${action_id}`);
+
+ /** @type {{ tools: FunctionTool[] | { type: 'code_interpreter'|'retrieval'}[]}} */
+ const { tools: _tools = [] } = assistant;
+
+ const shouldRemoveAssistantTool = (tool) => {
+ if (!tool.function) {
+ return false;
+ }
+ const name = tool.function.name;
+ return (
+ name.includes(encodedDomain) || name.includes(legacyDomain) || name.includes(action_id)
+ );
+ };
+
+ const tools = _tools
+ .filter((tool) => !shouldRemoveAssistantTool(tool))
+ .concat(
+ functions.map((tool) => ({
+ ...tool,
+ function: {
+ ...tool.function,
+ name: `${tool.function.name}${actionDelimiter}${encodedDomain}`,
+ },
+ })),
+ );
+
+ let updatedAssistant = await openai.beta.assistants.update(assistant_id, { tools });
+ const promises = [];
+
+ // Only update user field for new assistant documents
+ const assistantUpdateData = { actions };
+ if (!assistant_data) {
+ assistantUpdateData.user = req.user.id;
+ }
+ promises.push(db.updateAssistantDoc({ assistant_id }, assistantUpdateData));
+
+ // Only update user field for new actions
+ const actionUpdateData = { metadata, assistant_id };
+ if (!actions_result || !actions_result.length) {
+ // For new actions, use the assistant owner's user ID
+ actionUpdateData.user = assistant_user || req.user.id;
+ }
+ promises.push(db.updateAction({ action_id, assistant_id }, actionUpdateData));
+
+ /** @type {[AssistantDocument, Action]} */
+ let [assistantDocument, updatedAction] = await Promise.all(promises);
+ const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
+ for (let field of sensitiveFields) {
+ if (updatedAction.metadata[field]) {
+ delete updatedAction.metadata[field];
+ }
+ }
+
+ /* Map Azure OpenAI model to the assistant as defined by config */
+ if (appConfig.endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) {
+ updatedAssistant = {
+ ...updatedAssistant,
+ model: req.body.model,
+ };
+ }
+
+ res.json([assistantDocument, updatedAssistant, updatedAction]);
+ } catch (error) {
+ const message = 'Trouble updating the Assistant Action';
+ logger.error(message, error);
+ res.status(500).json({ message });
+ }
+});
+
+/**
+ * Deletes an action for a specific assistant.
+ * @route DELETE /actions/:assistant_id/:action_id
+ * @param {string} req.params.assistant_id - The ID of the assistant.
+ * @param {string} req.params.action_id - The ID of the action to delete.
+ * @returns {Object} 200 - success response - application/json
+ */
+router.delete('/:assistant_id/:action_id/:model', async (req, res) => {
+ try {
+ const { assistant_id, action_id, model } = req.params;
+ req.body = req.body || {}; // Express 5: ensure req.body exists
+ req.body.model = model;
+ const { openai } = await getOpenAIClient({ req, res });
+
+ const initialPromises = [];
+ initialPromises.push(db.getAssistant({ assistant_id }));
+ initialPromises.push(openai.beta.assistants.retrieve(assistant_id));
+
+ /** @type {[AssistantDocument, Assistant]} */
+ const [assistant_data, assistant] = await Promise.all(initialPromises);
+
+ const { actions = [] } = assistant_data ?? {};
+ const { tools = [] } = assistant ?? {};
+
+ let storedDomain = '';
+ const updatedActions = actions.filter((action) => {
+ if (action.includes(action_id)) {
+ [storedDomain] = action.split(actionDelimiter);
+ return false;
+ }
+ return true;
+ });
+
+ if (!storedDomain) {
+ return res.status(400).json({ message: 'No domain provided' });
+ }
+
+ const updatedTools = tools.filter(
+ (tool) =>
+ !(
+ tool.function &&
+ (tool.function.name.includes(storedDomain) || tool.function.name.includes(action_id))
+ ),
+ );
+
+ await openai.beta.assistants.update(assistant_id, { tools: updatedTools });
+
+ const promises = [];
+ // Only update user field if assistant document doesn't exist
+ const assistantUpdateData = { actions: updatedActions };
+ if (!assistant_data) {
+ assistantUpdateData.user = req.user.id;
+ }
+ promises.push(db.updateAssistantDoc({ assistant_id }, assistantUpdateData));
+ promises.push(db.deleteAction({ action_id, assistant_id }));
+
+ const [, deletedAction] = await Promise.all(promises);
+ if (!deletedAction) {
+ logger.warn('[Assistant Action Delete] No matching action document found', {
+ action_id,
+ assistant_id,
+ });
+ }
+ res.status(200).json({ message: 'Action deleted successfully' });
+ } catch (error) {
+ const message = 'Trouble deleting the Assistant Action';
+ logger.error(message, error);
+ res.status(500).json({ message });
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/assistants/chatV1.js b/api/server/routes/assistants/chatV1.js
new file mode 100644
index 0000000..67bfc00
--- /dev/null
+++ b/api/server/routes/assistants/chatV1.js
@@ -0,0 +1,34 @@
+const express = require('express');
+
+const router = express.Router();
+const {
+ setHeaders,
+ handleAbort,
+ validateModel,
+ buildEndpointOption,
+} = require('~/server/middleware');
+const validateConvoAccess = require('~/server/middleware/validate/convoAccess');
+const validateAssistant = require('~/server/middleware/assistants/validate');
+const chatController = require('~/server/controllers/assistants/chatV1');
+
+router.post('/abort', handleAbort());
+
+/**
+ * @route POST /
+ * @desc Chat with an assistant
+ * @access Public
+ * @param {express.Request} req - The request object, containing the request data.
+ * @param {express.Response} res - The response object, used to send back a response.
+ * @returns {void}
+ */
+router.post(
+ '/',
+ validateModel,
+ buildEndpointOption,
+ validateAssistant,
+ validateConvoAccess,
+ setHeaders,
+ chatController,
+);
+
+module.exports = router;
diff --git a/api/server/routes/assistants/chatV2.js b/api/server/routes/assistants/chatV2.js
new file mode 100644
index 0000000..4612743
--- /dev/null
+++ b/api/server/routes/assistants/chatV2.js
@@ -0,0 +1,34 @@
+const express = require('express');
+
+const router = express.Router();
+const {
+ setHeaders,
+ handleAbort,
+ validateModel,
+ buildEndpointOption,
+} = require('~/server/middleware');
+const validateConvoAccess = require('~/server/middleware/validate/convoAccess');
+const validateAssistant = require('~/server/middleware/assistants/validate');
+const chatController = require('~/server/controllers/assistants/chatV2');
+
+router.post('/abort', handleAbort());
+
+/**
+ * @route POST /
+ * @desc Chat with an assistant
+ * @access Public
+ * @param {express.Request} req - The request object, containing the request data.
+ * @param {express.Response} res - The response object, used to send back a response.
+ * @returns {void}
+ */
+router.post(
+ '/',
+ validateModel,
+ buildEndpointOption,
+ validateAssistant,
+ validateConvoAccess,
+ setHeaders,
+ chatController,
+);
+
+module.exports = router;
diff --git a/api/server/routes/assistants/documents.js b/api/server/routes/assistants/documents.js
new file mode 100644
index 0000000..72a81d8
--- /dev/null
+++ b/api/server/routes/assistants/documents.js
@@ -0,0 +1,13 @@
+const express = require('express');
+const controllers = require('~/server/controllers/assistants/v1');
+
+const router = express.Router();
+
+/**
+ * Returns a list of the user's assistant documents (metadata saved to database).
+ * @route GET /assistants/documents
+ * @returns {AssistantDocument[]} 200 - success response - application/json
+ */
+router.get('/', controllers.getAssistantDocuments);
+
+module.exports = router;
diff --git a/api/server/routes/assistants/index.js b/api/server/routes/assistants/index.js
new file mode 100644
index 0000000..6251f39
--- /dev/null
+++ b/api/server/routes/assistants/index.js
@@ -0,0 +1,19 @@
+const express = require('express');
+const { uaParser, checkBan, requireJwtAuth, configMiddleware } = require('~/server/middleware');
+const router = express.Router();
+
+const { v1 } = require('./v1');
+const chatV1 = require('./chatV1');
+const v2 = require('./v2');
+const chatV2 = require('./chatV2');
+
+router.use(requireJwtAuth);
+router.use(checkBan);
+router.use(uaParser);
+router.use(configMiddleware);
+router.use('/v1/', v1);
+router.use('/v1/chat', chatV1);
+router.use('/v2/', v2);
+router.use('/v2/chat', chatV2);
+
+module.exports = router;
diff --git a/api/server/routes/assistants/tools.js b/api/server/routes/assistants/tools.js
new file mode 100644
index 0000000..324b620
--- /dev/null
+++ b/api/server/routes/assistants/tools.js
@@ -0,0 +1,8 @@
+const express = require('express');
+const { getAvailableTools } = require('~/server/controllers/PluginController');
+
+const router = express.Router();
+
+router.get('/', getAvailableTools);
+
+module.exports = router;
diff --git a/api/server/routes/assistants/v1.js b/api/server/routes/assistants/v1.js
new file mode 100644
index 0000000..544a48f
--- /dev/null
+++ b/api/server/routes/assistants/v1.js
@@ -0,0 +1,81 @@
+const express = require('express');
+const controllers = require('~/server/controllers/assistants/v1');
+const documents = require('./documents');
+const actions = require('./actions');
+const tools = require('./tools');
+
+const router = express.Router();
+const avatar = express.Router();
+
+/**
+ * Assistant actions route.
+ * @route GET|POST /assistants/actions
+ */
+router.use('/actions', actions);
+
+/**
+ * Create an assistant.
+ * @route GET /assistants/tools
+ * @returns {TPlugin[]} 200 - application/json
+ */
+router.use('/tools', tools);
+
+/**
+ * Create an assistant.
+ * @route GET /assistants/documents
+ * @returns {AssistantDocument[]} 200 - application/json
+ */
+router.use('/documents', documents);
+
+/**
+ * Create an assistant.
+ * @route POST /assistants
+ * @param {AssistantCreateParams} req.body - The assistant creation parameters.
+ * @returns {Assistant} 201 - success response - application/json
+ */
+router.post('/', controllers.createAssistant);
+
+/**
+ * Retrieves an assistant.
+ * @route GET /assistants/:id
+ * @param {string} req.params.id - Assistant identifier.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+router.get('/:id', controllers.retrieveAssistant);
+
+/**
+ * Modifies an assistant.
+ * @route PATCH /assistants/:id
+ * @param {string} req.params.id - Assistant identifier.
+ * @param {AssistantUpdateParams} req.body - The assistant update parameters.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+router.patch('/:id', controllers.patchAssistant);
+
+/**
+ * Deletes an assistant.
+ * @route DELETE /assistants/:id
+ * @param {string} req.params.id - Assistant identifier.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+router.delete('/:id', controllers.deleteAssistant);
+
+/**
+ * Returns a list of assistants.
+ * @route GET /assistants
+ * @param {AssistantListParams} req.query - The assistant list parameters for pagination and sorting.
+ * @returns {AssistantListResponse} 200 - success response - application/json
+ */
+router.get('/', controllers.listAssistants);
+
+/**
+ * Uploads and updates an avatar for a specific assistant.
+ * @route POST /assistants/:assistant_id/avatar/
+ * @param {string} req.params.assistant_id - The ID of the assistant.
+ * @param {Express.Multer.File} req.file - The avatar image file.
+ * @param {string} [req.body.metadata] - Optional metadata for the assistant's avatar.
+ * @returns {Object} 200 - success response - application/json
+ */
+avatar.post('/:assistant_id/avatar/', controllers.uploadAssistantAvatar);
+
+module.exports = { v1: router, avatar };
diff --git a/api/server/routes/assistants/v2.js b/api/server/routes/assistants/v2.js
new file mode 100644
index 0000000..3037256
--- /dev/null
+++ b/api/server/routes/assistants/v2.js
@@ -0,0 +1,83 @@
+const express = require('express');
+const { configMiddleware } = require('~/server/middleware');
+const v1 = require('~/server/controllers/assistants/v1');
+const v2 = require('~/server/controllers/assistants/v2');
+const documents = require('./documents');
+const actions = require('./actions');
+const tools = require('./tools');
+
+const router = express.Router();
+router.use(configMiddleware);
+
+/**
+ * Assistant actions route.
+ * @route GET|POST /assistants/actions
+ */
+router.use('/actions', actions);
+
+/**
+ * Create an assistant.
+ * @route GET /assistants/tools
+ * @returns {TPlugin[]} 200 - application/json
+ */
+router.use('/tools', tools);
+
+/**
+ * Create an assistant.
+ * @route GET /assistants/documents
+ * @returns {AssistantDocument[]} 200 - application/json
+ */
+router.use('/documents', documents);
+
+/**
+ * Create an assistant.
+ * @route POST /assistants
+ * @param {AssistantCreateParams} req.body - The assistant creation parameters.
+ * @returns {Assistant} 201 - success response - application/json
+ */
+router.post('/', v2.createAssistant);
+
+/**
+ * Retrieves an assistant.
+ * @route GET /assistants/:id
+ * @param {string} req.params.id - Assistant identifier.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+router.get('/:id', v1.retrieveAssistant);
+
+/**
+ * Modifies an assistant.
+ * @route PATCH /assistants/:id
+ * @param {string} req.params.id - Assistant identifier.
+ * @param {AssistantUpdateParams} req.body - The assistant update parameters.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+router.patch('/:id', v2.patchAssistant);
+
+/**
+ * Deletes an assistant.
+ * @route DELETE /assistants/:id
+ * @param {string} req.params.id - Assistant identifier.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+router.delete('/:id', v1.deleteAssistant);
+
+/**
+ * Returns a list of assistants.
+ * @route GET /assistants
+ * @param {AssistantListParams} req.query - The assistant list parameters for pagination and sorting.
+ * @returns {AssistantListResponse} 200 - success response - application/json
+ */
+router.get('/', v1.listAssistants);
+
+/**
+ * Uploads and updates an avatar for a specific assistant.
+ * @route POST /avatar/:assistant_id
+ * @param {string} req.params.assistant_id - The ID of the assistant.
+ * @param {Express.Multer.File} req.file - The avatar image file.
+ * @param {string} [req.body.metadata] - Optional metadata for the assistant's avatar.
+ * @returns {Object} 200 - success response - application/json
+ */
+router.post('/avatar/:assistant_id', v1.uploadAssistantAvatar);
+
+module.exports = router;
diff --git a/api/server/routes/auth.2fa-ratelimit.test.js b/api/server/routes/auth.2fa-ratelimit.test.js
new file mode 100644
index 0000000..3d11189
--- /dev/null
+++ b/api/server/routes/auth.2fa-ratelimit.test.js
@@ -0,0 +1,121 @@
+const express = require('express');
+const request = require('supertest');
+
+const mockSetTwoFactorTempUser = jest.fn((req, res, next) => next());
+const mockTwoFactorTempLimiter = jest.fn((req, res, next) => next());
+const mockCheckBan = jest.fn((req, res, next) => next());
+const mockVerify2FAWithTempToken = jest.fn((req, res) => res.status(204).end());
+
+jest.mock('@librechat/api', () => ({
+ createSetBalanceConfig: jest.fn(() => (req, res, next) => next()),
+ forceRefreshCloudFrontAuthCookies: jest.fn(),
+}));
+
+jest.mock('~/server/controllers/AuthController', () => ({
+ refreshController: jest.fn((req, res) => res.status(204).end()),
+ registrationController: jest.fn((req, res) => res.status(204).end()),
+ resetPasswordController: jest.fn((req, res) => res.status(204).end()),
+ resetPasswordRequestController: jest.fn((req, res) => res.status(204).end()),
+ graphTokenController: jest.fn((req, res) => res.status(204).end()),
+}));
+
+jest.mock('~/server/controllers/TwoFactorController', () => ({
+ enable2FA: jest.fn((req, res) => res.status(204).end()),
+ verify2FA: jest.fn((req, res) => res.status(204).end()),
+ confirm2FA: jest.fn((req, res) => res.status(204).end()),
+ disable2FA: jest.fn((req, res) => res.status(204).end()),
+ regenerateBackupCodes: jest.fn((req, res) => res.status(204).end()),
+}));
+
+jest.mock('~/server/controllers/auth/TwoFactorAuthController', () => ({
+ verify2FAWithTempToken: (...args) => mockVerify2FAWithTempToken(...args),
+}));
+
+jest.mock('~/server/controllers/auth/LogoutController', () => ({
+ logoutController: jest.fn((req, res) => res.status(204).end()),
+}));
+
+jest.mock('~/server/controllers/auth/LoginController', () => ({
+ loginController: jest.fn((req, res) => res.status(204).end()),
+}));
+
+jest.mock('~/models', () => ({
+ findBalanceByUser: jest.fn(),
+ upsertBalanceFields: jest.fn(),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn(),
+}));
+
+jest.mock('~/server/middleware', () => {
+ const pass = (req, res, next) => next();
+ return {
+ logHeaders: pass,
+ loginLimiter: pass,
+ setTwoFactorTempUser: (...args) => mockSetTwoFactorTempUser(...args),
+ twoFactorTempLimiter: (...args) => mockTwoFactorTempLimiter(...args),
+ checkBan: (...args) => mockCheckBan(...args),
+ requireLocalAuth: pass,
+ requireLdapAuth: pass,
+ registerLimiter: pass,
+ checkInviteUser: pass,
+ validateRegistration: pass,
+ resetPasswordLimiter: pass,
+ resetPasswordSubmissionLimiter: pass,
+ validatePasswordReset: pass,
+ requireJwtAuth: pass,
+ };
+});
+
+const authRouter = require('./auth');
+
+describe('POST /api/auth/2fa/verify-temp rate limiting', () => {
+ let app;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockSetTwoFactorTempUser.mockImplementation((req, res, next) => next());
+ mockTwoFactorTempLimiter.mockImplementation((req, res, next) => next());
+ mockCheckBan.mockImplementation((req, res, next) => next());
+ mockVerify2FAWithTempToken.mockImplementation((req, res) => res.status(204).end());
+
+ app = express();
+ app.use(express.json());
+ app.use('/api/auth', authRouter);
+ });
+
+ it('sets the temp user before limiting, checking bans, and verifying temp 2FA tokens', async () => {
+ await request(app).post('/api/auth/2fa/verify-temp').send({ token: '123456' }).expect(204);
+
+ expect(mockSetTwoFactorTempUser).toHaveBeenCalledTimes(1);
+ expect(mockTwoFactorTempLimiter).toHaveBeenCalledTimes(1);
+ expect(mockCheckBan).toHaveBeenCalledTimes(1);
+ expect(mockVerify2FAWithTempToken).toHaveBeenCalledTimes(1);
+ expect(mockSetTwoFactorTempUser.mock.invocationCallOrder[0]).toBeLessThan(
+ mockTwoFactorTempLimiter.mock.invocationCallOrder[0],
+ );
+ expect(mockTwoFactorTempLimiter.mock.invocationCallOrder[0]).toBeLessThan(
+ mockCheckBan.mock.invocationCallOrder[0],
+ );
+ expect(mockCheckBan.mock.invocationCallOrder[0]).toBeLessThan(
+ mockVerify2FAWithTempToken.mock.invocationCallOrder[0],
+ );
+ });
+
+ it('does not verify the temp 2FA token after the limiter rejects the request', async () => {
+ mockTwoFactorTempLimiter.mockImplementation((req, res) =>
+ res.status(429).json({ message: 'Too many verification attempts' }),
+ );
+
+ const response = await request(app)
+ .post('/api/auth/2fa/verify-temp')
+ .send({ token: '123456' })
+ .expect(429);
+
+ expect(response.body).toEqual({ message: 'Too many verification attempts' });
+ expect(mockSetTwoFactorTempUser).toHaveBeenCalledTimes(1);
+ expect(mockCheckBan).not.toHaveBeenCalled();
+ expect(mockVerify2FAWithTempToken).not.toHaveBeenCalled();
+ });
+});
diff --git a/api/server/routes/auth.cloudfront.test.js b/api/server/routes/auth.cloudfront.test.js
new file mode 100644
index 0000000..7456501
--- /dev/null
+++ b/api/server/routes/auth.cloudfront.test.js
@@ -0,0 +1,157 @@
+const express = require('express');
+const request = require('supertest');
+
+const mockForceRefreshCloudFrontAuthCookies = jest.fn();
+
+jest.mock('@librechat/api', () => ({
+ createSetBalanceConfig: jest.fn(() => (req, res, next) => next()),
+ forceRefreshCloudFrontAuthCookies: (...args) => mockForceRefreshCloudFrontAuthCookies(...args),
+}));
+
+jest.mock('~/server/controllers/AuthController', () => ({
+ refreshController: jest.fn((req, res) => res.status(200).end()),
+ registrationController: jest.fn((req, res) => res.status(200).end()),
+ resetPasswordController: jest.fn((req, res) => res.status(200).end()),
+ resetPasswordRequestController: jest.fn((req, res) => res.status(200).end()),
+ graphTokenController: jest.fn((req, res) => res.status(200).end()),
+}));
+
+jest.mock('~/server/controllers/TwoFactorController', () => ({
+ enable2FA: jest.fn((req, res) => res.status(200).end()),
+ verify2FA: jest.fn((req, res) => res.status(200).end()),
+ confirm2FA: jest.fn((req, res) => res.status(200).end()),
+ disable2FA: jest.fn((req, res) => res.status(200).end()),
+ regenerateBackupCodes: jest.fn((req, res) => res.status(200).end()),
+}));
+
+jest.mock('~/server/controllers/auth/TwoFactorAuthController', () => ({
+ verify2FAWithTempToken: jest.fn((req, res) => res.status(200).end()),
+}));
+
+jest.mock('~/server/controllers/auth/LogoutController', () => ({
+ logoutController: jest.fn((req, res) => res.status(200).end()),
+}));
+
+jest.mock('~/server/controllers/auth/LoginController', () => ({
+ loginController: jest.fn((req, res) => res.status(200).end()),
+}));
+
+jest.mock('~/models', () => ({
+ findBalanceByUser: jest.fn(),
+ upsertBalanceFields: jest.fn(),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn(),
+}));
+
+jest.mock('~/server/middleware', () => {
+ const pass = (req, res, next) => next();
+ return {
+ logHeaders: pass,
+ loginLimiter: pass,
+ setTwoFactorTempUser: pass,
+ twoFactorTempLimiter: pass,
+ checkBan: pass,
+ requireLocalAuth: pass,
+ requireLdapAuth: pass,
+ registerLimiter: pass,
+ checkInviteUser: pass,
+ validateRegistration: pass,
+ resetPasswordLimiter: pass,
+ resetPasswordSubmissionLimiter: pass,
+ validatePasswordReset: pass,
+ requireJwtAuth: jest.fn((req, res, next) => {
+ if (req.headers.authorization !== 'Bearer ok') {
+ return res.status(401).json({ message: 'Unauthorized' });
+ }
+ req.user = { _id: 'user123', tenantId: 'tenantA' };
+ if (req.headers['x-cloudfront-warmed'] === 'true') {
+ req.cloudFrontAuthCookieRefreshResult = {
+ enabled: true,
+ attempted: true,
+ refreshed: true,
+ expiresInSec: 1800,
+ refreshAfterSec: 1500,
+ };
+ }
+ return next();
+ }),
+ };
+});
+
+const authRouter = require('./auth');
+
+describe('POST /api/auth/cloudfront/refresh', () => {
+ let app;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ app = express();
+ app.use(express.json());
+ app.use('/api/auth', authRouter);
+ });
+
+ it('requires authentication', async () => {
+ await request(app).post('/api/auth/cloudfront/refresh').expect(401);
+
+ expect(mockForceRefreshCloudFrontAuthCookies).not.toHaveBeenCalled();
+ });
+
+ it('returns 404 when CloudFront cookie mode is disabled', async () => {
+ mockForceRefreshCloudFrontAuthCookies.mockReturnValue({
+ enabled: false,
+ attempted: false,
+ refreshed: false,
+ reason: 'cloudfront_disabled',
+ });
+
+ const response = await request(app)
+ .post('/api/auth/cloudfront/refresh')
+ .set('Authorization', 'Bearer ok')
+ .expect(404);
+
+ expect(response.status).toBe(404);
+ });
+
+ it('returns cookie refresh timing when CloudFront cookies are refreshed', async () => {
+ mockForceRefreshCloudFrontAuthCookies.mockReturnValue({
+ enabled: true,
+ attempted: true,
+ refreshed: true,
+ expiresInSec: 1800,
+ refreshAfterSec: 1500,
+ });
+
+ const response = await request(app)
+ .post('/api/auth/cloudfront/refresh')
+ .set('Authorization', 'Bearer ok')
+ .expect(200);
+
+ expect(response.body).toEqual({
+ ok: true,
+ expiresInSec: 1800,
+ refreshAfterSec: 1500,
+ });
+ expect(mockForceRefreshCloudFrontAuthCookies).toHaveBeenCalledWith(
+ expect.objectContaining({ user: { _id: 'user123', tenantId: 'tenantA' } }),
+ expect.any(Object),
+ { _id: 'user123', tenantId: 'tenantA' },
+ );
+ });
+
+ it('reuses the auth middleware refresh result instead of minting cookies twice', async () => {
+ const response = await request(app)
+ .post('/api/auth/cloudfront/refresh')
+ .set('Authorization', 'Bearer ok')
+ .set('x-cloudfront-warmed', 'true')
+ .expect(200);
+
+ expect(response.body).toEqual({
+ ok: true,
+ expiresInSec: 1800,
+ refreshAfterSec: 1500,
+ });
+ expect(mockForceRefreshCloudFrontAuthCookies).not.toHaveBeenCalled();
+ });
+});
diff --git a/api/server/routes/auth.js b/api/server/routes/auth.js
new file mode 100644
index 0000000..191c1d3
--- /dev/null
+++ b/api/server/routes/auth.js
@@ -0,0 +1,104 @@
+const express = require('express');
+const { createSetBalanceConfig, forceRefreshCloudFrontAuthCookies } = require('@librechat/api');
+const {
+ resetPasswordRequestController,
+ resetPasswordController,
+ registrationController,
+ graphTokenController,
+ refreshController,
+} = require('~/server/controllers/AuthController');
+const {
+ regenerateBackupCodes,
+ disable2FA,
+ confirm2FA,
+ enable2FA,
+ verify2FA,
+} = require('~/server/controllers/TwoFactorController');
+const { verify2FAWithTempToken } = require('~/server/controllers/auth/TwoFactorAuthController');
+const { logoutController } = require('~/server/controllers/auth/LogoutController');
+const { loginController } = require('~/server/controllers/auth/LoginController');
+const { findBalanceByUser, upsertBalanceFields } = require('~/models');
+const { getAppConfig } = require('~/server/services/Config');
+const middleware = require('~/server/middleware');
+
+const setBalanceConfig = createSetBalanceConfig({
+ getAppConfig,
+ findBalanceByUser,
+ upsertBalanceFields,
+});
+
+const router = express.Router();
+const getCloudFrontAuthCookieRefreshResult = (req, res) => {
+ const warmedResult = req.cloudFrontAuthCookieRefreshResult;
+ if (warmedResult && (warmedResult.attempted || !warmedResult.enabled)) {
+ return warmedResult;
+ }
+
+ return forceRefreshCloudFrontAuthCookies(req, res, req.user);
+};
+
+const ldapAuth = !!process.env.LDAP_URL && !!process.env.LDAP_USER_SEARCH_BASE;
+//Local
+router.post('/logout', middleware.requireJwtAuth, logoutController);
+router.post(
+ '/login',
+ middleware.logHeaders,
+ middleware.loginLimiter,
+ middleware.checkBan,
+ ldapAuth ? middleware.requireLdapAuth : middleware.requireLocalAuth,
+ setBalanceConfig,
+ loginController,
+);
+router.post('/refresh', refreshController);
+router.post('/cloudfront/refresh', middleware.requireJwtAuth, (req, res) => {
+ const result = getCloudFrontAuthCookieRefreshResult(req, res);
+ if (!result.enabled) {
+ return res.sendStatus(404);
+ }
+
+ const status = result.refreshed ? 200 : 500;
+ return res.status(status).json({
+ ok: result.refreshed,
+ expiresInSec: result.expiresInSec,
+ refreshAfterSec: result.refreshAfterSec,
+ });
+});
+router.post(
+ '/register',
+ middleware.registerLimiter,
+ middleware.checkBan,
+ middleware.checkInviteUser,
+ middleware.validateRegistration,
+ registrationController,
+);
+router.post(
+ '/requestPasswordReset',
+ middleware.resetPasswordLimiter,
+ middleware.checkBan,
+ middleware.validatePasswordReset,
+ resetPasswordRequestController,
+);
+router.post(
+ '/resetPassword',
+ middleware.resetPasswordSubmissionLimiter,
+ middleware.checkBan,
+ middleware.validatePasswordReset,
+ resetPasswordController,
+);
+
+router.post('/2fa/enable', middleware.requireJwtAuth, enable2FA);
+router.post('/2fa/verify', middleware.requireJwtAuth, verify2FA);
+router.post(
+ '/2fa/verify-temp',
+ middleware.setTwoFactorTempUser,
+ middleware.twoFactorTempLimiter,
+ middleware.checkBan,
+ verify2FAWithTempToken,
+);
+router.post('/2fa/confirm', middleware.requireJwtAuth, confirm2FA);
+router.post('/2fa/disable', middleware.requireJwtAuth, disable2FA);
+router.post('/2fa/backup/regenerate', middleware.requireJwtAuth, regenerateBackupCodes);
+
+router.get('/graph-token', middleware.requireJwtAuth, graphTokenController);
+
+module.exports = router;
diff --git a/api/server/routes/auth.reset-password-ratelimit.test.js b/api/server/routes/auth.reset-password-ratelimit.test.js
new file mode 100644
index 0000000..86ec583
--- /dev/null
+++ b/api/server/routes/auth.reset-password-ratelimit.test.js
@@ -0,0 +1,121 @@
+const express = require('express');
+const request = require('supertest');
+
+const mockCheckBan = jest.fn((req, res, next) => next());
+const mockResetPasswordSubmissionLimiter = jest.fn((req, res, next) => next());
+const mockValidatePasswordReset = jest.fn((req, res, next) => next());
+const mockResetPasswordController = jest.fn((req, res) => res.status(204).end());
+
+jest.mock('@librechat/api', () => ({
+ createSetBalanceConfig: jest.fn(() => (req, res, next) => next()),
+ forceRefreshCloudFrontAuthCookies: jest.fn(),
+}));
+
+jest.mock('~/server/controllers/AuthController', () => ({
+ refreshController: jest.fn((req, res) => res.status(204).end()),
+ registrationController: jest.fn((req, res) => res.status(204).end()),
+ resetPasswordController: (...args) => mockResetPasswordController(...args),
+ resetPasswordRequestController: jest.fn((req, res) => res.status(204).end()),
+ graphTokenController: jest.fn((req, res) => res.status(204).end()),
+}));
+
+jest.mock('~/server/controllers/TwoFactorController', () => ({
+ enable2FA: jest.fn((req, res) => res.status(204).end()),
+ verify2FA: jest.fn((req, res) => res.status(204).end()),
+ confirm2FA: jest.fn((req, res) => res.status(204).end()),
+ disable2FA: jest.fn((req, res) => res.status(204).end()),
+ regenerateBackupCodes: jest.fn((req, res) => res.status(204).end()),
+}));
+
+jest.mock('~/server/controllers/auth/TwoFactorAuthController', () => ({
+ verify2FAWithTempToken: jest.fn((req, res) => res.status(204).end()),
+}));
+
+jest.mock('~/server/controllers/auth/LogoutController', () => ({
+ logoutController: jest.fn((req, res) => res.status(204).end()),
+}));
+
+jest.mock('~/server/controllers/auth/LoginController', () => ({
+ loginController: jest.fn((req, res) => res.status(204).end()),
+}));
+
+jest.mock('~/models', () => ({
+ findBalanceByUser: jest.fn(),
+ upsertBalanceFields: jest.fn(),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn(),
+}));
+
+jest.mock('~/server/middleware', () => {
+ const pass = (req, res, next) => next();
+ return {
+ logHeaders: pass,
+ loginLimiter: pass,
+ setTwoFactorTempUser: pass,
+ twoFactorTempLimiter: pass,
+ checkBan: (...args) => mockCheckBan(...args),
+ requireLocalAuth: pass,
+ requireLdapAuth: pass,
+ registerLimiter: pass,
+ checkInviteUser: pass,
+ validateRegistration: pass,
+ resetPasswordLimiter: pass,
+ resetPasswordSubmissionLimiter: (...args) => mockResetPasswordSubmissionLimiter(...args),
+ validatePasswordReset: (...args) => mockValidatePasswordReset(...args),
+ requireJwtAuth: pass,
+ };
+});
+
+const authRouter = require('./auth');
+
+describe('POST /api/auth/resetPassword rate limiting', () => {
+ let app;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockCheckBan.mockImplementation((req, res, next) => next());
+ mockResetPasswordSubmissionLimiter.mockImplementation((req, res, next) => next());
+ mockValidatePasswordReset.mockImplementation((req, res, next) => next());
+ mockResetPasswordController.mockImplementation((req, res) => res.status(204).end());
+
+ app = express();
+ app.use(express.json());
+ app.use('/api/auth', authRouter);
+ });
+
+ it('limits before ban checks, validation, and password reset work', async () => {
+ await request(app).post('/api/auth/resetPassword').send({ token: 'token' }).expect(204);
+
+ expect(mockResetPasswordSubmissionLimiter).toHaveBeenCalledTimes(1);
+ expect(mockCheckBan).toHaveBeenCalledTimes(1);
+ expect(mockValidatePasswordReset).toHaveBeenCalledTimes(1);
+ expect(mockResetPasswordController).toHaveBeenCalledTimes(1);
+ expect(mockResetPasswordSubmissionLimiter.mock.invocationCallOrder[0]).toBeLessThan(
+ mockCheckBan.mock.invocationCallOrder[0],
+ );
+ expect(mockCheckBan.mock.invocationCallOrder[0]).toBeLessThan(
+ mockValidatePasswordReset.mock.invocationCallOrder[0],
+ );
+ expect(mockValidatePasswordReset.mock.invocationCallOrder[0]).toBeLessThan(
+ mockResetPasswordController.mock.invocationCallOrder[0],
+ );
+ });
+
+ it('does not validate or reset passwords after the limiter rejects the request', async () => {
+ mockResetPasswordSubmissionLimiter.mockImplementation((req, res) =>
+ res.status(429).json({ message: 'Too many password reset attempts' }),
+ );
+
+ const response = await request(app)
+ .post('/api/auth/resetPassword')
+ .send({ token: 'token' })
+ .expect(429);
+
+ expect(response.body).toEqual({ message: 'Too many password reset attempts' });
+ expect(mockCheckBan).not.toHaveBeenCalled();
+ expect(mockValidatePasswordReset).not.toHaveBeenCalled();
+ expect(mockResetPasswordController).not.toHaveBeenCalled();
+ });
+});
diff --git a/api/server/routes/balance.js b/api/server/routes/balance.js
new file mode 100644
index 0000000..7095824
--- /dev/null
+++ b/api/server/routes/balance.js
@@ -0,0 +1,17 @@
+const express = require('express');
+const { createSetBalanceConfig } = require('@librechat/api');
+const router = express.Router();
+const controller = require('../controllers/Balance');
+const { requireJwtAuth } = require('../middleware/');
+const { findBalanceByUser, upsertBalanceFields } = require('~/models');
+const { getAppConfig } = require('~/server/services/Config');
+
+const setBalanceConfig = createSetBalanceConfig({
+ getAppConfig,
+ findBalanceByUser,
+ upsertBalanceFields,
+});
+
+router.get('/', requireJwtAuth, setBalanceConfig, controller);
+
+module.exports = router;
diff --git a/api/server/routes/banner.js b/api/server/routes/banner.js
new file mode 100644
index 0000000..ad949fd
--- /dev/null
+++ b/api/server/routes/banner.js
@@ -0,0 +1,17 @@
+const express = require('express');
+const { logger } = require('@librechat/data-schemas');
+const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth');
+const { getBanner } = require('~/models');
+
+const router = express.Router();
+
+router.get('/', optionalJwtAuth, async (req, res) => {
+ try {
+ res.status(200).send(await getBanner(req.user));
+ } catch (error) {
+ logger.error('[getBanner] Error getting banner', error);
+ res.status(500).json({ message: 'Error getting banner' });
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/categories.js b/api/server/routes/categories.js
new file mode 100644
index 0000000..612bc37
--- /dev/null
+++ b/api/server/routes/categories.js
@@ -0,0 +1,15 @@
+const express = require('express');
+const router = express.Router();
+const { requireJwtAuth } = require('~/server/middleware');
+const { getCategories } = require('~/models');
+
+router.get('/', requireJwtAuth, async (req, res) => {
+ try {
+ const categories = await getCategories();
+ res.status(200).send(categories);
+ } catch (error) {
+ res.status(500).send({ message: 'Failed to retrieve categories', error: error.message });
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/config.js b/api/server/routes/config.js
new file mode 100644
index 0000000..4078766
--- /dev/null
+++ b/api/server/routes/config.js
@@ -0,0 +1,315 @@
+const express = require('express');
+const {
+ isEnabled,
+ getBalanceConfig,
+ getCloudFrontConfig,
+ getAppConfigOptionsFromUser,
+ resolveBuildInfo,
+ resolveTitleTiming,
+ sanitizeModelSpecs,
+ excludeHiddenModelSpecs,
+ isFileSnapshotEnabled,
+} = require('@librechat/api');
+const { EModelEndpoint, defaultSocialLogins } = require('librechat-data-provider');
+const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas');
+const { hasCapability } = require('~/server/middleware/roles/capabilities');
+const { getLdapConfig } = require('~/server/services/Config/ldap');
+const { getRumConfig } = require('~/server/services/Config/rum');
+const { getAppConfig } = require('~/server/services/Config/app');
+
+const router = express.Router();
+const emailLoginEnabled =
+ process.env.ALLOW_EMAIL_LOGIN === undefined || isEnabled(process.env.ALLOW_EMAIL_LOGIN);
+const passwordResetEnabled = isEnabled(process.env.ALLOW_PASSWORD_RESET);
+
+const sharedLinksEnabled =
+ process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
+
+const publicSharedLinksEnabled =
+ sharedLinksEnabled && isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
+
+const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
+const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
+
+/**
+ * Resolve build metadata eagerly at module load so the first `/api/config`
+ * request does not pay the cost of `execFileSync('git', ...)` on the hot path.
+ * The resolver caches its result after the first call.
+ */
+resolveBuildInfo();
+
+function isBirthday() {
+ const today = new Date();
+ return today.getMonth() === 1 && today.getDate() === 11;
+}
+
+/**
+ * Pre-login fields rendered by the unauthenticated login, registration, password-reset,
+ * and email-verification pages. Any field added here is readable by anonymous callers
+ * of `GET /api/config`, so keep this set strictly to what those pages need.
+ *
+ * See client consumers under `client/src/components/Auth/` and `client/src/routes/Layouts/Startup.tsx`.
+ */
+function buildPreLoginPayload() {
+ const isOpenIdEnabled =
+ !!process.env.OPENID_CLIENT_ID &&
+ (isEnabled(process.env.OPENID_USE_PKCE) || !!process.env.OPENID_CLIENT_SECRET?.trim()) &&
+ !!process.env.OPENID_ISSUER &&
+ !!process.env.OPENID_SESSION_SECRET;
+
+ const isSamlEnabled =
+ !!process.env.SAML_ENTRY_POINT &&
+ !!process.env.SAML_ISSUER &&
+ !!process.env.SAML_CERT &&
+ !!process.env.SAML_SESSION_SECRET;
+
+ const ldap = getLdapConfig();
+
+ /** @type {Partial} */
+ const payload = {
+ appTitle: process.env.APP_TITLE || 'LibreChat',
+ discordLoginEnabled: !!process.env.DISCORD_CLIENT_ID && !!process.env.DISCORD_CLIENT_SECRET,
+ facebookLoginEnabled: !!process.env.FACEBOOK_CLIENT_ID && !!process.env.FACEBOOK_CLIENT_SECRET,
+ githubLoginEnabled: !!process.env.GITHUB_CLIENT_ID && !!process.env.GITHUB_CLIENT_SECRET,
+ googleLoginEnabled: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET,
+ appleLoginEnabled:
+ !!process.env.APPLE_CLIENT_ID &&
+ !!process.env.APPLE_TEAM_ID &&
+ !!process.env.APPLE_KEY_ID &&
+ !!process.env.APPLE_PRIVATE_KEY_PATH,
+ openidLoginEnabled: isOpenIdEnabled,
+ openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID',
+ openidImageUrl: process.env.OPENID_IMAGE_URL,
+ openidAutoRedirect: isEnabled(process.env.OPENID_AUTO_REDIRECT),
+ samlLoginEnabled: !isOpenIdEnabled && isSamlEnabled,
+ samlLabel: process.env.SAML_BUTTON_LABEL,
+ samlImageUrl: process.env.SAML_IMAGE_URL,
+ serverDomain: process.env.DOMAIN_SERVER || 'http://localhost:3080',
+ emailLoginEnabled,
+ registrationEnabled: !ldap?.enabled && isEnabled(process.env.ALLOW_REGISTRATION),
+ socialLoginEnabled: isEnabled(process.env.ALLOW_SOCIAL_LOGIN),
+ emailEnabled:
+ (!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
+ !!process.env.EMAIL_USERNAME &&
+ !!process.env.EMAIL_PASSWORD &&
+ !!process.env.EMAIL_FROM,
+ passwordResetEnabled,
+ };
+
+ const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);
+ if (minPasswordLength && !isNaN(minPasswordLength)) {
+ payload.minPasswordLength = minPasswordLength;
+ }
+
+ if (ldap) {
+ payload.ldap = ldap;
+ }
+
+ return payload;
+}
+
+/**
+ * Fields shared by authenticated chat and share-view config. Anonymous share
+ * views receive these through `/api/share/:shareId/config` after share access
+ * checks, not through the generic startup config endpoint.
+ */
+function buildPublicSharePayload() {
+ /** @type {Partial} */
+ const payload = {
+ analyticsGtmId: process.env.ANALYTICS_GTM_ID,
+ };
+
+ if (typeof process.env.CUSTOM_FOOTER === 'string') {
+ payload.customFooter = process.env.CUSTOM_FOOTER;
+ }
+
+ return payload;
+}
+
+/**
+ * Post-login fields appended only when `req.user` is present. These describe the
+ * authenticated UX (account-settings links, share-link feature flags, birthday icon,
+ * openid token-reuse marker) and are not needed on the pre-login screens, so they
+ * are not exposed to unauthenticated callers.
+ */
+function buildPostLoginPayload() {
+ /** @type {Partial} */
+ const payload = {
+ showBirthdayIcon:
+ isBirthday() ||
+ isEnabled(process.env.SHOW_BIRTHDAY_ICON) ||
+ process.env.SHOW_BIRTHDAY_ICON === '',
+ helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai',
+ sharedLinksEnabled,
+ publicSharedLinksEnabled,
+ openidReuseTokens,
+ /** Read inline (not module-level) for per-request evaluation and test isolation */
+ allowAccountDeletion:
+ process.env.ALLOW_ACCOUNT_DELETION === undefined ||
+ isEnabled(process.env.ALLOW_ACCOUNT_DELETION),
+ };
+
+ return payload;
+}
+
+function buildBuildInfoPayload(interfaceConfig) {
+ if (interfaceConfig?.buildInfo === false) {
+ return undefined;
+ }
+ const info = resolveBuildInfo();
+ if (!info.commit && !info.branch && !info.buildDate) {
+ return undefined;
+ }
+ return {
+ commit: info.commit,
+ commitShort: info.commitShort,
+ branch: info.branch,
+ buildDate: info.buildDate,
+ };
+}
+
+function buildWebSearchConfig(appConfig) {
+ const ws = appConfig?.webSearch;
+ if (!ws) {
+ return undefined;
+ }
+ const { searchProvider, scraperProvider, rerankerType } = ws;
+ if (!searchProvider && !scraperProvider && !rerankerType) {
+ return undefined;
+ }
+ return {
+ ...(searchProvider && { searchProvider }),
+ ...(scraperProvider && { scraperProvider }),
+ ...(rerankerType && { rerankerType }),
+ };
+}
+
+function buildCloudFrontStartupConfig() {
+ const config = getCloudFrontConfig();
+ if (
+ config?.imageSigning !== 'cookies' ||
+ !config.domain ||
+ !config.cookieDomain ||
+ !config.privateKey ||
+ !config.keyPairId
+ ) {
+ return undefined;
+ }
+
+ return {
+ cookieRefresh: {
+ endpoint: '/api/auth/cloudfront/refresh',
+ domain: config.domain,
+ },
+ };
+}
+
+router.get('/', async function (req, res) {
+ try {
+ const preLoginPayload = buildPreLoginPayload();
+ const publicSharePayload = buildPublicSharePayload();
+ const rum = getRumConfig();
+
+ if (!req.user) {
+ const tenantId = getTenantId();
+ const baseConfig = await getAppConfig(tenantId ? { tenantId } : { baseOnly: true });
+
+ /** @type {Partial} */
+ const payload = {
+ ...preLoginPayload,
+ socialLogins: baseConfig?.registration?.socialLogins ?? defaultSocialLogins,
+ turnstile: baseConfig?.turnstileConfig,
+ ...(rum ? { rum } : {}),
+ };
+
+ const interfaceConfig = baseConfig?.interfaceConfig;
+ const buildInfoDisabled = interfaceConfig?.buildInfo === false;
+ if (interfaceConfig?.privacyPolicy || interfaceConfig?.termsOfService || buildInfoDisabled) {
+ payload.interface = {};
+ if (interfaceConfig.privacyPolicy) {
+ payload.interface.privacyPolicy = interfaceConfig.privacyPolicy;
+ }
+ if (interfaceConfig.termsOfService) {
+ payload.interface.termsOfService = interfaceConfig.termsOfService;
+ }
+ if (buildInfoDisabled) {
+ payload.interface.buildInfo = false;
+ }
+ }
+
+ const unauthBuildInfo = buildBuildInfoPayload(interfaceConfig);
+ if (unauthBuildInfo) {
+ payload.buildInfo = unauthBuildInfo;
+ }
+
+ return res.status(200).send(payload);
+ }
+
+ const appConfig = await getAppConfig(getAppConfigOptionsFromUser(req.user));
+
+ const balanceConfig = getBalanceConfig(appConfig);
+ const cloudFront = buildCloudFrontStartupConfig();
+
+ /** @type {TStartupConfig} */
+ const payload = {
+ ...preLoginPayload,
+ ...publicSharePayload,
+ ...buildPostLoginPayload(),
+ sharedLinksSnapshotFilesEnabled: sharedLinksEnabled && isFileSnapshotEnabled(appConfig),
+ socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins,
+ interface: appConfig?.interfaceConfig,
+ titleGenerationTiming: resolveTitleTiming({
+ appConfig,
+ endpoint: EModelEndpoint.agents,
+ }),
+ turnstile: appConfig?.turnstileConfig,
+ modelSpecs: sanitizeModelSpecs(excludeHiddenModelSpecs(appConfig?.modelSpecs)),
+ balance: balanceConfig,
+ bundlerURL: process.env.SANDPACK_BUNDLER_URL,
+ staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL,
+ sharePointFilePickerEnabled,
+ sharePointBaseUrl: process.env.SHAREPOINT_BASE_URL,
+ sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
+ sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
+ conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
+ ? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
+ : 0,
+ ...(cloudFront ? { cloudFront } : {}),
+ ...(rum ? { rum } : {}),
+ };
+
+ const webSearch = buildWebSearchConfig(appConfig);
+ if (webSearch) {
+ payload.webSearch = webSearch;
+ }
+
+ const buildInfo = buildBuildInfoPayload(appConfig?.interfaceConfig);
+ if (buildInfo) {
+ payload.buildInfo = buildInfo;
+ }
+
+ if (!payload.allowAccountDeletion) {
+ try {
+ const userId = req.user.id ?? req.user._id?.toString();
+ if (userId) {
+ const canDelete = await hasCapability(
+ { id: userId, role: req.user.role ?? '', tenantId: req.user.tenantId },
+ SystemCapabilities.ACCESS_ADMIN,
+ );
+ if (canDelete) {
+ payload.allowAccountDeletion = true;
+ }
+ }
+ } catch (err) {
+ logger.warn(`[config] ACCESS_ADMIN capability check failed: ${err.message}`);
+ }
+ }
+
+ return res.status(200).send(payload);
+ } catch (err) {
+ logger.error('Error in startup config', err);
+ return res.status(500).send({ error: err.message });
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js
new file mode 100644
index 0000000..b38e73b
--- /dev/null
+++ b/api/server/routes/convos.js
@@ -0,0 +1,387 @@
+const multer = require('multer');
+const express = require('express');
+const { sleep } = require('@librechat/agents');
+const {
+ isEnabled,
+ deleteAgentCheckpoints,
+ resolveImportMaxFileSize,
+ restoreTenantContextFromReq,
+ deleteAllSharedLinksWithCleanup,
+ deleteConvoSharedLinksWithCleanup,
+} = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
+const {
+ createImportLimiters,
+ validateConvoAccess,
+ createForkLimiters,
+ configMiddleware,
+} = require('~/server/middleware');
+const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork');
+const { storage, importFileFilter } = require('~/server/routes/files/multer');
+const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
+const { importConversations } = require('~/server/utils/import');
+const getLogStores = require('~/cache/getLogStores');
+const db = require('~/models');
+
+const assistantClients = {
+ [EModelEndpoint.azureAssistants]: require('~/server/services/Endpoints/azureAssistants'),
+ [EModelEndpoint.assistants]: require('~/server/services/Endpoints/assistants'),
+};
+
+const router = express.Router();
+router.use(requireJwtAuth);
+
+const isValidProjectFilter = (projectId) =>
+ !projectId || projectId === 'unassigned' || /^[a-f\d]{24}$/i.test(projectId);
+
+router.get('/', async (req, res) => {
+ const limit = parseInt(req.query.limit, 10) || 25;
+ const cursor = req.query.cursor;
+ const isArchived = isEnabled(req.query.isArchived);
+ const search = req.query.search ? decodeURIComponent(req.query.search) : undefined;
+ const sortBy = req.query.sortBy || 'updatedAt';
+ const sortDirection = req.query.sortDirection || 'desc';
+ const projectId = Array.isArray(req.query.projectId)
+ ? req.query.projectId[0]
+ : req.query.projectId;
+
+ if (!isValidProjectFilter(projectId)) {
+ return res.status(400).json({ error: 'projectId must be a valid project id or unassigned' });
+ }
+
+ let tags;
+ if (req.query.tags) {
+ tags = Array.isArray(req.query.tags) ? req.query.tags : [req.query.tags];
+ }
+
+ try {
+ const result = await db.getConvosByCursor(req.user.id, {
+ cursor,
+ limit,
+ isArchived,
+ tags,
+ search,
+ sortBy,
+ sortDirection,
+ projectId,
+ });
+ res.status(200).json(result);
+ } catch (error) {
+ logger.error('Error fetching conversations', error);
+ res.status(500).json({ error: 'Error fetching conversations' });
+ }
+});
+
+router.get('/:conversationId', async (req, res) => {
+ const { conversationId } = req.params;
+ const convo = await db.getConvo(req.user.id, conversationId);
+
+ if (convo) {
+ res.status(200).json(convo);
+ } else {
+ res.status(404).end();
+ }
+});
+
+router.get('/gen_title/:conversationId', async (req, res) => {
+ const { conversationId } = req.params;
+ const titleCache = getLogStores(CacheKeys.GEN_TITLE);
+ const key = `${req.user.id}-${conversationId}`;
+ let title = await titleCache.get(key);
+
+ if (!title) {
+ // Exponential backoff: 500ms, 1s, 2s, 4s, 8s (total ~15.5s max wait)
+ const delays = [500, 1000, 2000, 4000, 8000];
+ for (const delay of delays) {
+ await sleep(delay);
+ title = await titleCache.get(key);
+ if (title) {
+ break;
+ }
+ }
+ }
+
+ if (title) {
+ await titleCache.delete(key);
+ res.status(200).json({ title });
+ } else {
+ res.status(404).json({
+ message: "Title not found or method not implemented for the conversation's endpoint",
+ });
+ }
+});
+
+router.delete('/', configMiddleware, async (req, res) => {
+ let filter = {};
+ const { conversationId, source, thread_id, endpoint } = req.body?.arg ?? {};
+
+ // Prevent deletion of all conversations
+ if (!conversationId && !source && !thread_id && !endpoint) {
+ return res.status(400).json({
+ error: 'no parameters provided',
+ });
+ }
+
+ if (conversationId) {
+ filter = { conversationId };
+ } else if (source === 'button') {
+ return res.status(200).send('No conversationId provided');
+ }
+
+ if (
+ typeof endpoint !== 'undefined' &&
+ Object.prototype.propertyIsEnumerable.call(assistantClients, endpoint)
+ ) {
+ /** @type {{ openai: OpenAI }} */
+ const { openai } = await assistantClients[endpoint].initializeClient({ req, res });
+ try {
+ const response = await openai.beta.threads.delete(thread_id);
+ logger.debug('Deleted OpenAI thread:', response);
+ } catch (error) {
+ logger.error('Error deleting OpenAI thread:', error);
+ }
+ }
+
+ try {
+ const dbResponse = await db.deleteConvos(req.user.id, filter);
+ // HITL: prune the deleted conversations' durable checkpoints — a paused run's
+ // checkpoint would otherwise persist until the Mongo TTL. Never throws.
+ await deleteAgentCheckpoints(
+ dbResponse.conversationIds,
+ req.config?.endpoints?.[EModelEndpoint.agents]?.checkpointer,
+ );
+ if (filter.conversationId) {
+ await db.deleteToolCalls(req.user.id, filter.conversationId);
+ await deleteConvoSharedLinksWithCleanup(req.user.id, filter.conversationId);
+ }
+ res.status(201).json(dbResponse);
+ } catch (error) {
+ logger.error('Error clearing conversations', error);
+ res.status(500).send('Error clearing conversations');
+ }
+});
+
+router.delete('/all', configMiddleware, async (req, res) => {
+ try {
+ const dbResponse = await db.deleteConvos(req.user.id, {});
+ // HITL: prune ALL the deleted conversations' durable checkpoints in one bulk pass.
+ await deleteAgentCheckpoints(
+ dbResponse.conversationIds,
+ req.config?.endpoints?.[EModelEndpoint.agents]?.checkpointer,
+ );
+ await db.deleteToolCalls(req.user.id);
+ await deleteAllSharedLinksWithCleanup(req.user.id);
+ res.status(201).json(dbResponse);
+ } catch (error) {
+ logger.error('Error clearing conversations', error);
+ res.status(500).send('Error clearing conversations');
+ }
+});
+
+/**
+ * Archives or unarchives a conversation.
+ * @route POST /archive
+ * @param {string} req.body.arg.conversationId - The conversation ID to archive/unarchive.
+ * @param {boolean} req.body.arg.isArchived - Whether to archive (true) or unarchive (false).
+ * @returns {object} 200 - The updated conversation object.
+ */
+router.post('/archive', validateConvoAccess, async (req, res) => {
+ const { conversationId, isArchived } = req.body?.arg ?? {};
+
+ if (!conversationId) {
+ return res.status(400).json({ error: 'conversationId is required' });
+ }
+
+ if (typeof isArchived !== 'boolean') {
+ return res.status(400).json({ error: 'isArchived must be a boolean' });
+ }
+
+ try {
+ const dbResponse = await db.saveConvo(
+ {
+ userId: req?.user?.id,
+ isTemporary: req?.body?.isTemporary,
+ interfaceConfig: req?.config?.interfaceConfig,
+ },
+ { conversationId, isArchived },
+ { context: `POST /api/convos/archive ${conversationId}` },
+ );
+ res.status(200).json(dbResponse);
+ } catch (error) {
+ logger.error('Error archiving conversation', error);
+ res.status(500).send('Error archiving conversation');
+ }
+});
+
+router.post('/pin', validateConvoAccess, async (req, res) => {
+ const { conversationId, pinned } = req.body?.arg ?? {};
+
+ if (!conversationId) {
+ return res.status(400).json({ error: 'conversationId is required' });
+ }
+
+ if (pinned === undefined) {
+ return res.status(400).json({ error: 'pinned is required' });
+ }
+
+ if (typeof pinned !== 'boolean') {
+ return res.status(400).json({ error: 'pinned must be a boolean' });
+ }
+
+ try {
+ const dbResponse = await db.saveConvo(
+ { userId: req.user.id },
+ { conversationId, pinned },
+ { context: `POST /api/convos/pin ${conversationId}` },
+ );
+ res.status(200).json(dbResponse);
+ } catch (error) {
+ logger.error('Error pinning conversation', error);
+ res.status(500).send('Error pinning conversation');
+ }
+});
+
+/** Maximum allowed length for conversation titles */
+const MAX_CONVO_TITLE_LENGTH = 1024;
+
+/**
+ * Updates a conversation's title.
+ * @route POST /update
+ * @param {string} req.body.arg.conversationId - The conversation ID to update.
+ * @param {string} req.body.arg.title - The new title for the conversation.
+ * @returns {object} 201 - The updated conversation object.
+ */
+router.post('/update', validateConvoAccess, async (req, res) => {
+ const { conversationId, title } = req.body?.arg ?? {};
+
+ if (!conversationId) {
+ return res.status(400).json({ error: 'conversationId is required' });
+ }
+
+ if (title === undefined) {
+ return res.status(400).json({ error: 'title is required' });
+ }
+
+ if (typeof title !== 'string') {
+ return res.status(400).json({ error: 'title must be a string' });
+ }
+
+ const sanitizedTitle = title.trim().slice(0, MAX_CONVO_TITLE_LENGTH);
+
+ try {
+ const dbResponse = await db.saveConvo(
+ {
+ userId: req?.user?.id,
+ isTemporary: req?.body?.isTemporary,
+ interfaceConfig: req?.config?.interfaceConfig,
+ },
+ { conversationId, title: sanitizedTitle },
+ { context: `POST /api/convos/update ${conversationId}` },
+ );
+ res.status(201).json(dbResponse);
+ } catch (error) {
+ logger.error('Error updating conversation', error);
+ res.status(500).send('Error updating conversation');
+ }
+});
+
+const { importIpLimiter, importUserLimiter } = createImportLimiters();
+/** Fork and duplicate share one rate-limit budget (same "clone" operation class) */
+const { forkIpLimiter, forkUserLimiter } = createForkLimiters();
+const importMaxFileSize = resolveImportMaxFileSize();
+const upload = multer({
+ storage,
+ fileFilter: importFileFilter,
+ limits: { fileSize: importMaxFileSize },
+});
+const uploadSingle = upload.single('file');
+
+function handleUpload(req, res, next) {
+ uploadSingle(req, res, (err) => {
+ if (err && err.code === 'LIMIT_FILE_SIZE') {
+ return res.status(413).json({ message: 'File exceeds the maximum allowed size' });
+ }
+ if (err) {
+ return next(err);
+ }
+ next();
+ });
+}
+
+/**
+ * Imports a conversation from a JSON file and saves it to the database.
+ * @route POST /import
+ * @param {Express.Multer.File} req.file - The JSON file to import.
+ * @returns {object} 201 - success response - application/json
+ */
+router.post(
+ '/import',
+ importIpLimiter,
+ importUserLimiter,
+ configMiddleware,
+ handleUpload,
+ restoreTenantContextFromReq,
+ async (req, res) => {
+ try {
+ /* TODO: optimize to return imported conversations and add manually */
+ await importConversations({
+ filepath: req.file.path,
+ requestUserId: req.user.id,
+ userRole: req.user.role,
+ interfaceConfig: req.config?.interfaceConfig,
+ });
+ res.status(201).json({ message: 'Conversation(s) imported successfully' });
+ } catch (error) {
+ logger.error('Error processing file', error);
+ res.status(500).send('Error processing file');
+ }
+ },
+);
+
+/**
+ * POST /fork
+ * This route handles forking a conversation based on the TForkConvoRequest and responds with TForkConvoResponse.
+ * @route POST /fork
+ * @param {express.Request<{}, TForkConvoResponse, TForkConvoRequest>} req - Express request object.
+ * @param {express.Response} res - Express response object.
+ * @returns {Promise} - The response after forking the conversation.
+ */
+router.post('/fork', forkIpLimiter, forkUserLimiter, async (req, res) => {
+ try {
+ /** @type {TForkConvoRequest} */
+ const { conversationId, messageId, option, splitAtTarget, latestMessageId } = req.body;
+ const result = await forkConversation({
+ requestUserId: req.user.id,
+ originalConvoId: conversationId,
+ targetMessageId: messageId,
+ latestMessageId,
+ records: true,
+ splitAtTarget,
+ option,
+ });
+
+ res.json(result);
+ } catch (error) {
+ logger.error('Error forking conversation:', error);
+ res.status(500).send('Error forking conversation');
+ }
+});
+
+router.post('/duplicate', forkIpLimiter, forkUserLimiter, async (req, res) => {
+ const { conversationId, title } = req.body;
+
+ try {
+ const result = await duplicateConversation({
+ userId: req.user.id,
+ conversationId,
+ title,
+ });
+ res.status(201).json(result);
+ } catch (error) {
+ logger.error('Error duplicating conversation:', error);
+ res.status(500).send('Error duplicating conversation');
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/endpoints.js b/api/server/routes/endpoints.js
new file mode 100644
index 0000000..8b1fcec
--- /dev/null
+++ b/api/server/routes/endpoints.js
@@ -0,0 +1,12 @@
+const express = require('express');
+const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
+const configMiddleware = require('~/server/middleware/config/app');
+const endpointController = require('~/server/controllers/EndpointController');
+const tokenConfigController = require('~/server/controllers/TokenConfigController');
+
+const router = express.Router();
+/** Auth required for role/tenant-scoped endpoint config resolution. */
+router.get('/', requireJwtAuth, endpointController);
+router.get('/token-config', requireJwtAuth, configMiddleware, tokenConfigController);
+
+module.exports = router;
diff --git a/api/server/routes/files/avatar.js b/api/server/routes/files/avatar.js
new file mode 100644
index 0000000..db00674
--- /dev/null
+++ b/api/server/routes/files/avatar.js
@@ -0,0 +1,54 @@
+const fs = require('fs').promises;
+const express = require('express');
+const { logger } = require('@librechat/data-schemas');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { resizeAvatar } = require('~/server/services/Files/images/avatar');
+const { getFileStrategy } = require('~/server/utils/getFileStrategy');
+const { filterFile } = require('~/server/services/Files/process');
+
+const router = express.Router();
+
+router.post('/', async (req, res) => {
+ try {
+ const appConfig = req.config;
+ filterFile({ req, file: req.file, image: true, isAvatar: true });
+ const userId = req.user.id;
+ const { manual } = req.body;
+ const input = await fs.readFile(req.file.path);
+
+ if (!userId) {
+ throw new Error('User ID is undefined');
+ }
+
+ const fileStrategy = getFileStrategy(appConfig, { isAvatar: true });
+ const desiredFormat = appConfig.imageOutputType;
+ const resizedBuffer = await resizeAvatar({
+ userId,
+ input,
+ desiredFormat,
+ });
+
+ const { processAvatar } = getStrategyFunctions(fileStrategy);
+ const url = await processAvatar({
+ buffer: resizedBuffer,
+ userId,
+ manual,
+ tenantId: req.user.tenantId,
+ });
+
+ res.json({ url });
+ } catch (error) {
+ const message = 'An error occurred while uploading the profile picture';
+ logger.error(message, error);
+ res.status(500).json({ message });
+ } finally {
+ try {
+ await fs.unlink(req.file.path);
+ logger.debug('[/files/images/avatar] Temp. image upload file deleted');
+ } catch {
+ logger.debug('[/files/images/avatar] Temp. image upload file already deleted');
+ }
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/files/files.agents.test.js b/api/server/routes/files/files.agents.test.js
new file mode 100644
index 0000000..eb35f2e
--- /dev/null
+++ b/api/server/routes/files/files.agents.test.js
@@ -0,0 +1,731 @@
+const express = require('express');
+const request = require('supertest');
+const mongoose = require('mongoose');
+const { v4: uuidv4 } = require('uuid');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { createMethods, SystemCapabilities } = require('@librechat/data-schemas');
+const {
+ SystemRoles,
+ AccessRoleIds,
+ ResourceType,
+ PrincipalType,
+} = require('librechat-data-provider');
+const { createAgent, createFile } = require('~/models');
+
+// Only mock the external dependencies that we don't want to test
+jest.mock('~/server/services/Files/process', () => ({
+ processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }),
+ filterFile: jest.fn(),
+ processFileUpload: jest.fn(),
+ processAgentFileUpload: jest.fn().mockImplementation(async ({ res }) => {
+ // processAgentFileUpload sends response directly via res.json()
+ return res.status(200).json({
+ message: 'Agent file uploaded and processed successfully',
+ file_id: 'test-file-id',
+ });
+ }),
+}));
+
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn(() => ({})),
+}));
+
+jest.mock('~/server/controllers/assistants/helpers', () => ({
+ getOpenAIClient: jest.fn(),
+}));
+
+jest.mock('~/server/services/Tools/credentials', () => ({
+ loadAuthValues: jest.fn(),
+}));
+
+jest.mock('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'),
+ refreshS3FileUrls: jest.fn(),
+}));
+
+jest.mock('~/cache', () => ({
+ getLogStores: jest.fn(() => ({
+ get: jest.fn(),
+ set: jest.fn(),
+ })),
+}));
+
+// Mock fs.promises.unlink to prevent file cleanup errors in tests
+jest.mock('fs', () => {
+ const actualFs = jest.requireActual('fs');
+ return {
+ ...actualFs,
+ promises: {
+ ...actualFs.promises,
+ unlink: jest.fn().mockResolvedValue(undefined),
+ },
+ };
+});
+
+const { processAgentFileUpload } = require('~/server/services/Files/process');
+
+// Import the router
+const router = require('~/server/routes/files/files');
+
+describe('File Routes - Agent Files Endpoint', () => {
+ let app;
+ let mongoServer;
+ let authorId;
+ let otherUserId;
+ let agentId;
+ let fileId1;
+ let fileId2;
+ let fileId3;
+ let File;
+ let User;
+ let Agent;
+ let methods;
+ let AclEntry;
+ // eslint-disable-next-line no-unused-vars
+ let AccessRole;
+ let SystemGrant;
+ let modelsToCleanup = [];
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+
+ // Initialize all models using createModels
+ const { createModels } = require('@librechat/data-schemas');
+ const models = createModels(mongoose);
+
+ // Track which models we're adding
+ modelsToCleanup = Object.keys(models);
+
+ // Register models on mongoose.models so methods can access them
+ Object.assign(mongoose.models, models);
+
+ // Create methods with our test mongoose instance
+ methods = createMethods(mongoose);
+
+ // Now we can access models from the db/models
+ File = models.File;
+ Agent = models.Agent;
+ AclEntry = models.AclEntry;
+ User = models.User;
+ AccessRole = models.AccessRole;
+ SystemGrant = models.SystemGrant;
+
+ // Seed default roles using our methods
+ await methods.seedDefaultRoles();
+
+ app = express();
+ app.use(express.json());
+
+ // Mock authentication middleware
+ app.use((req, res, next) => {
+ req.user = { id: otherUserId || 'default-user' };
+ req.app = { locals: {} };
+ next();
+ });
+
+ app.use('/files', router);
+ });
+
+ afterAll(async () => {
+ // 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 () => {
+ // Clean up all test data
+ await File.deleteMany({});
+ await Agent.deleteMany({});
+ await User.deleteMany({});
+ await AclEntry.deleteMany({});
+ // Don't delete AccessRole as they are seeded defaults needed for tests
+
+ // Create test users
+ authorId = new mongoose.Types.ObjectId();
+ otherUserId = new mongoose.Types.ObjectId();
+ agentId = uuidv4();
+ fileId1 = uuidv4();
+ fileId2 = uuidv4();
+ fileId3 = uuidv4();
+
+ // Create users in database
+ await User.create({
+ _id: authorId,
+ username: 'author',
+ email: 'author@test.com',
+ });
+
+ await User.create({
+ _id: otherUserId,
+ username: 'other',
+ email: 'other@test.com',
+ });
+
+ // Create files
+ await createFile({
+ user: authorId,
+ file_id: fileId1,
+ filename: 'file1.txt',
+ filepath: '/uploads/file1.txt',
+ bytes: 100,
+ type: 'text/plain',
+ });
+
+ await createFile({
+ user: authorId,
+ file_id: fileId2,
+ filename: 'file2.txt',
+ filepath: '/uploads/file2.txt',
+ bytes: 200,
+ type: 'text/plain',
+ });
+
+ await createFile({
+ user: otherUserId,
+ file_id: fileId3,
+ filename: 'file3.txt',
+ filepath: '/uploads/file3.txt',
+ bytes: 300,
+ type: 'text/plain',
+ });
+ });
+
+ describe('GET /files/agent/:agent_id', () => {
+ it('should return files accessible through the agent for non-author with EDIT permission', async () => {
+ // Create an agent with files attached
+ const agent = await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId1, fileId2, fileId3],
+ },
+ },
+ });
+
+ // Grant EDIT permission to user on the agent using PermissionService
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_EDITOR,
+ grantedBy: authorId,
+ });
+
+ // Mock req.user for this request
+ app.use((req, res, next) => {
+ req.user = { id: otherUserId.toString() };
+ next();
+ });
+
+ const response = await request(app).get(`/files/agent/${agentId}`);
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body).toHaveLength(3);
+ expect(response.body.map((f) => f.file_id)).toContain(fileId1);
+ expect(response.body.map((f) => f.file_id)).toContain(fileId2);
+ expect(response.body.map((f) => f.file_id)).toContain(fileId3);
+ });
+
+ it('should return 400 when agent_id is not provided', async () => {
+ const response = await request(app).get('/files/agent/');
+
+ expect(response.status).toBe(404); // Express returns 404 for missing route parameter
+ });
+
+ it('should return empty array for non-existent agent', async () => {
+ const response = await request(app).get('/files/agent/non-existent-agent');
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body).toEqual([]);
+ });
+
+ it('should return empty array when user only has VIEW permission', async () => {
+ // Create an agent with files attached
+ const agent = await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId1, fileId2],
+ },
+ },
+ });
+
+ // Grant only VIEW permission to user on the agent
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ grantedBy: authorId,
+ });
+
+ const response = await request(app).get(`/files/agent/${agentId}`);
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body).toEqual([]);
+ });
+
+ it('should return agent files for agent author', async () => {
+ // Create an agent with files attached
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId1, fileId2],
+ },
+ },
+ });
+
+ // Create a new app instance with author authentication
+ const authorApp = express();
+ authorApp.use(express.json());
+ authorApp.use((req, res, next) => {
+ req.user = { id: authorId.toString() };
+ req.app = { locals: {} };
+ next();
+ });
+ authorApp.use('/files', router);
+
+ const response = await request(authorApp).get(`/files/agent/${agentId}`);
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body).toHaveLength(2);
+ });
+
+ it('should return attached files uploaded by another editor', async () => {
+ const agent = await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId1, fileId3],
+ },
+ },
+ });
+
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_EDITOR,
+ grantedBy: authorId,
+ });
+
+ // Create a new app instance with author authentication
+ const authorApp = express();
+ authorApp.use(express.json());
+ authorApp.use((req, res, next) => {
+ req.user = { id: authorId.toString() };
+ req.app = { locals: {} };
+ next();
+ });
+ authorApp.use('/files', router);
+
+ const response = await request(authorApp).get(`/files/agent/${agentId}`);
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body).toHaveLength(2);
+ expect(response.body.map((f) => f.file_id)).toContain(fileId1);
+ expect(response.body.map((f) => f.file_id)).toContain(fileId3);
+ });
+ });
+
+ describe('POST /files - Agent File Upload Permission Check', () => {
+ let agentCustomId;
+
+ beforeEach(async () => {
+ agentCustomId = `agent_${uuidv4().replace(/-/g, '').substring(0, 21)}`;
+ jest.clearAllMocks();
+ });
+
+ /**
+ * Helper to create an Express app with specific user context
+ */
+ const createAppWithUser = (userId, userRole = SystemRoles.USER) => {
+ const testApp = express();
+ testApp.use(express.json());
+
+ // Mock multer - populate req.file
+ testApp.use((req, res, next) => {
+ if (req.method === 'POST') {
+ req.file = {
+ originalname: 'test.txt',
+ mimetype: 'text/plain',
+ size: 100,
+ path: '/tmp/test.txt',
+ };
+ req.file_id = uuidv4();
+ }
+ next();
+ });
+
+ testApp.use((req, res, next) => {
+ req.user = { id: userId.toString(), role: userRole };
+ req.app = { locals: {} };
+ req.config = { fileStrategy: 'local' };
+ next();
+ });
+
+ testApp.use('/files', router);
+ return testApp;
+ };
+
+ it('should deny file upload to agent when user has no permission', async () => {
+ // Create an agent owned by authorId
+ await createAgent({
+ id: agentCustomId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ const testApp = createAppWithUser(otherUserId);
+
+ const response = await request(testApp).post('/files').send({
+ endpoint: 'agents',
+ agent_id: agentCustomId,
+ tool_resource: 'context',
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.error).toBe('Forbidden');
+ expect(response.body.message).toBe('Insufficient permissions to upload files to this agent');
+ expect(processAgentFileUpload).not.toHaveBeenCalled();
+ });
+
+ it('should allow file upload to agent for agent author', async () => {
+ // Create an agent owned by authorId
+ await createAgent({
+ id: agentCustomId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ const testApp = createAppWithUser(authorId);
+
+ const response = await request(testApp).post('/files').send({
+ endpoint: 'agents',
+ agent_id: agentCustomId,
+ tool_resource: 'context',
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(200);
+ expect(processAgentFileUpload).toHaveBeenCalled();
+ });
+
+ it('should allow file upload to agent for user with EDIT permission', async () => {
+ // Create an agent owned by authorId
+ const agent = await createAgent({
+ id: agentCustomId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ // Grant EDIT permission to otherUserId
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_EDITOR,
+ grantedBy: authorId,
+ });
+
+ const testApp = createAppWithUser(otherUserId);
+
+ const response = await request(testApp).post('/files').send({
+ endpoint: 'agents',
+ agent_id: agentCustomId,
+ tool_resource: 'context',
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(200);
+ expect(processAgentFileUpload).toHaveBeenCalled();
+ });
+
+ it('should deny file upload to agent for user with only VIEW permission', async () => {
+ // Create an agent owned by authorId
+ const agent = await createAgent({
+ id: agentCustomId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ // Grant only VIEW permission to otherUserId
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ grantedBy: authorId,
+ });
+
+ const testApp = createAppWithUser(otherUserId);
+
+ const response = await request(testApp).post('/files').send({
+ endpoint: 'agents',
+ agent_id: agentCustomId,
+ tool_resource: 'file_search',
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.error).toBe('Forbidden');
+ expect(processAgentFileUpload).not.toHaveBeenCalled();
+ });
+
+ it('should allow file upload for user with MANAGE_AGENTS capability regardless of agent ownership', async () => {
+ // Create an agent owned by authorId
+ await createAgent({
+ id: agentCustomId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ // Seed MANAGE_AGENTS capability for the ADMIN role
+ await SystemGrant.create({
+ principalType: PrincipalType.ROLE,
+ principalId: SystemRoles.ADMIN,
+ capability: SystemCapabilities.MANAGE_AGENTS,
+ grantedAt: new Date(),
+ });
+
+ // Create app with admin user (otherUserId as admin)
+ const testApp = createAppWithUser(otherUserId, SystemRoles.ADMIN);
+
+ const response = await request(testApp).post('/files').send({
+ endpoint: 'agents',
+ agent_id: agentCustomId,
+ tool_resource: 'context',
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(200);
+ expect(processAgentFileUpload).toHaveBeenCalled();
+ });
+
+ it('should return 404 when uploading to non-existent agent', async () => {
+ const testApp = createAppWithUser(otherUserId);
+
+ const response = await request(testApp).post('/files').send({
+ endpoint: 'agents',
+ agent_id: 'agent_nonexistent123456789',
+ tool_resource: 'context',
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toBe('Not Found');
+ expect(response.body.message).toBe('Agent not found');
+ expect(processAgentFileUpload).not.toHaveBeenCalled();
+ });
+
+ it('should allow file upload without agent_id (message attachment)', async () => {
+ const testApp = createAppWithUser(otherUserId);
+
+ const response = await request(testApp).post('/files').send({
+ endpoint: 'agents',
+ file_id: uuidv4(),
+ // No agent_id or tool_resource - this is a message attachment
+ });
+
+ expect(response.status).toBe(200);
+ expect(processAgentFileUpload).toHaveBeenCalled();
+ });
+
+ it('should allow file upload with agent_id but no tool_resource (message attachment)', async () => {
+ // Create an agent owned by authorId
+ await createAgent({
+ id: agentCustomId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ const testApp = createAppWithUser(otherUserId);
+
+ const response = await request(testApp).post('/files').send({
+ endpoint: 'agents',
+ agent_id: agentCustomId,
+ file_id: uuidv4(),
+ // No tool_resource - permission check should not apply
+ });
+
+ expect(response.status).toBe(200);
+ expect(processAgentFileUpload).toHaveBeenCalled();
+ });
+
+ it('should allow message_file attachment to agent even without EDIT permission', async () => {
+ // Create an agent owned by authorId
+ const agent = await createAgent({
+ id: agentCustomId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ // Grant only VIEW permission to otherUserId
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ grantedBy: authorId,
+ });
+
+ const testApp = createAppWithUser(otherUserId);
+
+ // message_file: true indicates this is a chat message attachment, not a permanent file upload
+ const response = await request(testApp).post('/files').send({
+ endpoint: 'agents',
+ agent_id: agentCustomId,
+ tool_resource: 'context',
+ message_file: true,
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(200);
+ expect(processAgentFileUpload).toHaveBeenCalled();
+ });
+
+ it('should allow message_file attachment (string "true") to agent even without EDIT permission', async () => {
+ // Create an agent owned by authorId
+ const agent = await createAgent({
+ id: agentCustomId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ // Grant only VIEW permission to otherUserId
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ grantedBy: authorId,
+ });
+
+ const testApp = createAppWithUser(otherUserId);
+
+ // message_file as string "true" (from form data) should also be allowed
+ const response = await request(testApp).post('/files').send({
+ endpoint: 'agents',
+ agent_id: agentCustomId,
+ tool_resource: 'context',
+ message_file: 'true',
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(200);
+ expect(processAgentFileUpload).toHaveBeenCalled();
+ });
+
+ it('should deny file upload when message_file is false (not a message attachment)', async () => {
+ // Create an agent owned by authorId
+ const agent = await createAgent({
+ id: agentCustomId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ // Grant only VIEW permission to otherUserId
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ grantedBy: authorId,
+ });
+
+ const testApp = createAppWithUser(otherUserId);
+
+ // message_file: false should NOT bypass permission check
+ const response = await request(testApp).post('/files').send({
+ endpoint: 'agents',
+ agent_id: agentCustomId,
+ tool_resource: 'context',
+ message_file: false,
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.error).toBe('Forbidden');
+ expect(processAgentFileUpload).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js
new file mode 100644
index 0000000..bfd0faa
--- /dev/null
+++ b/api/server/routes/files/files.js
@@ -0,0 +1,670 @@
+const fs = require('fs').promises;
+const express = require('express');
+const { logger, SystemCapabilities } = require('@librechat/data-schemas');
+const {
+ logAxiosError,
+ refreshS3FileUrls,
+ resolveUploadErrorMessage,
+ verifyAgentUploadPermission,
+} = require('@librechat/api');
+const {
+ Time,
+ isUUID,
+ CacheKeys,
+ FileSources,
+ ResourceType,
+ EModelEndpoint,
+ EToolResources,
+ PermissionBits,
+ checkOpenAIStorage,
+ isAssistantsEndpoint,
+} = require('librechat-data-provider');
+const {
+ filterFile,
+ processFileUpload,
+ processDeleteRequest,
+ processAgentFileUpload,
+} = require('~/server/services/Files/process');
+const { fileAccess } = require('~/server/middleware/accessResources/fileAccess');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
+const { hasCapability } = require('~/server/middleware/roles/capabilities');
+const { checkPermission } = require('~/server/services/PermissionService');
+const { cleanFileName, getContentDisposition } = require('~/server/utils/files');
+const { getLogStores } = require('~/cache');
+const { Readable } = require('stream');
+const db = require('~/models');
+
+const router = express.Router();
+const AGENT_TOOL_RESOURCE_KEYS = new Set([
+ EToolResources.execute_code,
+ EToolResources.file_search,
+ EToolResources.image_edit,
+ EToolResources.context,
+ EToolResources.ocr,
+]);
+
+const isAgentToolResourceKey = (toolResource) =>
+ typeof toolResource === 'string' && AGENT_TOOL_RESOURCE_KEYS.has(toolResource);
+
+router.get('/', async (req, res) => {
+ try {
+ const appConfig = req.config;
+ const files = await db.getFiles({ user: req.user.id });
+ if (appConfig.fileStrategy === FileSources.s3) {
+ try {
+ const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
+ const alreadyChecked = await cache.get(req.user.id);
+ if (!alreadyChecked) {
+ await refreshS3FileUrls(files, db.batchUpdateFiles);
+ await cache.set(req.user.id, true, Time.THIRTY_MINUTES);
+ }
+ } catch (error) {
+ logger.warn('[/files] Error refreshing S3 file URLs:', error);
+ }
+ }
+ res.status(200).send(files);
+ } catch (error) {
+ logger.error('[/files] Error getting files:', error);
+ res.status(400).json({ message: 'Error in request', error: error.message });
+ }
+});
+
+/**
+ * Get files specific to an agent
+ * @route GET /files/agent/:agent_id
+ * @param {string} agent_id - The agent ID to get files for
+ * @returns {Promise} Array of files attached to the agent
+ */
+router.get('/agent/:agent_id', async (req, res) => {
+ try {
+ const { agent_id } = req.params;
+ const userId = req.user.id;
+
+ if (!agent_id) {
+ return res.status(400).json({ error: 'Agent ID is required' });
+ }
+
+ const agent = await db.getAgent({ id: agent_id });
+ if (!agent) {
+ return res.status(200).json([]);
+ }
+
+ if (agent.author.toString() !== userId) {
+ const hasEditPermission = await checkPermission({
+ userId,
+ role: req.user.role,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ requiredPermission: PermissionBits.EDIT,
+ });
+
+ if (!hasEditPermission) {
+ return res.status(200).json([]);
+ }
+ }
+
+ const agentFileIds = new Set();
+ if (agent.tool_resources) {
+ for (const [, resource] of Object.entries(agent.tool_resources)) {
+ if (resource?.file_ids && Array.isArray(resource.file_ids)) {
+ resource.file_ids.forEach((fileId) => agentFileIds.add(fileId));
+ }
+ }
+ }
+
+ if (agentFileIds.size === 0) {
+ return res.status(200).json([]);
+ }
+
+ const files = await db.getFiles({ file_id: { $in: [...agentFileIds] } }, null, {
+ text: 0,
+ });
+
+ res.status(200).json(files);
+ } catch (error) {
+ logger.error('[/files/agent/:agent_id] Error fetching agent files:', error);
+ res.status(500).json({ error: 'Failed to fetch agent files' });
+ }
+});
+
+router.get('/config', async (req, res) => {
+ try {
+ const appConfig = req.config;
+ res.status(200).json(appConfig.fileConfig);
+ } catch (error) {
+ logger.error('[/files] Error getting fileConfig', error);
+ res.status(400).json({ message: 'Error in request', error: error.message });
+ }
+});
+
+router.delete('/', async (req, res) => {
+ try {
+ const { files: _files } = req.body;
+
+ /** @type {MongoFile[]} */
+ const files = _files.filter((file) => {
+ if (!file.file_id) {
+ return false;
+ }
+ if (!file.filepath) {
+ return false;
+ }
+
+ if (/^(file|assistant)-/.test(file.file_id)) {
+ return true;
+ }
+
+ return isUUID.safeParse(file.file_id).success;
+ });
+
+ if (files.length === 0) {
+ res.status(204).json({ message: 'Nothing provided to delete' });
+ return;
+ }
+
+ const fileIds = files.map((file) => file.file_id);
+ const dbFiles = await db.getFiles({ file_id: { $in: fileIds } });
+
+ if (req.body.agent_id && req.body.tool_resource) {
+ if (!isAgentToolResourceKey(req.body.tool_resource)) {
+ return res.status(400).json({ message: 'Invalid agent tool resource' });
+ }
+
+ const agent = await db.getAgent({
+ id: req.body.agent_id,
+ });
+
+ if (!agent) {
+ return res.status(404).json({ message: 'Agent not found' });
+ }
+
+ const hasAgentEditAccess =
+ agent.author?.toString() === req.user.id.toString() ||
+ (await checkPermission({
+ userId: req.user.id,
+ role: req.user.role,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ requiredPermission: PermissionBits.EDIT,
+ }));
+ if (!hasAgentEditAccess) {
+ return res.status(403).json({
+ message: 'You can only delete files you have access to',
+ unauthorizedFiles: files.map((file) => file.file_id),
+ });
+ }
+
+ const toolResourceFiles = agent.tool_resources?.[req.body.tool_resource]?.file_ids ?? [];
+ const agentFiles = files
+ .filter((f) => toolResourceFiles.includes(f.file_id))
+ .map((file) => ({ tool_resource: req.body.tool_resource, file_id: file.file_id }));
+ if (agentFiles.length === 0) {
+ res.status(200).json({ message: 'File associations removed successfully from agent' });
+ return;
+ }
+
+ await db.removeAgentResourceFiles({
+ agent_id: req.body.agent_id,
+ files: agentFiles,
+ });
+ res.status(200).json({ message: 'File associations removed successfully from agent' });
+ return;
+ }
+
+ const ownedFiles = [];
+ const nonOwnedFiles = [];
+
+ for (const file of dbFiles) {
+ if (file.user.toString() === req.user.id.toString()) {
+ ownedFiles.push(file);
+ } else {
+ nonOwnedFiles.push(file);
+ }
+ }
+
+ if (dbFiles.length > 0 && nonOwnedFiles.length === 0) {
+ await processDeleteRequest({ req, files: ownedFiles });
+ logger.debug(
+ `[/files] Files deleted successfully: ${ownedFiles
+ .filter((f) => f.file_id)
+ .map((f) => f.file_id)
+ .join(', ')}`,
+ );
+ res.status(200).json({ message: 'Files deleted successfully' });
+ return;
+ }
+
+ const authorizedFiles = [...ownedFiles];
+ const unauthorizedFiles = nonOwnedFiles;
+
+ if (unauthorizedFiles.length > 0) {
+ return res.status(403).json({
+ message: 'You can only delete files you own',
+ unauthorizedFiles: unauthorizedFiles.map((f) => f.file_id),
+ });
+ }
+
+ /* Handle assistant unlinking even if no valid files to delete */
+ if (req.body.assistant_id && req.body.tool_resource && dbFiles.length === 0) {
+ const assistant = await db.getAssistant({
+ id: req.body.assistant_id,
+ });
+
+ const toolResourceFiles = assistant.tool_resources?.[req.body.tool_resource]?.file_ids ?? [];
+ const assistantFiles = files.filter((f) => toolResourceFiles.includes(f.file_id));
+
+ await processDeleteRequest({ req, files: assistantFiles });
+ res.status(200).json({ message: 'File associations removed successfully from assistant' });
+ return;
+ } else if (
+ req.body.assistant_id &&
+ req.body.files?.[0]?.filepath === EModelEndpoint.azureAssistants
+ ) {
+ await processDeleteRequest({ req, files: req.body.files });
+ return res
+ .status(200)
+ .json({ message: 'File associations removed successfully from Azure Assistant' });
+ }
+
+ await processDeleteRequest({ req, files: authorizedFiles });
+
+ logger.debug(
+ `[/files] Files deleted successfully: ${authorizedFiles
+ .filter((f) => f.file_id)
+ .map((f) => f.file_id)
+ .join(', ')}`,
+ );
+ res.status(200).json({ message: 'Files deleted successfully' });
+ } catch (error) {
+ logger.error('[/files] Error deleting files:', error);
+ res.status(400).json({ message: 'Error in request', error: error.message });
+ }
+});
+
+function isValidID(str) {
+ return /^[A-Za-z0-9_-]{21}$/.test(str);
+}
+
+router.get('/code/download/:session_id/:fileId', async (req, res) => {
+ try {
+ const { session_id, fileId } = req.params;
+ const logPrefix = `Session ID: ${session_id} | File ID: ${fileId} | Code output download requested by user `;
+ logger.debug(logPrefix);
+
+ if (!session_id || !fileId) {
+ return res.status(400).send('Bad request');
+ }
+
+ if (!isValidID(session_id) || !isValidID(fileId)) {
+ logger.debug(`${logPrefix} invalid session_id or fileId`);
+ return res.status(400).send('Bad request');
+ }
+
+ const { getDownloadStream } = getStrategyFunctions(FileSources.execute_code);
+ if (!getDownloadStream) {
+ logger.warn(
+ `${logPrefix} has no stream method implemented for ${FileSources.execute_code} source`,
+ );
+ return res.status(501).send('Not Implemented');
+ }
+
+ /* Code-output downloads are always user-private — `processCodeOutput`
+ * persists every code-execution artifact under
+ * `metadata.codeEnvRef.kind === 'user'` regardless of which skill
+ * the run invoked. Pass `kind: 'user'` + `id: ` so codeapi's
+ * `sessionAuth` resolves the matching `:user:`
+ * sessionKey; without these query params it 400s with
+ * "kind must be one of: skill, agent, user". */
+ /** @type {AxiosResponse | undefined} */
+ const response = await getDownloadStream(
+ `${session_id}/${fileId}`,
+ {
+ kind: 'user',
+ id: req.user.id,
+ },
+ req,
+ );
+ res.set(response.headers);
+ response.data.pipe(res);
+ } catch (error) {
+ /* `logAxiosError` redacts buffer/stream response bodies — without
+ * it, a stream-typed axios failure dumps the entire `Readable`'s
+ * internal state (megabytes of socket + readableState) into the
+ * log line. Plain `logger.error(error)` would do that here. */
+ logAxiosError({ message: 'Error downloading code-output file', error });
+ res.status(500).send('Error downloading file');
+ }
+});
+
+/* Lazy-sweep cutoff: pending records older than this are marked failed
+ * on the next poll. 2min is well past the 60s render ceiling, so any
+ * `pending` past it is definitively orphaned. Tighter than the boot
+ * sweep (5min) since this runs per-request, not per-instance. */
+const PREVIEW_LAZY_SWEEP_CUTOFF_MS = 2 * 60 * 1000;
+
+/**
+ * Poll the lifecycle status of a code-execution file's inline preview.
+ *
+ * Deferred-preview flow: the immediate persist step writes the file
+ * record at `status: 'pending'`; the background render transitions
+ * it to `'ready'` (with `text` + `textFormat`) or `'failed'` (with
+ * `previewError`). The frontend's `useFilePreview` React Query hook
+ * polls this endpoint at ~2.5s intervals while `status === 'pending'`,
+ * then auto-stops on terminal status.
+ *
+ * Returns the smallest viable shape:
+ * - `status` always present (defaults to `'ready'` for legacy records
+ * that never had the field — clients treat absent as ready).
+ * - `text` and `textFormat` only when status is 'ready' AND text
+ * is non-null (preserves the security contract from PR #12934 —
+ * office bucket files MUST NOT receive plain-text fallbacks).
+ * - `previewError` only when status is 'failed'.
+ *
+ * Lazy-sweeps stale `pending` records on the spot — see
+ * `PREVIEW_LAZY_SWEEP_CUTOFF_MS` for the rationale.
+ *
+ * Reuses the `fileAccess` middleware so ACL is identical to download.
+ *
+ * @route GET /files/:file_id/preview
+ */
+router.get('/:file_id/preview', fileAccess, async (req, res) => {
+ try {
+ const { file_id } = req.params;
+ /* `fileAccess` already fetched the record (sans `text`, the default
+ * projection drops it). Reuse for the lifecycle check; only re-fetch
+ * with `text` on a terminal ready response — the typical lifecycle
+ * is N pending polls + 1 ready, so this avoids ~N redundant text
+ * reads per file. */
+ let file = req.fileAccess.file;
+ /* Lazy sweep: if stuck `pending` past the cutoff, mark `failed`
+ * conditional on the observed `updatedAt` (concurrent legitimate
+ * updates win). */
+ if (file.status === 'pending' && file.updatedAt instanceof Date) {
+ const ageMs = Date.now() - file.updatedAt.getTime();
+ if (ageMs > PREVIEW_LAZY_SWEEP_CUTOFF_MS) {
+ const swept = await db.updateFile(
+ { file_id, status: 'failed', previewError: 'orphaned' },
+ { status: 'pending', updatedAt: file.updatedAt },
+ );
+ if (swept) {
+ file = swept;
+ logger.info(
+ `[/files/:file_id/preview] Lazy-swept orphaned pending record ${file_id} (age ${Math.round(ageMs / 1000)}s)`,
+ );
+ }
+ }
+ }
+ /* Default to 'ready' for back-compat: legacy records pre-date the
+ * field, and non-office files never get a status set on persist. */
+ const status = file.status ?? 'ready';
+ const payload = { file_id, status };
+ if (status === 'ready') {
+ const withText = await db.findFileById(file_id);
+ if (withText?.text != null) {
+ payload.text = withText.text;
+ payload.textFormat = withText.textFormat ?? null;
+ }
+ } else if (status === 'failed' && file.previewError) {
+ payload.previewError = file.previewError;
+ }
+ return res.status(200).json(payload);
+ } catch (error) {
+ logger.error('[/files/:file_id/preview] Error fetching preview status:', error);
+ return res
+ .status(500)
+ .json({ error: 'Internal Server Error', message: 'Failed to fetch preview status' });
+ }
+});
+
+/**
+ * Returns a strategy-managed signed URL for an already-authorized file record.
+ */
+const getDirectDownloadURL = async ({
+ req,
+ file,
+ customFilename = cleanFileName(file.filename),
+}) => {
+ const { getDownloadURL } = getStrategyFunctions(file.source);
+ if (!getDownloadURL) {
+ return null;
+ }
+
+ return getDownloadURL({
+ req,
+ file,
+ customFilename,
+ contentType: file.type || 'application/octet-stream',
+ });
+};
+
+// Security allowlist: excludes internal ids, owner/tenant identifiers, and extracted text.
+// `filepath` stays included because cached TFile records need it for previews/deletes.
+const DOWNLOAD_METADATA_FIELDS = [
+ 'conversationId',
+ 'message',
+ 'file_id',
+ 'temp_file_id',
+ 'bytes',
+ 'model',
+ 'embedded',
+ 'filename',
+ 'filepath',
+ 'storageKey',
+ 'storageRegion',
+ 'object',
+ 'type',
+ 'usage',
+ 'context',
+ 'source',
+ 'filterSource',
+ 'width',
+ 'height',
+ 'expiresAt',
+ 'preview',
+ 'textFormat',
+ 'status',
+ 'previewError',
+ 'createdAt',
+ 'updatedAt',
+];
+
+const getDownloadFileMetadata = (file) => {
+ const rawFile = typeof file.toObject === 'function' ? file.toObject() : file;
+ return DOWNLOAD_METADATA_FIELDS.reduce((metadata, field) => {
+ if (rawFile[field] !== undefined) {
+ metadata[field] = rawFile[field];
+ }
+ return metadata;
+ }, {});
+};
+
+router.get('/download-url/:userId/:file_id', fileAccess, async (req, res) => {
+ try {
+ const { userId, file_id } = req.params;
+ logger.debug(`File download URL requested by user ${userId}: ${file_id}`);
+
+ const file = req.fileAccess.file;
+ if (checkOpenAIStorage(file.source) && !file.model) {
+ logger.warn(
+ `File download URL requested by user ${userId} has no associated model: ${file_id}`,
+ );
+ return res.status(400).send('The model used when creating this file is not available');
+ }
+
+ const filename = cleanFileName(file.filename);
+ const downloadURL = checkOpenAIStorage(file.source)
+ ? null
+ : await getDirectDownloadURL({ req, file, customFilename: filename });
+
+ if (!downloadURL) {
+ logger.debug(
+ `File download URL requested by user ${userId} is not supported for source: ${file.source}`,
+ );
+ return res.status(501).send('Not Implemented');
+ }
+
+ res.setHeader('Cache-Control', 'no-store');
+ return res.status(200).json({
+ url: downloadURL,
+ filename,
+ type: file.type || 'application/octet-stream',
+ metadata: getDownloadFileMetadata(file),
+ });
+ } catch (error) {
+ logger.error('[DOWNLOAD URL ROUTE] Error generating file download URL:', error);
+ res.status(500).send('Error generating file download URL');
+ }
+});
+
+router.get('/download/:userId/:file_id', fileAccess, async (req, res) => {
+ try {
+ const { userId, file_id } = req.params;
+ logger.debug(`File download requested by user ${userId}: ${file_id}`);
+
+ // Access already validated by fileAccess middleware
+ const file = req.fileAccess.file;
+
+ if (checkOpenAIStorage(file.source) && !file.model) {
+ logger.warn(`File download requested by user ${userId} has no associated model: ${file_id}`);
+ return res.status(400).send('The model used when creating this file is not available');
+ }
+
+ const { getDownloadStream, getDownloadURL } = getStrategyFunctions(file.source);
+ if (!getDownloadStream && !getDownloadURL) {
+ logger.warn(
+ `File download requested by user ${userId} has no download method implemented: ${file.source}`,
+ );
+ return res.status(501).send('Not Implemented');
+ }
+
+ const setHeaders = () => {
+ res.setHeader('Content-Disposition', getContentDisposition(file.filename));
+ res.setHeader('Content-Type', 'application/octet-stream');
+ res.setHeader(
+ 'X-File-Metadata',
+ encodeURIComponent(JSON.stringify(getDownloadFileMetadata(file))),
+ );
+ };
+
+ if (checkOpenAIStorage(file.source)) {
+ req.body = { model: file.model };
+ const endpointMap = {
+ [FileSources.openai]: EModelEndpoint.assistants,
+ [FileSources.azure]: EModelEndpoint.azureAssistants,
+ };
+ const { openai } = await getOpenAIClient({
+ req,
+ res,
+ overrideEndpoint: endpointMap[file.source],
+ });
+ logger.debug(`Downloading file ${file_id} from OpenAI`);
+ const passThrough = await getDownloadStream(file_id, openai);
+ setHeaders();
+ logger.debug(`File ${file_id} downloaded from OpenAI`);
+
+ // Handle both Node.js and Web streams
+ const stream =
+ passThrough.body && typeof passThrough.body.getReader === 'function'
+ ? Readable.fromWeb(passThrough.body)
+ : passThrough.body;
+
+ stream.pipe(res);
+ } else {
+ if (getDownloadURL && req.query.direct === 'true') {
+ try {
+ const downloadURL = await getDirectDownloadURL({ req, file });
+ if (downloadURL) {
+ res.setHeader('Cache-Control', 'no-store');
+ return res.redirect(302, downloadURL);
+ }
+ } catch (error) {
+ logger.warn(
+ '[DOWNLOAD ROUTE] Falling back to stream after URL generation failed:',
+ error,
+ );
+ }
+ }
+
+ if (!getDownloadStream) {
+ logger.warn(
+ `File download requested by user ${userId} has no stream method implemented: ${file.source}`,
+ );
+ return res.status(501).send('Not Implemented');
+ }
+
+ const fileStream = await getDownloadStream(req, file.storageKey || file.filepath);
+
+ fileStream.on('error', (streamError) => {
+ logger.error('[DOWNLOAD ROUTE] Stream error:', streamError);
+ });
+
+ setHeaders();
+ fileStream.pipe(res);
+ }
+ } catch (error) {
+ logger.error('[DOWNLOAD ROUTE] Error downloading file:', error);
+ res.status(500).send('Error downloading file');
+ }
+});
+
+router.post('/', async (req, res) => {
+ const metadata = req.body;
+ let cleanup = true;
+
+ try {
+ filterFile({ req });
+
+ metadata.temp_file_id = metadata.file_id;
+ metadata.file_id = req.file_id;
+
+ if (isAssistantsEndpoint(metadata.endpoint)) {
+ return await processFileUpload({ req, res, metadata });
+ }
+
+ let skipUploadAuth = false;
+ try {
+ skipUploadAuth = await hasCapability(req.user, SystemCapabilities.MANAGE_AGENTS);
+ } catch (err) {
+ logger.warn(`[/files] capability check failed, denying bypass: ${err.message}`);
+ }
+
+ if (!skipUploadAuth) {
+ const denied = await verifyAgentUploadPermission({
+ req,
+ res,
+ metadata,
+ getAgent: db.getAgent,
+ checkPermission,
+ });
+ if (denied) {
+ return;
+ }
+ }
+
+ return await processAgentFileUpload({ req, res, metadata });
+ } catch (error) {
+ const message = resolveUploadErrorMessage(error);
+ logger.error('[/files] Error processing file:', error);
+
+ try {
+ await fs.unlink(req.file.path);
+ cleanup = false;
+ } catch (error) {
+ logger.error('[/files] Error deleting file:', error);
+ }
+ res.status(500).json({ message });
+ } finally {
+ if (cleanup) {
+ try {
+ await fs.unlink(req.file.path);
+ } catch (error) {
+ logger.error('[/files] Error deleting file after file processing:', error);
+ }
+ } else {
+ logger.debug('[/files] File processing completed without cleanup');
+ }
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/files/files.test.js b/api/server/routes/files/files.test.js
new file mode 100644
index 0000000..c2930f3
--- /dev/null
+++ b/api/server/routes/files/files.test.js
@@ -0,0 +1,939 @@
+const express = require('express');
+const request = require('supertest');
+const mongoose = require('mongoose');
+const { Readable } = require('stream');
+const { v4: uuidv4 } = require('uuid');
+const { createMethods, tenantStorage } = require('@librechat/data-schemas');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const {
+ SystemRoles,
+ ResourceType,
+ AccessRoleIds,
+ PrincipalType,
+ FileSources,
+} = require('librechat-data-provider');
+const { createAgent, createFile } = require('~/models');
+
+// Only mock the external dependencies that we don't want to test
+jest.mock('~/server/services/Files/process', () => ({
+ processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }),
+ filterFile: jest.fn(),
+ processFileUpload: jest.fn(),
+ processAgentFileUpload: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn(() => ({})),
+}));
+
+jest.mock('~/server/controllers/assistants/helpers', () => ({
+ getOpenAIClient: jest.fn(),
+}));
+
+jest.mock('~/server/services/Tools/credentials', () => ({
+ loadAuthValues: jest.fn(),
+}));
+
+jest.mock('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'),
+ refreshS3FileUrls: jest.fn(),
+}));
+
+jest.mock('~/cache', () => ({
+ getLogStores: jest.fn(() => ({
+ get: jest.fn(),
+ set: jest.fn(),
+ })),
+}));
+
+jest.mock('~/config', () => ({
+ logger: {
+ error: jest.fn(),
+ warn: jest.fn(),
+ debug: jest.fn(),
+ },
+}));
+
+const { processDeleteRequest } = require('~/server/services/Files/process');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+
+// Import the router after mocks
+const router = require('./files');
+
+describe('File Routes - Delete with Agent Access', () => {
+ let app;
+ let mongoServer;
+ let authorId;
+ let otherUserId;
+ let fileId;
+ let File;
+ let Agent;
+ let AclEntry;
+ let User;
+ let methods;
+ let modelsToCleanup = [];
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+
+ // Initialize all models using createModels
+ const { createModels } = require('@librechat/data-schemas');
+ const models = createModels(mongoose);
+
+ // Track which models we're adding
+ modelsToCleanup = Object.keys(models);
+
+ // Register models on mongoose.models so methods can access them
+ Object.assign(mongoose.models, models);
+
+ // Create methods with our test mongoose instance
+ methods = createMethods(mongoose);
+
+ // Now we can access models from the db/models
+ File = models.File;
+ Agent = models.Agent;
+ AclEntry = models.AclEntry;
+ User = models.User;
+
+ // Seed default roles using our methods
+ await methods.seedDefaultRoles();
+
+ app = express();
+ app.use(express.json());
+
+ app.use((req, res, next) => {
+ req.user = {
+ id: otherUserId?.toString() || 'default-user',
+ role: SystemRoles.USER,
+ };
+ req.app.locals = {};
+ next();
+ });
+
+ app.use('/files', router);
+ });
+
+ 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 () => {
+ jest.clearAllMocks();
+
+ // Clear database - clean up all test data
+ await File.deleteMany({});
+ await Agent.deleteMany({});
+ await User.deleteMany({});
+ await AclEntry.deleteMany({});
+ // Don't delete AccessRole as they are seeded defaults needed for tests
+
+ // Create test data
+ authorId = new mongoose.Types.ObjectId();
+ otherUserId = new mongoose.Types.ObjectId();
+ fileId = uuidv4();
+
+ // Create users in database
+ await User.create({
+ _id: authorId,
+ username: 'author',
+ email: 'author@test.com',
+ });
+
+ await User.create({
+ _id: otherUserId,
+ username: 'other',
+ email: 'other@test.com',
+ });
+
+ // Create a file owned by the author
+ await createFile({
+ user: authorId,
+ file_id: fileId,
+ filename: 'test.txt',
+ filepath: '/uploads/test.txt',
+ bytes: 100,
+ type: 'text/plain',
+ });
+ });
+
+ describe('DELETE /files', () => {
+ it('should allow deleting files owned by the user', async () => {
+ // Create a file owned by the current user
+ const userFileId = uuidv4();
+ await createFile({
+ user: otherUserId,
+ file_id: userFileId,
+ filename: 'user-file.txt',
+ filepath: '/uploads/user-file.txt',
+ bytes: 200,
+ type: 'text/plain',
+ });
+
+ const response = await request(app)
+ .delete('/files')
+ .send({
+ files: [
+ {
+ file_id: userFileId,
+ filepath: '/uploads/user-file.txt',
+ },
+ ],
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.message).toBe('Files deleted successfully');
+ expect(processDeleteRequest).toHaveBeenCalled();
+ });
+
+ it('should prevent deleting files not owned by user without agent context', async () => {
+ const response = await request(app)
+ .delete('/files')
+ .send({
+ files: [
+ {
+ file_id: fileId,
+ filepath: '/uploads/test.txt',
+ },
+ ],
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.message).toBe('You can only delete files you own');
+ expect(response.body.unauthorizedFiles).toContain(fileId);
+ expect(processDeleteRequest).not.toHaveBeenCalled();
+ });
+
+ it('should prevent physically deleting non-owned files accessible through shared agent', async () => {
+ const agent = await createAgent({
+ id: uuidv4(),
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId],
+ },
+ },
+ });
+
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_EDITOR,
+ grantedBy: authorId,
+ });
+
+ const response = await request(app)
+ .delete('/files')
+ .send({
+ agent_id: agent.id,
+ files: [
+ {
+ file_id: fileId,
+ filepath: '/uploads/test.txt',
+ },
+ ],
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.message).toBe('You can only delete files you own');
+ expect(response.body.unauthorizedFiles).toContain(fileId);
+ expect(processDeleteRequest).not.toHaveBeenCalled();
+ });
+
+ it('unlinks attached agent files without invoking storage deletion', async () => {
+ const agent = await createAgent({
+ id: uuidv4(),
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId],
+ },
+ },
+ });
+
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_EDITOR,
+ grantedBy: authorId,
+ });
+
+ const response = await request(app)
+ .delete('/files')
+ .send({
+ agent_id: agent.id,
+ tool_resource: 'file_search',
+ files: [
+ {
+ file_id: fileId,
+ filepath: '/uploads/test.txt',
+ },
+ ],
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.message).toBe('File associations removed successfully from agent');
+ expect(processDeleteRequest).not.toHaveBeenCalled();
+
+ const updatedAgent = await Agent.findOne({ id: agent.id }).lean();
+ expect(updatedAgent.tool_resources.file_search.file_ids).toEqual([]);
+ });
+
+ it('rejects invalid agent tool_resource values before unlinking', async () => {
+ const agent = await createAgent({
+ id: uuidv4(),
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUserId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId],
+ },
+ },
+ });
+
+ const response = await request(app)
+ .delete('/files')
+ .send({
+ agent_id: agent.id,
+ tool_resource: 'file_search.$pullAll',
+ files: [{ file_id: fileId, filepath: '/uploads/test.txt' }],
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Invalid agent tool resource');
+ expect(processDeleteRequest).not.toHaveBeenCalled();
+
+ const updatedAgent = await Agent.findOne({ id: agent.id }).lean();
+ expect(updatedAgent.tool_resources.file_search.file_ids).toEqual([fileId]);
+ });
+
+ it('allows an agent author to unlink an editor-owned attached file', async () => {
+ const editorFileId = uuidv4();
+ await createFile({
+ user: otherUserId,
+ file_id: editorFileId,
+ filename: 'editor-file.txt',
+ filepath: '/uploads/editor-file.txt',
+ bytes: 300,
+ type: 'text/plain',
+ });
+
+ const agent = await createAgent({
+ id: uuidv4(),
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [editorFileId],
+ },
+ },
+ });
+
+ const authorApp = express();
+ authorApp.use(express.json());
+ authorApp.use((req, res, next) => {
+ req.user = {
+ id: authorId.toString(),
+ role: SystemRoles.USER,
+ };
+ req.app.locals = {};
+ next();
+ });
+ authorApp.use('/files', router);
+
+ const response = await request(authorApp)
+ .delete('/files')
+ .send({
+ agent_id: agent.id,
+ tool_resource: 'file_search',
+ files: [{ file_id: editorFileId, filepath: '/uploads/editor-file.txt' }],
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.message).toBe('File associations removed successfully from agent');
+ expect(processDeleteRequest).not.toHaveBeenCalled();
+
+ const updatedAgent = await Agent.findOne({ id: agent.id }).lean();
+ expect(updatedAgent.tool_resources.file_search.file_ids).toEqual([]);
+
+ const retainedFile = await File.findOne({ file_id: editorFileId }).lean();
+ expect(retainedFile).toBeTruthy();
+ });
+
+ it('should prevent physically deleting attached files owned by another user', async () => {
+ const thirdUserId = new mongoose.Types.ObjectId();
+ const thirdUserFileId = uuidv4();
+ await createFile({
+ user: thirdUserId,
+ file_id: thirdUserFileId,
+ filename: 'third-user-file.txt',
+ filepath: '/uploads/third-user-file.txt',
+ bytes: 300,
+ type: 'text/plain',
+ });
+
+ const agent = await createAgent({
+ id: uuidv4(),
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [thirdUserFileId],
+ },
+ },
+ });
+
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_EDITOR,
+ grantedBy: authorId,
+ });
+
+ const response = await request(app)
+ .delete('/files')
+ .send({
+ agent_id: agent.id,
+ files: [
+ {
+ file_id: thirdUserFileId,
+ filepath: '/uploads/third-user-file.txt',
+ },
+ ],
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.message).toBe('You can only delete files you own');
+ expect(response.body.unauthorizedFiles).toContain(thirdUserFileId);
+ expect(processDeleteRequest).not.toHaveBeenCalled();
+ });
+
+ it('should prevent physically deleting non-owned files not attached to the specified agent', async () => {
+ // Create another file not attached to the agent
+ const unattachedFileId = uuidv4();
+ await createFile({
+ user: authorId,
+ file_id: unattachedFileId,
+ filename: 'unattached.txt',
+ filepath: '/uploads/unattached.txt',
+ bytes: 300,
+ type: 'text/plain',
+ });
+
+ // Create an agent without the unattached file
+ const agent = await createAgent({
+ id: uuidv4(),
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId], // Only fileId, not unattachedFileId
+ },
+ },
+ });
+
+ // Grant EDIT permission to user on the agent
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_EDITOR,
+ grantedBy: authorId,
+ });
+
+ const response = await request(app)
+ .delete('/files')
+ .send({
+ agent_id: agent.id,
+ files: [
+ {
+ file_id: unattachedFileId,
+ filepath: '/uploads/unattached.txt',
+ },
+ ],
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.message).toBe('You can only delete files you own');
+ expect(response.body.unauthorizedFiles).toContain(unattachedFileId);
+ expect(processDeleteRequest).not.toHaveBeenCalled();
+ });
+
+ it('should handle mixed authorized and unauthorized files', async () => {
+ // Create a file owned by the current user
+ const userFileId = uuidv4();
+ await createFile({
+ user: otherUserId,
+ file_id: userFileId,
+ filename: 'user-file.txt',
+ filepath: '/uploads/user-file.txt',
+ bytes: 200,
+ type: 'text/plain',
+ });
+
+ // Create an unauthorized file
+ const unauthorizedFileId = uuidv4();
+ await createFile({
+ user: authorId,
+ file_id: unauthorizedFileId,
+ filename: 'unauthorized.txt',
+ filepath: '/uploads/unauthorized.txt',
+ bytes: 400,
+ type: 'text/plain',
+ });
+
+ // Create an agent with only fileId attached
+ const agent = await createAgent({
+ id: uuidv4(),
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId],
+ },
+ },
+ });
+
+ // Grant EDIT permission to user on the agent
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_EDITOR,
+ grantedBy: authorId,
+ });
+
+ const response = await request(app)
+ .delete('/files')
+ .send({
+ agent_id: agent.id,
+ files: [
+ { file_id: userFileId, filepath: '/uploads/user-file.txt' },
+ { file_id: fileId, filepath: '/uploads/test.txt' },
+ { file_id: unauthorizedFileId, filepath: '/uploads/unauthorized.txt' },
+ ],
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.message).toBe('You can only delete files you own');
+ expect(response.body.unauthorizedFiles).toContain(unauthorizedFileId);
+ expect(processDeleteRequest).not.toHaveBeenCalled();
+ });
+
+ it('should prevent unlinking attached files when user lacks EDIT permission on agent', async () => {
+ // Create an agent with the file attached
+ const agent = await createAgent({
+ id: uuidv4(),
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId],
+ },
+ },
+ });
+
+ // Grant only VIEW permission to user on the agent
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ grantedBy: authorId,
+ });
+
+ const response = await request(app)
+ .delete('/files')
+ .send({
+ agent_id: agent.id,
+ tool_resource: 'file_search',
+ files: [
+ {
+ file_id: fileId,
+ filepath: '/uploads/test.txt',
+ },
+ ],
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.message).toBe('You can only delete files you have access to');
+ expect(response.body.unauthorizedFiles).toContain(fileId);
+ expect(processDeleteRequest).not.toHaveBeenCalled();
+ });
+
+ it('unlinks missing agent resource files without invoking storage deletion', async () => {
+ const missingFileId = uuidv4();
+ const agent = await createAgent({
+ id: uuidv4(),
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUserId,
+ tool_resources: {
+ file_search: {
+ file_ids: [missingFileId],
+ },
+ },
+ });
+
+ const response = await request(app)
+ .delete('/files')
+ .send({
+ agent_id: agent.id,
+ tool_resource: 'file_search',
+ files: [{ file_id: missingFileId, filepath: '/uploads/missing.txt' }],
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.message).toBe('File associations removed successfully from agent');
+ expect(processDeleteRequest).not.toHaveBeenCalled();
+
+ const updatedAgent = await Agent.findOne({ id: agent.id }).lean();
+ expect(updatedAgent.tool_resources.file_search.file_ids).toEqual([]);
+ });
+
+ it('prevents unlinking missing agent resource files without agent edit access', async () => {
+ const missingFileId = uuidv4();
+ const agent = await createAgent({
+ id: uuidv4(),
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [missingFileId],
+ },
+ },
+ });
+
+ const response = await request(app)
+ .delete('/files')
+ .send({
+ agent_id: agent.id,
+ tool_resource: 'file_search',
+ files: [{ file_id: missingFileId, filepath: '/uploads/missing.txt' }],
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.message).toBe('You can only delete files you have access to');
+ expect(response.body.unauthorizedFiles).toContain(missingFileId);
+ expect(processDeleteRequest).not.toHaveBeenCalled();
+
+ const updatedAgent = await Agent.findOne({ id: agent.id }).lean();
+ expect(updatedAgent.tool_resources.file_search.file_ids).toEqual([missingFileId]);
+ });
+ });
+
+ describe('GET /files/download-url/:userId/:file_id', () => {
+ it('returns a direct signed download URL when the strategy supports it', async () => {
+ const userFileId = uuidv4();
+ const getDownloadURL = jest.fn().mockResolvedValue('https://cdn.example.com/file.pdf?signed');
+ getStrategyFunctions.mockReturnValue({ getDownloadURL });
+
+ await createFile({
+ user: otherUserId,
+ file_id: userFileId,
+ filename: 'file.pdf',
+ filepath: 'uploads/user/file.pdf',
+ storageKey: 'r/us-east-2/uploads/user/file.pdf',
+ storageRegion: 'us-east-2',
+ bytes: 200,
+ type: 'application/pdf',
+ source: FileSources.s3,
+ text: 'private extracted text',
+ });
+
+ const response = await request(app).get(`/files/download-url/${otherUserId}/${userFileId}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ url: 'https://cdn.example.com/file.pdf?signed',
+ filename: 'file.pdf',
+ type: 'application/pdf',
+ });
+ expect(response.headers['cache-control']).toBe('no-store');
+ expect(response.body.metadata).toMatchObject({
+ file_id: userFileId,
+ filename: 'file.pdf',
+ filepath: 'uploads/user/file.pdf',
+ storageKey: 'r/us-east-2/uploads/user/file.pdf',
+ storageRegion: 'us-east-2',
+ source: FileSources.s3,
+ });
+ expect(response.body.metadata).not.toHaveProperty('_id');
+ expect(response.body.metadata).not.toHaveProperty('__v');
+ expect(response.body.metadata).not.toHaveProperty('user');
+ expect(response.body.metadata).not.toHaveProperty('tenantId');
+ expect(response.body.metadata).not.toHaveProperty('text');
+ expect(getDownloadURL).toHaveBeenCalledWith(
+ expect.objectContaining({
+ file: expect.objectContaining({ file_id: userFileId }),
+ customFilename: 'file.pdf',
+ contentType: 'application/pdf',
+ }),
+ );
+ });
+
+ it('returns 501 when the strategy does not support direct URLs', async () => {
+ const userFileId = uuidv4();
+ getStrategyFunctions.mockReturnValue({});
+
+ await createFile({
+ user: otherUserId,
+ file_id: userFileId,
+ filename: 'file.txt',
+ filepath: 'uploads/user/file.txt',
+ bytes: 200,
+ type: 'text/plain',
+ source: FileSources.local,
+ });
+
+ const response = await request(app).get(`/files/download-url/${otherUserId}/${userFileId}`);
+
+ expect(response.status).toBe(501);
+ });
+
+ it('denies tenant-scoped files before issuing a signed URL', async () => {
+ const userFileId = uuidv4();
+ const getDownloadURL = jest.fn().mockResolvedValue('https://cdn.example.com/file.pdf?signed');
+ getStrategyFunctions.mockReturnValue({ getDownloadURL });
+
+ await tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
+ createFile({
+ user: otherUserId,
+ file_id: userFileId,
+ filename: 'file.pdf',
+ filepath: 'uploads/user/file.pdf',
+ bytes: 200,
+ type: 'application/pdf',
+ source: FileSources.s3,
+ tenantId: 'tenant-a',
+ }),
+ );
+
+ const response = await request(app).get(`/files/download-url/${otherUserId}/${userFileId}`);
+
+ expect(response.status).toBe(403);
+ expect(getDownloadURL).not.toHaveBeenCalled();
+ });
+
+ it('returns 500 when direct URL generation fails', async () => {
+ const userFileId = uuidv4();
+ const getDownloadURL = jest.fn().mockRejectedValue(new Error('signing failed'));
+ getStrategyFunctions.mockReturnValue({ getDownloadURL });
+
+ await createFile({
+ user: otherUserId,
+ file_id: userFileId,
+ filename: 'file.pdf',
+ filepath: 'uploads/user/file.pdf',
+ storageKey: 'r/us-east-2/uploads/user/file.pdf',
+ storageRegion: 'us-east-2',
+ bytes: 200,
+ type: 'application/pdf',
+ source: FileSources.s3,
+ });
+
+ const response = await request(app).get(`/files/download-url/${otherUserId}/${userFileId}`);
+
+ expect(response.status).toBe(500);
+ expect(response.text).toBe('Error generating file download URL');
+ });
+ });
+
+ describe('GET /files/download/:userId/:file_id', () => {
+ it('streams proxied downloads by default when a direct URL is available', async () => {
+ const userFileId = uuidv4();
+ const getDownloadURL = jest.fn().mockResolvedValue('https://cdn.example.com/file.pdf?signed');
+ const getDownloadStream = jest.fn().mockResolvedValue(Readable.from(['file content']));
+ getStrategyFunctions.mockReturnValue({ getDownloadURL, getDownloadStream });
+
+ await createFile({
+ user: otherUserId,
+ file_id: userFileId,
+ filename: 'file.pdf',
+ filepath: 'uploads/user/file.pdf',
+ storageKey: 'r/us-east-2/uploads/user/file.pdf',
+ storageRegion: 'us-east-2',
+ bytes: 200,
+ type: 'application/pdf',
+ source: FileSources.cloudfront,
+ text: 'private extracted text',
+ });
+
+ const response = await request(app).get(`/files/download/${otherUserId}/${userFileId}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body.toString()).toBe('file content');
+ expect(response.headers.location).toBeUndefined();
+ const metadata = JSON.parse(decodeURIComponent(response.headers['x-file-metadata']));
+ expect(metadata).toMatchObject({
+ file_id: userFileId,
+ filename: 'file.pdf',
+ filepath: 'uploads/user/file.pdf',
+ storageKey: 'r/us-east-2/uploads/user/file.pdf',
+ storageRegion: 'us-east-2',
+ source: FileSources.cloudfront,
+ });
+ expect(metadata).not.toHaveProperty('_id');
+ expect(metadata).not.toHaveProperty('__v');
+ expect(metadata).not.toHaveProperty('user');
+ expect(metadata).not.toHaveProperty('tenantId');
+ expect(metadata).not.toHaveProperty('text');
+ expect(getDownloadURL).not.toHaveBeenCalled();
+ expect(getDownloadStream).toHaveBeenCalledWith(
+ expect.any(Object),
+ 'r/us-east-2/uploads/user/file.pdf',
+ );
+ });
+
+ it('redirects to a direct signed download URL when explicitly requested', async () => {
+ const userFileId = uuidv4();
+ const getDownloadURL = jest.fn().mockResolvedValue('https://cdn.example.com/file.pdf?signed');
+ const getDownloadStream = jest.fn();
+ getStrategyFunctions.mockReturnValue({ getDownloadURL, getDownloadStream });
+
+ await createFile({
+ user: otherUserId,
+ file_id: userFileId,
+ filename: 'file.pdf',
+ filepath: 'uploads/user/file.pdf',
+ bytes: 200,
+ type: 'application/pdf',
+ source: FileSources.cloudfront,
+ });
+
+ const response = await request(app).get(
+ `/files/download/${otherUserId}/${userFileId}?direct=true`,
+ );
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe('https://cdn.example.com/file.pdf?signed');
+ expect(response.headers['x-file-metadata']).toBeUndefined();
+ expect(response.headers['cache-control']).toBe('no-store');
+ expect(getDownloadStream).not.toHaveBeenCalled();
+ });
+
+ it('falls back to streaming when direct URL generation fails', async () => {
+ const userFileId = uuidv4();
+ const getDownloadURL = jest.fn().mockRejectedValue(new Error('missing signing keys'));
+ const getDownloadStream = jest.fn().mockResolvedValue(Readable.from(['file content']));
+ getStrategyFunctions.mockReturnValue({ getDownloadURL, getDownloadStream });
+
+ await createFile({
+ user: otherUserId,
+ file_id: userFileId,
+ filename: 'file.txt',
+ filepath: 'uploads/user/file.txt',
+ bytes: 200,
+ type: 'text/plain',
+ source: FileSources.s3,
+ });
+
+ const response = await request(app).get(
+ `/files/download/${otherUserId}/${userFileId}?direct=true`,
+ );
+
+ expect(response.status).toBe(200);
+ expect(response.body.toString()).toBe('file content');
+ expect(response.headers.location).toBeUndefined();
+ expect(response.headers['cache-control']).toBeUndefined();
+ expect(getDownloadURL).toHaveBeenCalledWith(
+ expect.objectContaining({
+ file: expect.objectContaining({ file_id: userFileId }),
+ customFilename: 'file.txt',
+ contentType: 'text/plain',
+ }),
+ );
+ expect(getDownloadStream).toHaveBeenCalledWith(expect.any(Object), 'uploads/user/file.txt');
+ });
+
+ it('returns 501 when direct URL generation fails and no stream fallback exists', async () => {
+ const userFileId = uuidv4();
+ const getDownloadURL = jest.fn().mockRejectedValue(new Error('missing signing keys'));
+ getStrategyFunctions.mockReturnValue({ getDownloadURL });
+
+ await createFile({
+ user: otherUserId,
+ file_id: userFileId,
+ filename: 'file.txt',
+ filepath: 'uploads/user/file.txt',
+ bytes: 200,
+ type: 'text/plain',
+ source: FileSources.cloudfront,
+ });
+
+ const response = await request(app).get(
+ `/files/download/${otherUserId}/${userFileId}?direct=true`,
+ );
+
+ expect(response.status).toBe(501);
+ expect(response.text).toBe('Not Implemented');
+ expect(response.headers.location).toBeUndefined();
+ expect(getDownloadURL).toHaveBeenCalledWith(
+ expect.objectContaining({
+ file: expect.objectContaining({ file_id: userFileId }),
+ customFilename: 'file.txt',
+ contentType: 'text/plain',
+ }),
+ );
+ });
+ });
+});
diff --git a/api/server/routes/files/images.agents.test.js b/api/server/routes/files/images.agents.test.js
new file mode 100644
index 0000000..f855a43
--- /dev/null
+++ b/api/server/routes/files/images.agents.test.js
@@ -0,0 +1,376 @@
+const express = require('express');
+const request = require('supertest');
+const mongoose = require('mongoose');
+const { v4: uuidv4 } = require('uuid');
+const { createMethods } = require('@librechat/data-schemas');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const {
+ SystemRoles,
+ AccessRoleIds,
+ ResourceType,
+ PrincipalType,
+} = require('librechat-data-provider');
+const { createAgent } = require('~/models');
+
+jest.mock('~/server/services/Files/process', () => ({
+ processAgentFileUpload: jest.fn().mockImplementation(async ({ res }) => {
+ return res.status(200).json({ message: 'Agent file uploaded', file_id: 'test-file-id' });
+ }),
+ processImageFile: jest.fn().mockImplementation(async ({ res }) => {
+ return res.status(200).json({ message: 'Image processed' });
+ }),
+ filterFile: jest.fn(),
+}));
+
+jest.mock('fs', () => {
+ const actualFs = jest.requireActual('fs');
+ return {
+ ...actualFs,
+ promises: {
+ ...actualFs.promises,
+ unlink: jest.fn().mockResolvedValue(undefined),
+ },
+ };
+});
+
+const fs = require('fs');
+const { processAgentFileUpload } = require('~/server/services/Files/process');
+
+const router = require('~/server/routes/files/images');
+
+describe('POST /images - Agent Upload Permission Check (Integration)', () => {
+ let mongoServer;
+ let authorId;
+ let otherUserId;
+ let agentCustomId;
+ let User;
+ let Agent;
+ let AclEntry;
+ let methods;
+ let modelsToCleanup = [];
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+
+ const { createModels } = require('@librechat/data-schemas');
+ const models = createModels(mongoose);
+ modelsToCleanup = Object.keys(models);
+ Object.assign(mongoose.models, models);
+ methods = createMethods(mongoose);
+
+ User = models.User;
+ Agent = models.Agent;
+ AclEntry = models.AclEntry;
+
+ await methods.seedDefaultRoles();
+ });
+
+ afterAll(async () => {
+ const collections = mongoose.connection.collections;
+ for (const key in collections) {
+ await collections[key].deleteMany({});
+ }
+ for (const modelName of modelsToCleanup) {
+ if (mongoose.models[modelName]) {
+ delete mongoose.models[modelName];
+ }
+ }
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await Agent.deleteMany({});
+ await User.deleteMany({});
+ await AclEntry.deleteMany({});
+
+ authorId = new mongoose.Types.ObjectId();
+ otherUserId = new mongoose.Types.ObjectId();
+ agentCustomId = `agent_${uuidv4().replace(/-/g, '').substring(0, 21)}`;
+
+ await User.create({ _id: authorId, username: 'author', email: 'author@test.com' });
+ await User.create({ _id: otherUserId, username: 'other', email: 'other@test.com' });
+
+ jest.clearAllMocks();
+ });
+
+ const createAppWithUser = (userId, userRole = SystemRoles.USER) => {
+ const app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ if (req.method === 'POST') {
+ req.file = {
+ originalname: 'test.png',
+ mimetype: 'image/png',
+ size: 100,
+ path: '/tmp/t.png',
+ filename: 'test.png',
+ };
+ req.file_id = uuidv4();
+ }
+ next();
+ });
+ app.use((req, _res, next) => {
+ req.user = { id: userId.toString(), role: userRole };
+ req.app = { locals: {} };
+ req.config = { fileStrategy: 'local', paths: { imageOutput: '/tmp/images' } };
+ next();
+ });
+ app.use('/images', router);
+ return app;
+ };
+
+ it('should return 403 when user has no permission on agent', async () => {
+ await createAgent({
+ id: agentCustomId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ const app = createAppWithUser(otherUserId);
+ const response = await request(app).post('/images').send({
+ endpoint: 'agents',
+ agent_id: agentCustomId,
+ tool_resource: 'context',
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.error).toBe('Forbidden');
+ expect(processAgentFileUpload).not.toHaveBeenCalled();
+ expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/t.png');
+ });
+
+ it('should allow upload for agent owner', async () => {
+ await createAgent({
+ id: agentCustomId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ const app = createAppWithUser(authorId);
+ const response = await request(app).post('/images').send({
+ endpoint: 'agents',
+ agent_id: agentCustomId,
+ tool_resource: 'context',
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(200);
+ expect(processAgentFileUpload).toHaveBeenCalled();
+ });
+
+ it('should allow upload for admin regardless of ownership', async () => {
+ await createAgent({
+ id: agentCustomId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ const app = createAppWithUser(otherUserId, SystemRoles.ADMIN);
+ const response = await request(app).post('/images').send({
+ endpoint: 'agents',
+ agent_id: agentCustomId,
+ tool_resource: 'context',
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(200);
+ expect(processAgentFileUpload).toHaveBeenCalled();
+ });
+
+ it('should allow upload for user with EDIT permission', async () => {
+ const agent = await createAgent({
+ id: agentCustomId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_EDITOR,
+ grantedBy: authorId,
+ });
+
+ const app = createAppWithUser(otherUserId);
+ const response = await request(app).post('/images').send({
+ endpoint: 'agents',
+ agent_id: agentCustomId,
+ tool_resource: 'context',
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(200);
+ expect(processAgentFileUpload).toHaveBeenCalled();
+ });
+
+ it('should deny upload for user with only VIEW permission', async () => {
+ const agent = await createAgent({
+ id: agentCustomId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ grantedBy: authorId,
+ });
+
+ const app = createAppWithUser(otherUserId);
+ const response = await request(app).post('/images').send({
+ endpoint: 'agents',
+ agent_id: agentCustomId,
+ tool_resource: 'context',
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.error).toBe('Forbidden');
+ expect(processAgentFileUpload).not.toHaveBeenCalled();
+ expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/t.png');
+ });
+
+ it('should skip permission check for regular image uploads without agent_id/tool_resource', async () => {
+ const app = createAppWithUser(otherUserId);
+ const response = await request(app).post('/images').send({
+ endpoint: 'agents',
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(200);
+ });
+
+ it('should return 404 for non-existent agent', async () => {
+ const app = createAppWithUser(otherUserId);
+ const response = await request(app).post('/images').send({
+ endpoint: 'agents',
+ agent_id: 'agent_nonexistent123456789',
+ tool_resource: 'context',
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toBe('Not Found');
+ expect(processAgentFileUpload).not.toHaveBeenCalled();
+ expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/t.png');
+ });
+
+ it('should allow message_file attachment (boolean true) without EDIT permission', async () => {
+ const agent = await createAgent({
+ id: agentCustomId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ grantedBy: authorId,
+ });
+
+ const app = createAppWithUser(otherUserId);
+ const response = await request(app).post('/images').send({
+ endpoint: 'agents',
+ agent_id: agentCustomId,
+ tool_resource: 'context',
+ message_file: true,
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(200);
+ expect(processAgentFileUpload).toHaveBeenCalled();
+ });
+
+ it('should allow message_file attachment (string "true") without EDIT permission', async () => {
+ const agent = await createAgent({
+ id: agentCustomId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ grantedBy: authorId,
+ });
+
+ const app = createAppWithUser(otherUserId);
+ const response = await request(app).post('/images').send({
+ endpoint: 'agents',
+ agent_id: agentCustomId,
+ tool_resource: 'context',
+ message_file: 'true',
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(200);
+ expect(processAgentFileUpload).toHaveBeenCalled();
+ });
+
+ it('should deny upload when message_file is false (not a message attachment)', async () => {
+ const agent = await createAgent({
+ id: agentCustomId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ grantedBy: authorId,
+ });
+
+ const app = createAppWithUser(otherUserId);
+ const response = await request(app).post('/images').send({
+ endpoint: 'agents',
+ agent_id: agentCustomId,
+ tool_resource: 'context',
+ message_file: false,
+ file_id: uuidv4(),
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.error).toBe('Forbidden');
+ expect(processAgentFileUpload).not.toHaveBeenCalled();
+ expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/t.png');
+ });
+});
diff --git a/api/server/routes/files/images.js b/api/server/routes/files/images.js
new file mode 100644
index 0000000..353557d
--- /dev/null
+++ b/api/server/routes/files/images.js
@@ -0,0 +1,69 @@
+const path = require('path');
+const fs = require('fs').promises;
+const express = require('express');
+const { logger } = require('@librechat/data-schemas');
+const { verifyAgentUploadPermission, resolveUploadErrorMessage } = require('@librechat/api');
+const { isAssistantsEndpoint } = require('librechat-data-provider');
+const {
+ processAgentFileUpload,
+ processImageFile,
+ filterFile,
+} = require('~/server/services/Files/process');
+const { checkPermission } = require('~/server/services/PermissionService');
+const db = require('~/models');
+
+const router = express.Router();
+
+router.post('/', async (req, res) => {
+ const metadata = req.body;
+ const appConfig = req.config;
+
+ try {
+ filterFile({ req, image: true });
+
+ metadata.temp_file_id = metadata.file_id;
+ metadata.file_id = req.file_id;
+
+ if (!isAssistantsEndpoint(metadata.endpoint) && metadata.tool_resource != null) {
+ const denied = await verifyAgentUploadPermission({
+ req,
+ res,
+ metadata,
+ getAgent: db.getAgent,
+ checkPermission,
+ });
+ if (denied) {
+ return;
+ }
+ return await processAgentFileUpload({ req, res, metadata });
+ }
+
+ await processImageFile({ req, res, metadata });
+ } catch (error) {
+ // TODO: delete remote file if it exists
+ logger.error('[/files/images] Error processing file:', error);
+
+ const message = resolveUploadErrorMessage(error);
+
+ try {
+ const filepath = path.join(
+ appConfig.paths.imageOutput,
+ req.user.id,
+ path.basename(req.file.filename),
+ );
+ await fs.unlink(filepath);
+ } catch (error) {
+ logger.error('[/files/images] Error deleting file:', error);
+ }
+ res.status(500).json({ message });
+ } finally {
+ try {
+ await fs.unlink(req.file.path);
+ logger.debug('[/files/images] Temp. image upload file deleted');
+ } catch {
+ logger.debug('[/files/images] Temp. image upload file already deleted');
+ }
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/files/index.js b/api/server/routes/files/index.js
new file mode 100644
index 0000000..ec00750
--- /dev/null
+++ b/api/server/routes/files/index.js
@@ -0,0 +1,69 @@
+const express = require('express');
+const {
+ createFileLimiters,
+ configMiddleware,
+ requireJwtAuth,
+ uaParser,
+ checkBan,
+} = require('~/server/middleware');
+const { restoreTenantContextFromReq } = require('@librechat/api');
+const { avatar: asstAvatarRouter } = require('~/server/routes/assistants/v1');
+const { avatar: agentAvatarRouter } = require('~/server/routes/agents/v1');
+const { createMulterInstance } = require('./multer');
+
+const files = require('./files');
+const images = require('./images');
+const avatar = require('./avatar');
+const speech = require('./speech');
+
+const initialize = async () => {
+ const router = express.Router();
+ router.use(requireJwtAuth);
+ router.use(configMiddleware);
+ router.use(checkBan);
+ router.use(uaParser);
+
+ const upload = await createMulterInstance();
+ router.post('/speech/stt', upload.single('audio'), restoreTenantContextFromReq);
+
+ /* Important: speech route must be added before the upload limiters */
+ router.use('/speech', speech);
+
+ const { fileUploadIpLimiter, fileUploadUserLimiter } = createFileLimiters();
+
+ /** Apply rate limiters to all POST routes (excluding /speech which is handled above) */
+ router.use((req, res, next) => {
+ if (req.method === 'POST' && !req.path.startsWith('/speech')) {
+ return fileUploadIpLimiter(req, res, (err) => {
+ if (err) {
+ return next(err);
+ }
+ return fileUploadUserLimiter(req, res, next);
+ });
+ }
+ next();
+ });
+
+ router.post('/', upload.single('file'), restoreTenantContextFromReq);
+ router.post('/images', upload.single('file'), restoreTenantContextFromReq);
+ router.post('/images/avatar', upload.single('file'), restoreTenantContextFromReq);
+ router.post(
+ '/images/agents/:agent_id/avatar',
+ upload.single('file'),
+ restoreTenantContextFromReq,
+ );
+ router.post(
+ '/images/assistants/:assistant_id/avatar',
+ upload.single('file'),
+ restoreTenantContextFromReq,
+ );
+
+ router.use('/', files);
+ router.use('/images', images);
+ router.use('/images/avatar', avatar);
+ router.use('/images/agents', agentAvatarRouter);
+ router.use('/images/assistants', asstAvatarRouter);
+ return router;
+};
+
+module.exports = { initialize };
diff --git a/api/server/routes/files/index.tenant.test.js b/api/server/routes/files/index.tenant.test.js
new file mode 100644
index 0000000..9b703d7
--- /dev/null
+++ b/api/server/routes/files/index.tenant.test.js
@@ -0,0 +1,151 @@
+process.env.TENANT_ISOLATION_STRICT = 'true';
+
+const express = require('express');
+const request = require('supertest');
+const fs = require('fs/promises');
+const { getTenantId, tenantStorage: mockTenantStorage } = require('@librechat/data-schemas');
+
+const TEST_TENANT = 'tenant-files-strict';
+
+let mockCurrentUser;
+
+jest.mock('fs/promises', () => ({
+ unlink: jest.fn().mockResolvedValue(undefined),
+}));
+
+const mockTenantResponse = (route) => (req, res) =>
+ res.status(200).json({ route, tenantId: getTenantId() });
+
+jest.mock('~/server/middleware', () => ({
+ createFileLimiters: jest.fn(() => ({
+ fileUploadIpLimiter: (req, res, next) => next(),
+ fileUploadUserLimiter: (req, res, next) => next(),
+ })),
+ configMiddleware: (req, res, next) => {
+ req.config = {
+ fileStrategy: 'local',
+ paths: { uploads: '/tmp/uploads', images: '/tmp/images' },
+ };
+ next();
+ },
+ requireJwtAuth: (req, res, next) => {
+ req.user = mockCurrentUser;
+ const tenantId = req.user?.tenantId;
+ if (!tenantId) {
+ next();
+ return;
+ }
+ mockTenantStorage.run({ tenantId }, async () => next());
+ },
+ uaParser: (req, res, next) => next(),
+ checkBan: (req, res, next) => next(),
+}));
+
+jest.mock('./multer', () => ({
+ createMulterInstance: jest.fn(async () => ({
+ single: jest.fn(() => (req, res, next) => {
+ req.file = {
+ path: '/tmp/uploaded-file',
+ originalname: 'uploaded.txt',
+ filename: 'uploaded.txt',
+ mimetype: 'text/plain',
+ size: 8,
+ };
+ req.file_id = 'file-upload-id';
+ mockTenantStorage.enterWith({});
+ next();
+ }),
+ })),
+}));
+
+jest.mock('./files', () => {
+ const express = require('express');
+ const router = express.Router();
+ router.post('/', mockTenantResponse('files'));
+ return router;
+});
+
+jest.mock('./images', () => {
+ const express = require('express');
+ const router = express.Router();
+ router.post('/', mockTenantResponse('images'));
+ return router;
+});
+
+jest.mock('./avatar', () => {
+ const express = require('express');
+ const router = express.Router();
+ router.post('/', mockTenantResponse('avatar'));
+ return router;
+});
+
+jest.mock('./speech', () => {
+ const express = require('express');
+ const router = express.Router();
+ router.post('/stt', mockTenantResponse('speech-stt'));
+ return router;
+});
+
+jest.mock('~/server/routes/agents/v1', () => {
+ const express = require('express');
+ const avatar = express.Router();
+ avatar.post('/:agent_id/avatar/', mockTenantResponse('agent-avatar'));
+ return { avatar };
+});
+
+jest.mock('~/server/routes/assistants/v1', () => {
+ const express = require('express');
+ const avatar = express.Router();
+ avatar.post('/:assistant_id/avatar/', mockTenantResponse('assistant-avatar'));
+ return { avatar };
+});
+
+describe('file upload routes restore strict isolation context after multer', () => {
+ let app;
+
+ beforeAll(async () => {
+ const { initialize } = require('./index');
+ app = express();
+ app.use('/api/files', await initialize());
+ });
+
+ beforeEach(() => {
+ fs.unlink.mockClear();
+ mockCurrentUser = {
+ id: 'user-files-strict',
+ role: 'USER',
+ tenantId: TEST_TENANT,
+ };
+ });
+
+ afterAll(() => {
+ delete process.env.TENANT_ISOLATION_STRICT;
+ });
+
+ it.each([
+ ['files', '/api/files'],
+ ['images', '/api/files/images'],
+ ['avatar', '/api/files/images/avatar'],
+ ['agent-avatar', '/api/files/images/agents/agent-1/avatar'],
+ ['assistant-avatar', '/api/files/images/assistants/asst-1/avatar'],
+ ['speech-stt', '/api/files/speech/stt'],
+ ])('restores context for %s upload', async (route, url) => {
+ const res = await request(app).post(url);
+
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({ route, tenantId: TEST_TENANT });
+ });
+
+ it('rejects strict upload continuations when no tenant can be resolved', async () => {
+ mockCurrentUser = {
+ id: 'user-without-tenant',
+ role: 'USER',
+ };
+
+ const res = await request(app).post('/api/files');
+
+ expect(res.status).toBe(403);
+ expect(res.body.error).toMatch(/Tenant context required/);
+ expect(fs.unlink).toHaveBeenCalledWith('/tmp/uploaded-file');
+ });
+});
diff --git a/api/server/routes/files/multer.js b/api/server/routes/files/multer.js
new file mode 100644
index 0000000..da17ce8
--- /dev/null
+++ b/api/server/routes/files/multer.js
@@ -0,0 +1,99 @@
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const multer = require('multer');
+const { sanitizeFilename } = require('@librechat/api');
+const {
+ mergeFileConfig,
+ inferMimeType,
+ getEndpointFileConfig,
+ fileConfig: defaultFileConfig,
+} = require('librechat-data-provider');
+const { getAppConfig } = require('~/server/services/Config');
+
+const storage = multer.diskStorage({
+ destination: function (req, file, cb) {
+ const appConfig = req.config;
+ const outputPath = path.join(appConfig.paths.uploads, 'temp', req.user.id);
+ if (!fs.existsSync(outputPath)) {
+ fs.mkdirSync(outputPath, { recursive: true });
+ }
+ cb(null, outputPath);
+ },
+ filename: function (req, file, cb) {
+ req.file_id = crypto.randomUUID();
+ file.originalname = decodeURIComponent(file.originalname);
+ const sanitizedFilename = sanitizeFilename(file.originalname);
+ cb(null, sanitizedFilename);
+ },
+});
+
+const importFileFilter = (req, file, cb) => {
+ if (file.mimetype === 'application/json') {
+ cb(null, true);
+ } else if (path.extname(file.originalname).toLowerCase() === '.json') {
+ cb(null, true);
+ } else {
+ cb(new Error('Only JSON files are allowed'), false);
+ }
+};
+
+const normalizeUploadMimeType = (file) => {
+ const mimeType = inferMimeType(file.originalname || '', file.mimetype || '');
+ if (mimeType && file.mimetype !== mimeType) {
+ file.mimetype = mimeType;
+ }
+ return mimeType;
+};
+
+/**
+ *
+ * @param {import('librechat-data-provider').FileConfig | undefined} customFileConfig
+ */
+const createFileFilter = (customFileConfig) => {
+ /**
+ * @param {ServerRequest} req
+ * @param {Express.Multer.File}
+ * @param {import('multer').FileFilterCallback} cb
+ */
+ const fileFilter = (req, file, cb) => {
+ if (!file) {
+ return cb(new Error('No file provided'), false);
+ }
+
+ const mimeType = normalizeUploadMimeType(file);
+
+ if (req.originalUrl.endsWith('/speech/stt') && mimeType.startsWith('audio/')) {
+ return cb(null, true);
+ }
+
+ const endpoint = req.body.endpoint;
+ const endpointType = req.body.endpointType;
+ const endpointFileConfig = getEndpointFileConfig({
+ fileConfig: customFileConfig,
+ endpoint,
+ endpointType,
+ });
+
+ if (!defaultFileConfig.checkType(mimeType, endpointFileConfig.supportedMimeTypes)) {
+ return cb(new Error('Unsupported file type: ' + (file.mimetype || mimeType)), false);
+ }
+
+ cb(null, true);
+ };
+
+ return fileFilter;
+};
+
+const createMulterInstance = async () => {
+ const appConfig = await getAppConfig();
+ const fileConfig = mergeFileConfig(appConfig?.fileConfig);
+ const fileFilter = createFileFilter(fileConfig);
+ return multer({
+ storage,
+ fileFilter,
+ limits: { fileSize: fileConfig.serverFileSizeLimit },
+ });
+};
+
+module.exports = { createMulterInstance, storage, importFileFilter, createFileFilter };
diff --git a/api/server/routes/files/multer.spec.js b/api/server/routes/files/multer.spec.js
new file mode 100644
index 0000000..23b7a24
--- /dev/null
+++ b/api/server/routes/files/multer.spec.js
@@ -0,0 +1,568 @@
+/* eslint-disable no-unused-vars */
+/* eslint-disable jest/no-done-callback */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const crypto = require('crypto');
+const { createMulterInstance, storage, importFileFilter, createFileFilter } = require('./multer');
+
+// Mock only the config service that requires external dependencies
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn(),
+}));
+
+describe('Multer Configuration', () => {
+ let tempDir;
+ let mockReq;
+ let mockFile;
+
+ beforeEach(() => {
+ // Create a temporary directory for each test
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'multer-test-'));
+
+ mockReq = {
+ user: { id: 'test-user-123' },
+ body: {},
+ originalUrl: '/api/files/upload',
+ config: {
+ paths: {
+ uploads: tempDir,
+ },
+ },
+ };
+
+ mockFile = {
+ originalname: 'test-file.jpg',
+ mimetype: 'image/jpeg',
+ size: 1024,
+ };
+
+ // Clear mocks
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ // Clean up temporary directory
+ if (fs.existsSync(tempDir)) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ describe('Storage Configuration', () => {
+ describe('destination function', () => {
+ it('should create the correct destination path', (done) => {
+ const cb = jest.fn((err, destination) => {
+ expect(err).toBeNull();
+ expect(destination).toBe(path.join(tempDir, 'temp', 'test-user-123'));
+ expect(fs.existsSync(destination)).toBe(true);
+ done();
+ });
+
+ storage.getDestination(mockReq, mockFile, cb);
+ });
+
+ it("should create directory recursively if it doesn't exist", (done) => {
+ const deepPath = path.join(tempDir, 'deep', 'nested', 'path');
+ mockReq.config.paths.uploads = deepPath;
+
+ const cb = jest.fn((err, destination) => {
+ expect(err).toBeNull();
+ expect(destination).toBe(path.join(deepPath, 'temp', 'test-user-123'));
+ expect(fs.existsSync(destination)).toBe(true);
+ done();
+ });
+
+ storage.getDestination(mockReq, mockFile, cb);
+ });
+ });
+
+ describe('filename function', () => {
+ it('should generate a UUID for req.file_id', (done) => {
+ const cb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ expect(mockReq.file_id).toBeDefined();
+ expect(mockReq.file_id).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
+ );
+ done();
+ });
+
+ storage.getFilename(mockReq, mockFile, cb);
+ });
+
+ it('should decode URI components in filename', (done) => {
+ const encodedFile = {
+ ...mockFile,
+ originalname: encodeURIComponent('test file with spaces.jpg'),
+ };
+
+ const cb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ expect(encodedFile.originalname).toBe('test file with spaces.jpg');
+ done();
+ });
+
+ storage.getFilename(mockReq, encodedFile, cb);
+ });
+
+ it('should call real sanitizeFilename with properly encoded filename', (done) => {
+ // Test with a properly URI-encoded filename that needs sanitization
+ const unsafeFile = {
+ ...mockFile,
+ originalname: encodeURIComponent('test@#$%^&*()file with spaces!.jpg'),
+ };
+
+ const cb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ // The actual sanitizeFilename should have cleaned this up after decoding
+ expect(filename).not.toContain('@');
+ expect(filename).not.toContain('#');
+ expect(filename).not.toContain('*');
+ expect(filename).not.toContain('!');
+ // Should still preserve dots and hyphens
+ expect(filename).toContain('.jpg');
+ done();
+ });
+
+ storage.getFilename(mockReq, unsafeFile, cb);
+ });
+
+ it('should handle very long filenames with actual crypto', (done) => {
+ const longFile = {
+ ...mockFile,
+ originalname: 'a'.repeat(300) + '.jpg',
+ };
+
+ const cb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ expect(filename.length).toBeLessThanOrEqual(255);
+ expect(filename).toMatch(/\.jpg$/); // Should still end with .jpg
+ // Should contain a hex suffix if truncated
+ if (filename.length === 255) {
+ expect(filename).toMatch(/-[a-f0-9]{6}\.jpg$/);
+ }
+ done();
+ });
+
+ storage.getFilename(mockReq, longFile, cb);
+ });
+
+ it('should generate unique file_id for each call', (done) => {
+ let firstFileId;
+
+ const firstCb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ firstFileId = mockReq.file_id;
+
+ // Reset req for second call
+ delete mockReq.file_id;
+
+ const secondCb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ expect(mockReq.file_id).toBeDefined();
+ expect(mockReq.file_id).not.toBe(firstFileId);
+ done();
+ });
+
+ storage.getFilename(mockReq, mockFile, secondCb);
+ });
+
+ storage.getFilename(mockReq, mockFile, firstCb);
+ });
+ });
+ });
+
+ describe('Import File Filter', () => {
+ it('should accept JSON files by mimetype', (done) => {
+ const jsonFile = {
+ ...mockFile,
+ mimetype: 'application/json',
+ originalname: 'data.json',
+ };
+
+ const cb = jest.fn((err, result) => {
+ expect(err).toBeNull();
+ expect(result).toBe(true);
+ done();
+ });
+
+ importFileFilter(mockReq, jsonFile, cb);
+ });
+
+ it('should accept files with .json extension', (done) => {
+ const jsonFile = {
+ ...mockFile,
+ mimetype: 'text/plain',
+ originalname: 'data.json',
+ };
+
+ const cb = jest.fn((err, result) => {
+ expect(err).toBeNull();
+ expect(result).toBe(true);
+ done();
+ });
+
+ importFileFilter(mockReq, jsonFile, cb);
+ });
+
+ it('should reject non-JSON files', (done) => {
+ const textFile = {
+ ...mockFile,
+ mimetype: 'text/plain',
+ originalname: 'document.txt',
+ };
+
+ const cb = jest.fn((err, result) => {
+ expect(err).toBeInstanceOf(Error);
+ expect(err.message).toBe('Only JSON files are allowed');
+ expect(result).toBe(false);
+ done();
+ });
+
+ importFileFilter(mockReq, textFile, cb);
+ });
+
+ it('should handle files with uppercase .JSON extension', (done) => {
+ const jsonFile = {
+ ...mockFile,
+ mimetype: 'text/plain',
+ originalname: 'DATA.JSON',
+ };
+
+ const cb = jest.fn((err, result) => {
+ expect(err).toBeNull();
+ expect(result).toBe(true);
+ done();
+ });
+
+ importFileFilter(mockReq, jsonFile, cb);
+ });
+ });
+
+ describe('File Filter with Real defaultFileConfig', () => {
+ it('should use real fileConfig.checkType for validation', async () => {
+ // Test with actual librechat-data-provider functions
+ const {
+ fileConfig,
+ imageMimeTypes,
+ applicationMimeTypes,
+ } = require('librechat-data-provider');
+
+ // Test that the real checkType function works with regex patterns
+ expect(fileConfig.checkType('image/jpeg', [imageMimeTypes])).toBe(true);
+ expect(fileConfig.checkType('video/mp4', [imageMimeTypes])).toBe(false);
+ expect(fileConfig.checkType('application/pdf', [applicationMimeTypes])).toBe(true);
+ expect(fileConfig.checkType('application/pdf', [])).toBe(false);
+ });
+
+ it('should handle audio files for speech-to-text endpoint with real config', async () => {
+ mockReq.originalUrl = '/api/speech/stt';
+
+ const multerInstance = await createMulterInstance();
+ expect(multerInstance).toBeDefined();
+ expect(typeof multerInstance.single).toBe('function');
+ });
+
+ it('should reject unsupported file types using real config', async () => {
+ // Mock defaultFileConfig for this specific test
+ const originalCheckType = require('librechat-data-provider').fileConfig.checkType;
+ const mockCheckType = jest.fn().mockReturnValue(false);
+ require('librechat-data-provider').fileConfig.checkType = mockCheckType;
+
+ try {
+ const multerInstance = await createMulterInstance();
+ expect(multerInstance).toBeDefined();
+
+ // Test the actual file filter behavior would reject unsupported files
+ expect(mockCheckType).toBeDefined();
+ } finally {
+ // Restore original function
+ require('librechat-data-provider').fileConfig.checkType = originalCheckType;
+ }
+ });
+
+ it('should infer ZIP MIME type when multipart upload omits it', (done) => {
+ const { mergeFileConfig } = require('librechat-data-provider');
+ const fileFilter = createFileFilter(mergeFileConfig());
+ const zipFile = {
+ ...mockFile,
+ originalname: 'archive.zip',
+ mimetype: '',
+ };
+
+ const cb = jest.fn((err, result) => {
+ expect(err).toBeNull();
+ expect(result).toBe(true);
+ expect(zipFile.mimetype).toBe('application/zip');
+ done();
+ });
+
+ fileFilter(mockReq, zipFile, cb);
+ });
+
+ it('should use real mergeFileConfig function', async () => {
+ const { mergeFileConfig, mbToBytes } = require('librechat-data-provider');
+
+ // Test with actual merge function - note that it converts MB to bytes
+ const testConfig = {
+ serverFileSizeLimit: 5, // 5 MB
+ endpoints: {
+ custom: {
+ supportedMimeTypes: ['text/plain'],
+ },
+ },
+ };
+
+ const result = mergeFileConfig(testConfig);
+
+ // The function converts MB to bytes, so 5 MB becomes 5 * 1024 * 1024 bytes
+ expect(result.serverFileSizeLimit).toBe(mbToBytes(5));
+ expect(result.endpoints.custom.supportedMimeTypes).toBeDefined();
+ // Should still have the default endpoints
+ expect(result.endpoints.default).toBeDefined();
+ });
+ });
+
+ describe('createMulterInstance with Real Functions', () => {
+ it('should create a multer instance with correct configuration', async () => {
+ const multerInstance = await createMulterInstance();
+
+ expect(multerInstance).toBeDefined();
+ expect(typeof multerInstance.single).toBe('function');
+ expect(typeof multerInstance.array).toBe('function');
+ expect(typeof multerInstance.fields).toBe('function');
+ });
+
+ it('should use real config merging', async () => {
+ const { getAppConfig } = require('~/server/services/Config');
+
+ const multerInstance = await createMulterInstance();
+
+ expect(getAppConfig).toHaveBeenCalled();
+ expect(multerInstance).toBeDefined();
+ });
+
+ it('should create multer instance with expected interface', async () => {
+ const multerInstance = await createMulterInstance();
+
+ expect(multerInstance).toBeDefined();
+ expect(typeof multerInstance.single).toBe('function');
+ expect(typeof multerInstance.array).toBe('function');
+ expect(typeof multerInstance.fields).toBe('function');
+ });
+ });
+
+ describe('Real Crypto Integration', () => {
+ it('should use actual crypto.randomUUID()', (done) => {
+ // Spy on crypto.randomUUID to ensure it's called
+ const uuidSpy = jest.spyOn(crypto, 'randomUUID');
+
+ const cb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ expect(uuidSpy).toHaveBeenCalled();
+ expect(mockReq.file_id).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
+ );
+
+ uuidSpy.mockRestore();
+ done();
+ });
+
+ storage.getFilename(mockReq, mockFile, cb);
+ });
+
+ it('should generate different UUIDs on subsequent calls', (done) => {
+ const uuids = [];
+ let callCount = 0;
+ const totalCalls = 5;
+
+ const cb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ uuids.push(mockReq.file_id);
+ callCount++;
+
+ if (callCount === totalCalls) {
+ // Check that all UUIDs are unique
+ const uniqueUuids = new Set(uuids);
+ expect(uniqueUuids.size).toBe(totalCalls);
+ done();
+ } else {
+ // Reset for next call
+ delete mockReq.file_id;
+ storage.getFilename(mockReq, mockFile, cb);
+ }
+ });
+
+ // Start the chain
+ storage.getFilename(mockReq, mockFile, cb);
+ });
+
+ it('should generate cryptographically secure UUIDs', (done) => {
+ const generatedUuids = new Set();
+ let callCount = 0;
+ const totalCalls = 10;
+
+ const cb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+
+ // Verify UUID format and uniqueness
+ expect(mockReq.file_id).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
+ );
+
+ generatedUuids.add(mockReq.file_id);
+ callCount++;
+
+ if (callCount === totalCalls) {
+ // All UUIDs should be unique
+ expect(generatedUuids.size).toBe(totalCalls);
+ done();
+ } else {
+ // Reset for next call
+ delete mockReq.file_id;
+ storage.getFilename(mockReq, mockFile, cb);
+ }
+ });
+
+ // Start the chain
+ storage.getFilename(mockReq, mockFile, cb);
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('should handle CVE-2024-28870: empty field name DoS vulnerability', async () => {
+ // Test for the CVE where empty field name could cause unhandled exception
+ const multerInstance = await createMulterInstance();
+
+ // Create a mock request with empty field name (the vulnerability scenario)
+ const mockReqWithEmptyField = {
+ ...mockReq,
+ headers: {
+ 'content-type': 'multipart/form-data',
+ },
+ };
+
+ const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ end: jest.fn(),
+ };
+
+ // This should not crash or throw unhandled exceptions
+ const uploadMiddleware = multerInstance.single(''); // Empty field name
+
+ const mockNext = jest.fn((err) => {
+ // If there's an error, it should be handled gracefully, not crash
+ if (err) {
+ expect(err).toBeInstanceOf(Error);
+ // The error should be handled, not crash the process
+ }
+ });
+
+ // This should complete without crashing the process
+ expect(() => {
+ uploadMiddleware(mockReqWithEmptyField, mockRes, mockNext);
+ }).not.toThrow();
+ });
+
+ it('should handle file system errors when directory creation fails', () => {
+ // Test with a non-existent parent directory to simulate fs issues
+ const invalidPath = '/nonexistent/path/that/should/not/exist';
+ mockReq.config.paths.uploads = invalidPath;
+
+ // The current implementation doesn't catch errors, so they're thrown synchronously
+ expect(() => {
+ storage.getDestination(mockReq, mockFile, jest.fn());
+ }).toThrow();
+ });
+
+ it('should handle malformed filenames with real sanitization', (done) => {
+ const malformedFile = {
+ ...mockFile,
+ originalname: null, // This should be handled gracefully
+ };
+
+ const cb = jest.fn((err, filename) => {
+ // The function should handle this gracefully
+ expect(typeof err === 'object' || err === null).toBe(true);
+ done();
+ });
+
+ try {
+ storage.getFilename(mockReq, malformedFile, cb);
+ } catch (error) {
+ // If it throws, that's also acceptable behavior
+ done();
+ }
+ });
+
+ it('should handle edge cases in filename sanitization', (done) => {
+ const edgeCaseFiles = [
+ { originalname: '', expected: /_/ },
+ { originalname: '.hidden', expected: /^_\.hidden/ },
+ { originalname: '../../../etc/passwd', expected: /passwd/ },
+ { originalname: 'file\x00name.txt', expected: /file_name\.txt/ },
+ ];
+
+ let testCount = 0;
+
+ const testNextFile = (fileData) => {
+ const fileToTest = { ...mockFile, originalname: fileData.originalname };
+
+ const cb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ expect(filename).toMatch(fileData.expected);
+
+ testCount++;
+ if (testCount === edgeCaseFiles.length) {
+ done();
+ } else {
+ testNextFile(edgeCaseFiles[testCount]);
+ }
+ });
+
+ storage.getFilename(mockReq, fileToTest, cb);
+ };
+
+ testNextFile(edgeCaseFiles[0]);
+ });
+ });
+
+ describe('Real Configuration Testing', () => {
+ it('should handle missing custom config gracefully with real mergeFileConfig', async () => {
+ const { getAppConfig } = require('~/server/services/Config');
+
+ // Mock getAppConfig to return undefined
+ getAppConfig.mockResolvedValueOnce(undefined);
+
+ const multerInstance = await createMulterInstance();
+ expect(multerInstance).toBeDefined();
+ expect(typeof multerInstance.single).toBe('function');
+ });
+
+ it('should properly integrate real fileConfig with custom endpoints', async () => {
+ const { getAppConfig } = require('~/server/services/Config');
+
+ // Mock appConfig with fileConfig
+ getAppConfig.mockResolvedValueOnce({
+ paths: {
+ uploads: tempDir,
+ },
+ fileConfig: {
+ endpoints: {
+ anthropic: {
+ supportedMimeTypes: ['text/plain', 'image/png'],
+ },
+ },
+ serverFileSizeLimit: 20971520, // 20 MB in bytes (mergeFileConfig converts)
+ },
+ });
+
+ const multerInstance = await createMulterInstance();
+ expect(multerInstance).toBeDefined();
+
+ // Verify that getAppConfig was called
+ expect(getAppConfig).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/api/server/routes/files/preview.spec.js b/api/server/routes/files/preview.spec.js
new file mode 100644
index 0000000..3363c1b
--- /dev/null
+++ b/api/server/routes/files/preview.spec.js
@@ -0,0 +1,406 @@
+/**
+ * Coverage for the new GET /files/:file_id/preview endpoint.
+ *
+ * Deferred-preview code-execution flow: the immediate persist step
+ * emits a file record at `status: 'pending'`; the background render
+ * transitions it to `'ready'` (with text) or `'failed'` (with
+ * previewError). The frontend polls this endpoint until status is
+ * terminal. This suite asserts the response shape across all four
+ * states (pending, ready, failed, legacy/back-compat) and the auth
+ * boundary (404 vs 403).
+ */
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn(), info: jest.fn() },
+ SystemCapabilities: {},
+}));
+
+jest.mock('@librechat/api', () => ({
+ refreshS3FileUrls: jest.fn(),
+ resolveUploadErrorMessage: jest.fn(),
+ verifyAgentUploadPermission: jest.fn(),
+}));
+
+const mockFindFileById = jest.fn();
+const mockGetFiles = jest.fn();
+const mockUpdateFile = jest.fn();
+const mockGetAgents = jest.fn().mockResolvedValue([]);
+jest.mock('~/models', () => ({
+ findFileById: (...args) => mockFindFileById(...args),
+ getFiles: (...args) => mockGetFiles(...args),
+ updateFile: (...args) => mockUpdateFile(...args),
+ getAgents: (...args) => mockGetAgents(...args),
+ batchUpdateFiles: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/process', () => ({
+ filterFile: jest.fn(),
+ processFileUpload: jest.fn(),
+ processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }),
+ processAgentFileUpload: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn(() => ({})),
+}));
+
+jest.mock('~/server/controllers/assistants/helpers', () => ({
+ getOpenAIClient: jest.fn(),
+}));
+
+jest.mock('~/server/middleware/roles/capabilities', () => ({
+ hasCapability: jest.fn(() => (_req, _res, next) => next()),
+}));
+
+jest.mock('~/server/services/PermissionService', () => ({
+ checkPermission: jest.fn(() => (_req, _res, next) => next()),
+ getEffectivePermissions: jest.fn().mockResolvedValue(0),
+}));
+
+jest.mock('~/server/services/Files', () => ({
+ hasAccessToFilesViaAgent: jest.fn(),
+}));
+
+jest.mock('~/server/utils/files', () => ({
+ cleanFileName: (name) => name,
+ getContentDisposition: (name) => `attachment; filename="${name}"`,
+}));
+
+jest.mock('~/cache', () => ({
+ getLogStores: jest.fn(() => ({ get: jest.fn(), set: jest.fn() })),
+}));
+
+const express = require('express');
+const request = require('supertest');
+const filesRouter = require('./files');
+
+/**
+ * Mount the router with a per-request user injector so we can simulate
+ * a logged-in user without spinning up the full auth stack.
+ */
+function buildApp({ user = { id: 'user-123', role: 'user' } } = {}) {
+ const app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ req.user = user;
+ req.config = { fileStrategy: 'local' };
+ next();
+ });
+ app.use('/files', filesRouter);
+ return app;
+}
+
+const OWNER_USER_ID = 'user-123';
+
+describe('GET /files/:file_id/preview', () => {
+ beforeEach(() => {
+ mockFindFileById.mockReset();
+ mockGetFiles.mockReset();
+ mockUpdateFile.mockReset();
+ mockGetAgents.mockReset();
+ mockGetAgents.mockResolvedValue([]);
+ });
+
+ it('returns 404 when the file does not exist (auth check fails first via fileAccess)', async () => {
+ /* `fileAccess` middleware does its own getFiles lookup and returns
+ * 404 before our handler ever runs. This test asserts the boundary
+ * lives there, not that the handler duplicates the check. */
+ mockGetFiles.mockResolvedValueOnce([]);
+ const res = await request(buildApp()).get('/files/missing-id/preview');
+ expect(res.status).toBe(404);
+ expect(res.body).toMatchObject({ error: 'Not Found' });
+ expect(mockFindFileById).not.toHaveBeenCalled();
+ });
+
+ it('returns 403 when the requester does not own the file and has no agent-based access', async () => {
+ /* fileAccess returns 403 — the file exists but belongs to someone
+ * else and no agent grants access. The preview handler should
+ * never run. */
+ mockGetFiles.mockResolvedValueOnce([
+ { file_id: 'someone-elses', user: 'other-user', filename: 'x.xlsx' },
+ ]);
+ const res = await request(buildApp()).get('/files/someone-elses/preview');
+ expect(res.status).toBe(403);
+ expect(mockFindFileById).not.toHaveBeenCalled();
+ });
+
+ it('allows preview text through an attached agent file reference', async () => {
+ mockGetFiles.mockResolvedValueOnce([
+ { file_id: 'victim-file', user: 'victim-user', filename: 'secret.xlsx', status: 'ready' },
+ ]);
+ mockGetAgents.mockResolvedValueOnce([
+ {
+ id: 'agent-attacker',
+ author: 'attacker-user',
+ tool_resources: { execute_code: { file_ids: ['victim-file'] } },
+ },
+ ]);
+ mockFindFileById.mockResolvedValueOnce({
+ file_id: 'victim-file',
+ text: 'shared secret',
+ textFormat: 'text',
+ });
+
+ const res = await request(buildApp({ user: { id: 'attacker-user', role: 'user' } })).get(
+ '/files/victim-file/preview',
+ );
+
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({
+ file_id: 'victim-file',
+ status: 'ready',
+ text: 'shared secret',
+ textFormat: 'text',
+ });
+ });
+
+ it('returns status:pending without text/textFormat while the deferred render is in flight', async () => {
+ mockGetFiles.mockResolvedValueOnce([
+ {
+ file_id: 'fid-pending',
+ user: OWNER_USER_ID,
+ filename: 'data.xlsx',
+ status: 'pending',
+ },
+ ]);
+ const res = await request(buildApp()).get('/files/fid-pending/preview');
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({ file_id: 'fid-pending', status: 'pending' });
+ /* Pending must NOT leak `text` and must NOT trigger the text re-fetch. */
+ expect(res.body).not.toHaveProperty('text');
+ expect(mockFindFileById).not.toHaveBeenCalled();
+ });
+
+ it('returns status:ready with text + textFormat when the deferred render succeeded', async () => {
+ mockGetFiles.mockResolvedValueOnce([
+ { file_id: 'fid-ready', user: OWNER_USER_ID, filename: 'data.xlsx', status: 'ready' },
+ ]);
+ /* Text is fetched only on the terminal ready response. */
+ mockFindFileById.mockResolvedValueOnce({
+ file_id: 'fid-ready',
+ text: '',
+ textFormat: 'html',
+ });
+ const res = await request(buildApp()).get('/files/fid-ready/preview');
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({
+ file_id: 'fid-ready',
+ status: 'ready',
+ text: '',
+ textFormat: 'html',
+ });
+ });
+
+ it('returns status:failed with previewError when the deferred render errored', async () => {
+ mockGetFiles.mockResolvedValueOnce([
+ {
+ file_id: 'fid-failed',
+ user: OWNER_USER_ID,
+ filename: 'data.xlsx',
+ status: 'failed',
+ previewError: 'parser-error',
+ },
+ ]);
+ const res = await request(buildApp()).get('/files/fid-failed/preview');
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({
+ file_id: 'fid-failed',
+ status: 'failed',
+ previewError: 'parser-error',
+ });
+ expect(mockFindFileById).not.toHaveBeenCalled();
+ });
+
+ it('defaults to status:ready for legacy records with no status field (back-compat)', async () => {
+ mockGetFiles.mockResolvedValueOnce([
+ {
+ file_id: 'fid-legacy',
+ user: OWNER_USER_ID,
+ filename: 'old.csv',
+ // status intentionally absent
+ },
+ ]);
+ mockFindFileById.mockResolvedValueOnce({
+ file_id: 'fid-legacy',
+ text: 'csv,header\n1,2',
+ textFormat: 'text',
+ });
+ const res = await request(buildApp()).get('/files/fid-legacy/preview');
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({
+ file_id: 'fid-legacy',
+ status: 'ready',
+ text: 'csv,header\n1,2',
+ textFormat: 'text',
+ });
+ });
+
+ it('returns status:ready with no text when the record is ready but text is null (binary/oversized)', async () => {
+ mockGetFiles.mockResolvedValueOnce([
+ { file_id: 'fid-binary', user: OWNER_USER_ID, filename: 'image.bin' },
+ ]);
+ mockFindFileById.mockResolvedValueOnce({
+ file_id: 'fid-binary',
+ text: null,
+ textFormat: null,
+ });
+ const res = await request(buildApp()).get('/files/fid-binary/preview');
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({ file_id: 'fid-binary', status: 'ready' });
+ });
+
+ it('returns ready with no text when ready record was deleted between fileAccess and text fetch', async () => {
+ /* `fileAccess` saw the record but the concurrent delete removed it
+ * before the text fetch. Surface ready-without-text rather than
+ * 500 — the client routes to download-only and stops polling. */
+ mockGetFiles.mockResolvedValueOnce([
+ { file_id: 'fid-race', user: OWNER_USER_ID, filename: 'data.xlsx', status: 'ready' },
+ ]);
+ mockFindFileById.mockResolvedValueOnce(null);
+ const res = await request(buildApp()).get('/files/fid-race/preview');
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({ file_id: 'fid-race', status: 'ready' });
+ });
+
+ it('returns 500 with a stable shape if the text fetch throws unexpectedly', async () => {
+ mockGetFiles.mockResolvedValueOnce([
+ { file_id: 'fid-boom', user: OWNER_USER_ID, filename: 'data.xlsx', status: 'ready' },
+ ]);
+ mockFindFileById.mockRejectedValueOnce(new Error('mongo down'));
+ const res = await request(buildApp()).get('/files/fid-boom/preview');
+ expect(res.status).toBe(500);
+ expect(res.body).toMatchObject({ error: 'Internal Server Error' });
+ });
+
+ describe('lazy sweep for stale pending records', () => {
+ /* The boot-time `sweepOrphanedPreviews` only runs once at startup
+ * with a 5-min cutoff. A backend crash + quick restart can leave
+ * `pending` records younger than 5 min that never get touched
+ * again. This endpoint sweeps them on the spot whenever a polling
+ * request lands on one — the user is exactly the consumer who
+ * cares, so on-demand sweep is the right shape. (Codex P2 review
+ * on PR #12957.) */
+ const STALE_MS = 6 * 60 * 1000;
+ const FRESH_MS = 30 * 1000;
+
+ it('marks a stale pending record as failed:orphaned and returns the swept state', async () => {
+ const updatedAt = new Date(Date.now() - STALE_MS);
+ mockGetFiles.mockResolvedValueOnce([
+ {
+ file_id: 'fid-stale',
+ user: OWNER_USER_ID,
+ filename: 'data.xlsx',
+ status: 'pending',
+ updatedAt,
+ },
+ ]);
+ mockUpdateFile.mockResolvedValueOnce({
+ file_id: 'fid-stale',
+ status: 'failed',
+ previewError: 'orphaned',
+ });
+
+ const res = await request(buildApp()).get('/files/fid-stale/preview');
+
+ expect(mockUpdateFile).toHaveBeenCalledWith(
+ { file_id: 'fid-stale', status: 'failed', previewError: 'orphaned' },
+ { status: 'pending', updatedAt },
+ );
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({
+ file_id: 'fid-stale',
+ status: 'failed',
+ previewError: 'orphaned',
+ });
+ });
+
+ it('does NOT sweep a fresh pending record (within the cutoff window)', async () => {
+ mockGetFiles.mockResolvedValueOnce([
+ {
+ file_id: 'fid-fresh',
+ user: OWNER_USER_ID,
+ filename: 'data.xlsx',
+ status: 'pending',
+ updatedAt: new Date(Date.now() - FRESH_MS),
+ },
+ ]);
+
+ const res = await request(buildApp()).get('/files/fid-fresh/preview');
+
+ expect(mockUpdateFile).not.toHaveBeenCalled();
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({ file_id: 'fid-fresh', status: 'pending' });
+ });
+
+ it('sweeps a record past the 2min cutoff but below the 5min boot-sweep threshold', async () => {
+ /* Pins the cutoff change from 5min to 2min — without this, a
+ * future revert wouldn't fail the suite. */
+ const updatedAt = new Date(Date.now() - 3 * 60 * 1000);
+ mockGetFiles.mockResolvedValueOnce([
+ {
+ file_id: 'fid-mid',
+ user: OWNER_USER_ID,
+ filename: 'data.xlsx',
+ status: 'pending',
+ updatedAt,
+ },
+ ]);
+ mockUpdateFile.mockResolvedValueOnce({
+ file_id: 'fid-mid',
+ status: 'failed',
+ previewError: 'orphaned',
+ });
+
+ const res = await request(buildApp()).get('/files/fid-mid/preview');
+
+ expect(mockUpdateFile).toHaveBeenCalled();
+ expect(res.body).toEqual({
+ file_id: 'fid-mid',
+ status: 'failed',
+ previewError: 'orphaned',
+ });
+ });
+
+ it('does NOT sweep a stale ready record (only pending qualifies)', async () => {
+ mockGetFiles.mockResolvedValueOnce([
+ {
+ file_id: 'fid-ready',
+ user: OWNER_USER_ID,
+ filename: 'data.xlsx',
+ status: 'ready',
+ updatedAt: new Date(Date.now() - STALE_MS),
+ },
+ ]);
+ mockFindFileById.mockResolvedValueOnce({
+ file_id: 'fid-ready',
+ text: 'final',
+ textFormat: 'html',
+ });
+
+ const res = await request(buildApp()).get('/files/fid-ready/preview');
+
+ expect(mockUpdateFile).not.toHaveBeenCalled();
+ expect(res.body).toMatchObject({ status: 'ready', text: 'final' });
+ });
+
+ it('falls through to the original pending payload if the conditional sweep loses the race', async () => {
+ const updatedAt = new Date(Date.now() - STALE_MS);
+ mockGetFiles.mockResolvedValueOnce([
+ {
+ file_id: 'fid-race',
+ user: OWNER_USER_ID,
+ filename: 'data.xlsx',
+ status: 'pending',
+ updatedAt,
+ },
+ ]);
+ mockUpdateFile.mockResolvedValueOnce(null);
+
+ const res = await request(buildApp()).get('/files/fid-race/preview');
+
+ expect(mockUpdateFile).toHaveBeenCalled();
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({ file_id: 'fid-race', status: 'pending' });
+ });
+ });
+});
diff --git a/api/server/routes/files/speech/customConfigSpeech.js b/api/server/routes/files/speech/customConfigSpeech.js
new file mode 100644
index 0000000..c3b1e2e
--- /dev/null
+++ b/api/server/routes/files/speech/customConfigSpeech.js
@@ -0,0 +1,10 @@
+const express = require('express');
+const router = express.Router();
+
+const { getCustomConfigSpeech } = require('~/server/services/Files/Audio');
+
+router.get('/get', async (req, res) => {
+ await getCustomConfigSpeech(req, res);
+});
+
+module.exports = router;
diff --git a/api/server/routes/files/speech/index.js b/api/server/routes/files/speech/index.js
new file mode 100644
index 0000000..074ed55
--- /dev/null
+++ b/api/server/routes/files/speech/index.js
@@ -0,0 +1,17 @@
+const express = require('express');
+const { createTTSLimiters, createSTTLimiters } = require('~/server/middleware');
+
+const stt = require('./stt');
+const tts = require('./tts');
+const customConfigSpeech = require('./customConfigSpeech');
+
+const router = express.Router();
+
+const { sttIpLimiter, sttUserLimiter } = createSTTLimiters();
+const { ttsIpLimiter, ttsUserLimiter } = createTTSLimiters();
+router.use('/stt', sttIpLimiter, sttUserLimiter, stt);
+router.use('/tts', ttsIpLimiter, ttsUserLimiter, tts);
+
+router.use('/config', customConfigSpeech);
+
+module.exports = router;
diff --git a/api/server/routes/files/speech/stt.js b/api/server/routes/files/speech/stt.js
new file mode 100644
index 0000000..663d2e4
--- /dev/null
+++ b/api/server/routes/files/speech/stt.js
@@ -0,0 +1,8 @@
+const express = require('express');
+const { speechToText } = require('~/server/services/Files/Audio');
+
+const router = express.Router();
+
+router.post('/', speechToText);
+
+module.exports = router;
diff --git a/api/server/routes/files/speech/tts.js b/api/server/routes/files/speech/tts.js
new file mode 100644
index 0000000..ad60010
--- /dev/null
+++ b/api/server/routes/files/speech/tts.js
@@ -0,0 +1,43 @@
+const multer = require('multer');
+const express = require('express');
+const { restoreTenantContextFromReq } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const { CacheKeys } = require('librechat-data-provider');
+const { getVoices, streamAudio, textToSpeech } = require('~/server/services/Files/Audio');
+const { getLogStores } = require('~/cache');
+
+const router = express.Router();
+const upload = multer();
+
+router.post('/manual', upload.none(), restoreTenantContextFromReq, async (req, res) => {
+ await textToSpeech(req, res);
+});
+
+const logDebugMessage = (req, message) =>
+ logger.debug(`[streamAudio] user: ${req?.user?.id ?? 'UNDEFINED_USER'} | ${message}`);
+
+// TODO: test caching
+router.post('/', async (req, res) => {
+ try {
+ const audioRunsCache = getLogStores(CacheKeys.AUDIO_RUNS);
+ const audioRun = await audioRunsCache.get(req.body.runId);
+ logDebugMessage(req, 'start stream audio');
+ if (audioRun) {
+ logDebugMessage(req, 'stream audio already running');
+ return res.status(401).json({ error: 'Audio stream already running' });
+ }
+ audioRunsCache.set(req.body.runId, true);
+ await streamAudio(req, res);
+ logDebugMessage(req, 'end stream audio');
+ res.status(200).end();
+ } catch (error) {
+ logger.error(`[streamAudio] user: ${req.user.id} | Failed to stream audio: ${error}`);
+ res.status(500).json({ error: 'Failed to stream audio' });
+ }
+});
+
+router.get('/voices', async (req, res) => {
+ await getVoices(req, res);
+});
+
+module.exports = router;
diff --git a/api/server/routes/index.js b/api/server/routes/index.js
new file mode 100644
index 0000000..ac2b38f
--- /dev/null
+++ b/api/server/routes/index.js
@@ -0,0 +1,79 @@
+const accessPermissions = require('./accessPermissions');
+const assistants = require('./assistants');
+const categories = require('./categories');
+const adminAuth = require('./admin/auth');
+const adminConfig = require('./admin/config');
+const adminGrants = require('./admin/grants');
+const adminGroups = require('./admin/groups');
+const adminRoles = require('./admin/roles');
+const adminSkills = require('./admin/skills');
+const adminUsers = require('./admin/users');
+const adminAuditLog = require('./admin/audit');
+const endpoints = require('./endpoints');
+const staticRoute = require('./static');
+const messages = require('./messages');
+const memories = require('./memories');
+const presets = require('./presets');
+const projects = require('./projects');
+const prompts = require('./prompts');
+const skills = require('./skills');
+const balance = require('./balance');
+const actions = require('./actions');
+const apiKeys = require('./apiKeys');
+const banner = require('./banner');
+const search = require('./search');
+const models = require('./models');
+const convos = require('./convos');
+const config = require('./config');
+const agents = require('./agents');
+const roles = require('./roles');
+const oauth = require('./oauth');
+const files = require('./files');
+const share = require('./share');
+const tags = require('./tags');
+const auth = require('./auth');
+const keys = require('./keys');
+const user = require('./user');
+const mcp = require('./mcp');
+const rum = require('./rum');
+
+module.exports = {
+ rum,
+ mcp,
+ auth,
+ adminAuth,
+ adminConfig,
+ adminGrants,
+ adminGroups,
+ adminRoles,
+ adminSkills,
+ adminUsers,
+ adminAuditLog,
+ keys,
+ apiKeys,
+ user,
+ tags,
+ roles,
+ oauth,
+ files,
+ share,
+ banner,
+ agents,
+ convos,
+ search,
+ config,
+ models,
+ prompts,
+ projects,
+ skills,
+ actions,
+ presets,
+ balance,
+ messages,
+ memories,
+ endpoints,
+ assistants,
+ categories,
+ staticRoute,
+ accessPermissions,
+};
diff --git a/api/server/routes/keys.js b/api/server/routes/keys.js
new file mode 100644
index 0000000..dfd68f6
--- /dev/null
+++ b/api/server/routes/keys.js
@@ -0,0 +1,40 @@
+const express = require('express');
+const { updateUserKey, deleteUserKey, getUserKeyExpiry } = require('~/models');
+const { requireJwtAuth } = require('~/server/middleware');
+
+const router = express.Router();
+
+router.put('/', requireJwtAuth, async (req, res) => {
+ if (req.body == null || typeof req.body !== 'object') {
+ return res.status(400).send({ error: 'Invalid request body.' });
+ }
+ const { name, value, expiresAt } = req.body;
+ await updateUserKey({ userId: req.user.id, name, value, expiresAt });
+ res.status(201).send();
+});
+
+router.delete('/:name', requireJwtAuth, async (req, res) => {
+ const { name } = req.params;
+ await deleteUserKey({ userId: req.user.id, name });
+ res.status(204).send();
+});
+
+router.delete('/', requireJwtAuth, async (req, res) => {
+ const { all } = req.query;
+
+ if (all !== 'true') {
+ return res.status(400).send({ error: 'Specify either all=true to delete.' });
+ }
+
+ await deleteUserKey({ userId: req.user.id, all: true });
+
+ res.status(204).send();
+});
+
+router.get('/', requireJwtAuth, async (req, res) => {
+ const { name } = req.query;
+ const response = await getUserKeyExpiry({ userId: req.user.id, name });
+ res.status(200).send(response);
+});
+
+module.exports = router;
diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js
new file mode 100644
index 0000000..1637b8f
--- /dev/null
+++ b/api/server/routes/mcp.js
@@ -0,0 +1,981 @@
+const { Router } = require('express');
+const { logger, getTenantId, tenantStorage } = require('@librechat/data-schemas');
+const {
+ CacheKeys,
+ Constants,
+ PermissionBits,
+ PermissionTypes,
+ Permissions,
+} = require('librechat-data-provider');
+const {
+ getBasePath,
+ createSafeUser,
+ MCPOAuthHandler,
+ MCPTokenStorage,
+ setOAuthSession,
+ PENDING_STALE_MS,
+ mcpConfig: mcpSettings,
+ getUserMCPAuthMap,
+ validateOAuthCsrf,
+ OAUTH_CSRF_COOKIE,
+ setOAuthCsrfCookie,
+ generateCheckAccess,
+ validateOAuthSession,
+ OAUTH_SESSION_COOKIE,
+} = require('@librechat/api');
+const {
+ createMCPServerController,
+ updateMCPServerController,
+ deleteMCPServerController,
+ getMCPServersList,
+ getMCPServerById,
+ getMCPTools,
+} = require('~/server/controllers/mcp');
+const {
+ getOAuthReconnectionManager,
+ getMCPServersRegistry,
+ getFlowStateManager,
+ getMCPManager,
+} = require('~/config');
+const {
+ getServerConnectionStatus,
+ resolveAllMcpConfigs,
+ resolveConfigServers,
+ getMCPSetupData,
+} = require('~/server/services/MCP');
+const { requireJwtAuth, canAccessMCPServerResource } = require('~/server/middleware');
+const { getUserPluginAuthValue } = require('~/server/services/PluginService');
+const { updateMCPServerTools } = require('~/server/services/Config/mcp');
+const { reinitMCPServer } = require('~/server/services/Tools/mcp');
+const { getLogStores } = require('~/cache');
+const db = require('~/models');
+
+const router = Router();
+
+const OAUTH_CSRF_COOKIE_PATH = '/api/mcp';
+
+const getOAuthFlowId = (userId, serverName) =>
+ MCPOAuthHandler.generateFlowId(userId, serverName, getTenantId());
+
+const canAccessOAuthFlow = (flowId, userId) => {
+ const parsed = MCPOAuthHandler.parseFlowId(flowId);
+ if (!parsed) {
+ return false;
+ }
+ if (parsed.tenantId && parsed.tenantId !== getTenantId()) {
+ return false;
+ }
+ return parsed.userId === userId || parsed.userId === 'system';
+};
+
+const clearGetTokensFlow = async ({ flowManager, flowId, tokens }) => {
+ const state = await flowManager.getFlowState(flowId, 'mcp_get_tokens');
+ if (state?.type === 'mcp_get_tokens' && state.status === 'PENDING') {
+ await flowManager.completeFlow(flowId, 'mcp_get_tokens', tokens);
+ return;
+ }
+ await flowManager.deleteFlow(flowId, 'mcp_get_tokens');
+};
+
+const checkMCPUsePermissions = generateCheckAccess({
+ permissionType: PermissionTypes.MCP_SERVERS,
+ permissions: [Permissions.USE],
+ getRoleByName: db.getRoleByName,
+});
+
+const checkMCPCreate = generateCheckAccess({
+ permissionType: PermissionTypes.MCP_SERVERS,
+ permissions: [Permissions.USE, Permissions.CREATE],
+ getRoleByName: db.getRoleByName,
+});
+
+/**
+ * Get all MCP tools available to the user
+ * Returns only MCP tools, completely decoupled from regular LibreChat tools
+ */
+router.get('/tools', requireJwtAuth, checkMCPUsePermissions, async (req, res) => {
+ return getMCPTools(req, res);
+});
+
+/**
+ * Initiate OAuth flow
+ * This endpoint is called when the user clicks the auth link in the UI
+ */
+router.get('/:serverName/oauth/initiate', requireJwtAuth, setOAuthSession, async (req, res) => {
+ try {
+ const { serverName } = req.params;
+ const { userId, flowId } = req.query;
+ const user = req.user;
+
+ // Verify the userId matches the authenticated user
+ if (typeof userId !== 'string' || userId !== user.id) {
+ return res.status(403).json({ error: 'User mismatch' });
+ }
+
+ const expectedFlowId = getOAuthFlowId(user.id, serverName);
+ if (typeof flowId !== 'string' || flowId !== expectedFlowId) {
+ logger.error('[MCP OAuth] Invalid flow ID for initiate request', {
+ serverName,
+ userId,
+ flowId,
+ expectedFlowId,
+ });
+ return res.status(403).json({ error: 'Flow mismatch' });
+ }
+
+ logger.debug('[MCP OAuth] Initiate request', { serverName, userId, flowId });
+
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+
+ /** Flow state to retrieve OAuth config */
+ const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
+ if (!flowState) {
+ logger.error('[MCP OAuth] Flow state not found', { flowId });
+ return res.status(404).json({ error: 'Flow not found' });
+ }
+
+ const {
+ authorizationUrl: storedAuthorizationUrl,
+ serverName: flowServerName,
+ userId: flowUserId,
+ serverUrl,
+ oauth: oauthConfig,
+ } = flowState.metadata || {};
+
+ if (flowUserId && flowUserId !== user.id) {
+ logger.error('[MCP OAuth] Flow user mismatch', { flowId, userId, flowUserId });
+ return res.status(403).json({ error: 'User mismatch' });
+ }
+
+ if (flowServerName && flowServerName !== serverName) {
+ logger.error('[MCP OAuth] Flow server mismatch', { flowId, serverName, flowServerName });
+ return res.status(400).json({ error: 'Invalid flow state' });
+ }
+
+ const pendingAge = flowState.createdAt ? Date.now() - flowState.createdAt : Infinity;
+ const isFreshPendingFlow = flowState.status === 'PENDING' && pendingAge < PENDING_STALE_MS;
+ if (!isFreshPendingFlow) {
+ logger.error('[MCP OAuth] Flow is not active for initiation', {
+ flowId,
+ status: flowState.status,
+ pendingAge,
+ });
+ return res.status(400).json({ error: 'Invalid flow state' });
+ }
+
+ if (typeof storedAuthorizationUrl === 'string' && storedAuthorizationUrl.length > 0) {
+ logger.debug('[MCP OAuth] Reusing stored authorization URL', {
+ serverName,
+ userId,
+ flowId,
+ });
+ setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH);
+ return res.redirect(storedAuthorizationUrl);
+ }
+
+ if (!serverUrl || !oauthConfig) {
+ logger.error('[MCP OAuth] Missing server URL or OAuth config in flow state');
+ return res.status(400).json({ error: 'Invalid flow state' });
+ }
+
+ const configServers = await resolveConfigServers(req);
+ const oauthHeaders = await getOAuthHeaders(serverName, userId, configServers);
+ const registry = getMCPServersRegistry();
+ const { allowedDomains, allowedAddresses } = await registry.resolveAllowlists({
+ userId,
+ role: req.user?.role,
+ });
+ const {
+ authorizationUrl,
+ flowId: oauthFlowId,
+ flowMetadata,
+ } = await MCPOAuthHandler.initiateOAuthFlow(
+ serverName,
+ serverUrl,
+ userId,
+ oauthHeaders,
+ oauthConfig,
+ allowedDomains,
+ undefined,
+ allowedAddresses,
+ getTenantId(),
+ );
+
+ logger.debug('[MCP OAuth] OAuth flow initiated', { oauthFlowId, authorizationUrl });
+
+ const oldState = flowState.metadata?.state;
+ if (typeof oldState === 'string') {
+ await MCPOAuthHandler.deleteStateMapping(oldState, flowManager);
+ }
+ const metadataWithUrl = { ...flowMetadata, authorizationUrl, tenantId: getTenantId() };
+ await flowManager.initFlow(oauthFlowId, 'mcp_oauth', metadataWithUrl);
+ await MCPOAuthHandler.storeStateMapping(flowMetadata.state, oauthFlowId, flowManager);
+ setOAuthCsrfCookie(res, oauthFlowId, OAUTH_CSRF_COOKIE_PATH);
+ res.redirect(authorizationUrl);
+ } catch (error) {
+ logger.error('[MCP OAuth] Failed to initiate OAuth', error);
+ res.status(500).json({ error: 'Failed to initiate OAuth' });
+ }
+});
+
+/**
+ * OAuth callback handler
+ * This handles the OAuth callback after the user has authorized the application
+ */
+router.get('/:serverName/oauth/callback', async (req, res) => {
+ const basePath = getBasePath();
+ try {
+ const { serverName } = req.params;
+ const { code, state, error: oauthError } = req.query;
+
+ logger.debug('[MCP OAuth] Callback received', {
+ serverName,
+ code: code ? 'present' : 'missing',
+ state,
+ error: oauthError,
+ });
+
+ if (oauthError) {
+ logger.error('[MCP OAuth] OAuth error received', { error: oauthError });
+ // Gate failFlow behind callback validation to prevent DoS via leaked state
+ if (state && typeof state === 'string') {
+ try {
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+ const flowId = await MCPOAuthHandler.resolveStateToFlowId(state, flowManager);
+ if (flowId) {
+ const parsed = MCPOAuthHandler.parseFlowId(flowId);
+ if (!parsed) {
+ logger.warn('[MCP OAuth] Invalid flow ID format for OAuth error callback', {
+ flowId,
+ });
+ } else {
+ const hasCsrf = validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH);
+ const hasSession = !hasCsrf && validateOAuthSession(req, parsed.userId);
+ if (hasCsrf || hasSession) {
+ await flowManager.failFlow(flowId, 'mcp_oauth', String(oauthError));
+ logger.debug('[MCP OAuth] Marked flow as FAILED with OAuth error', {
+ flowId,
+ error: oauthError,
+ });
+ }
+ }
+ }
+ } catch (err) {
+ logger.debug('[MCP OAuth] Could not mark flow as failed', err);
+ }
+ }
+ return res.redirect(
+ `${basePath}/oauth/error?error=${encodeURIComponent(String(oauthError))}`,
+ );
+ }
+
+ if (!code || typeof code !== 'string') {
+ logger.error('[MCP OAuth] Missing or invalid code');
+ return res.redirect(`${basePath}/oauth/error?error=missing_code`);
+ }
+
+ if (!state || typeof state !== 'string') {
+ logger.error('[MCP OAuth] Missing or invalid state');
+ return res.redirect(`${basePath}/oauth/error?error=missing_state`);
+ }
+
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+
+ const flowId = await MCPOAuthHandler.resolveStateToFlowId(state, flowManager);
+ if (!flowId) {
+ logger.error('[MCP OAuth] Could not resolve state to flow ID', { state });
+ return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
+ }
+ logger.debug('[MCP OAuth] Resolved flow ID from state', { flowId });
+
+ const parsedFlowId = MCPOAuthHandler.parseFlowId(flowId);
+ if (!parsedFlowId) {
+ logger.error('[MCP OAuth] Invalid flow ID format', { flowId });
+ return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
+ }
+
+ const hasCsrf = validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH);
+ const hasSession = !hasCsrf && validateOAuthSession(req, parsedFlowId.userId);
+ let hasActiveFlow = false;
+ if (!hasCsrf && !hasSession) {
+ const pendingFlow = await flowManager.getFlowState(flowId, 'mcp_oauth');
+ const pendingAge = pendingFlow?.createdAt ? Date.now() - pendingFlow.createdAt : Infinity;
+ hasActiveFlow = pendingFlow?.status === 'PENDING' && pendingAge < PENDING_STALE_MS;
+ if (hasActiveFlow) {
+ logger.debug(
+ '[MCP OAuth] CSRF/session cookies absent, validating via active PENDING flow',
+ {
+ flowId,
+ },
+ );
+ }
+ }
+
+ if (!hasCsrf && !hasSession && !hasActiveFlow) {
+ logger.error(
+ '[MCP OAuth] CSRF validation failed: no valid CSRF cookie, session cookie, or active flow',
+ {
+ flowId,
+ hasCsrfCookie: !!req.cookies?.[OAUTH_CSRF_COOKIE],
+ hasSessionCookie: !!req.cookies?.[OAUTH_SESSION_COOKIE],
+ },
+ );
+ return res.redirect(`${basePath}/oauth/error?error=csrf_validation_failed`);
+ }
+
+ logger.debug('[MCP OAuth] Getting flow state for flowId: ' + flowId);
+ const flowState = await MCPOAuthHandler.getFlowState(flowId, flowManager);
+
+ if (!flowState) {
+ logger.error('[MCP OAuth] Flow state not found for flowId:', flowId);
+ return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
+ }
+
+ logger.debug('[MCP OAuth] Flow state details', {
+ serverName: flowState.serverName,
+ userId: flowState.userId,
+ hasMetadata: !!flowState.metadata,
+ hasClientInfo: !!flowState.clientInfo,
+ hasCodeVerifier: !!flowState.codeVerifier,
+ });
+
+ /** Check if this flow has already been completed (idempotency protection) */
+ const currentFlowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
+ if (currentFlowState?.status === 'COMPLETED') {
+ logger.warn('[MCP OAuth] Flow already completed, preventing duplicate token exchange', {
+ flowId,
+ serverName,
+ });
+ return res.redirect(`${basePath}/oauth/success?serverName=${encodeURIComponent(serverName)}`);
+ }
+
+ logger.debug('[MCP OAuth] Completing OAuth flow');
+ if (!flowState.oauthHeaders) {
+ logger.warn(
+ '[MCP OAuth] oauthHeaders absent from flow state — config-source server oauth_headers will be empty',
+ { serverName, flowId },
+ );
+ }
+ /**
+ * Restore tenant context for the callback body. The callback is a cross-origin
+ * redirect from the OAuth provider, so SameSite=Strict cookies (including the
+ * JWT) are not sent. The tenantId was stored in the flow metadata at initiation
+ * time when the user was authenticated.
+ */
+ const runWithTenant = async (fn) => {
+ const flowTenantId = flowState.tenantId;
+ if (flowTenantId && !getTenantId()) {
+ return tenantStorage.run({ tenantId: flowTenantId }, fn);
+ }
+ return fn();
+ };
+
+ await runWithTenant(async () => {
+ const oauthHeaders =
+ flowState.oauthHeaders ?? (await getOAuthHeaders(serverName, flowState.userId));
+ const tokens = await MCPOAuthHandler.completeOAuthFlow(
+ flowId,
+ code,
+ flowManager,
+ oauthHeaders,
+ );
+ logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
+
+ /** Persist tokens immediately so reconnection uses fresh credentials */
+ if (flowState?.userId && tokens) {
+ try {
+ await MCPTokenStorage.storeTokens({
+ userId: flowState.userId,
+ serverName,
+ tokens,
+ createToken: db.createToken,
+ updateToken: db.updateToken,
+ findToken: db.findToken,
+ clientInfo: flowState.clientInfo,
+ metadata: MCPOAuthHandler.buildStoredClientMetadata(
+ flowState.metadata,
+ flowState.resourceMetadata,
+ ),
+ });
+ logger.debug('[MCP OAuth] Stored OAuth tokens prior to reconnection', {
+ serverName,
+ userId: flowState.userId,
+ });
+ } catch (error) {
+ logger.error('[MCP OAuth] Failed to store OAuth tokens after callback', error);
+ throw error;
+ }
+
+ /**
+ * Clear any cached `mcp_get_tokens` flow result so subsequent lookups
+ * re-fetch the freshly stored credentials instead of returning stale nulls.
+ */
+ if (typeof flowManager?.deleteFlow === 'function') {
+ try {
+ const tokenFlowId = MCPOAuthHandler.generateTokenFlowId(
+ flowState.userId,
+ serverName,
+ flowState.tenantId,
+ );
+ await clearGetTokensFlow({
+ flowManager,
+ flowId: tokenFlowId,
+ tokens,
+ });
+ if (tokenFlowId !== flowId) {
+ await clearGetTokensFlow({
+ flowManager,
+ flowId,
+ tokens,
+ });
+ }
+ } catch (error) {
+ logger.warn('[MCP OAuth] Failed to clear cached token flow state', error);
+ }
+ }
+ }
+
+ try {
+ const mcpManager = getMCPManager(flowState.userId);
+ logger.debug(`[MCP OAuth] Attempting to reconnect ${serverName} with new OAuth tokens`);
+
+ if (flowState.userId !== 'system') {
+ const user = { id: flowState.userId };
+
+ /** Merged config (incl. Config-tier overlays) so the reconnection and
+ * the cache gate both see request-scoped servers the base registry
+ * lookup misses */
+ let serverConfig;
+ try {
+ const allConfigs = await resolveAllMcpConfigs(flowState.userId);
+ serverConfig = allConfigs?.[serverName];
+ } catch (error) {
+ logger.warn(
+ `[MCP OAuth] Could not resolve server config for ${serverName} before reconnecting:`,
+ error,
+ );
+ }
+
+ const userConnection = await mcpManager.getUserConnection({
+ user,
+ serverName,
+ flowManager,
+ serverConfig,
+ tokenMethods: {
+ findToken: db.findToken,
+ updateToken: db.updateToken,
+ createToken: db.createToken,
+ deleteTokens: db.deleteTokens,
+ },
+ });
+
+ logger.info(
+ `[MCP OAuth] Successfully reconnected ${serverName} for user ${flowState.userId}`,
+ );
+
+ const oauthReconnectionManager = getOAuthReconnectionManager();
+ oauthReconnectionManager.clearReconnection(flowState.userId, serverName);
+
+ const tools = await userConnection.fetchTools();
+ await updateMCPServerTools({
+ userId: flowState.userId,
+ serverName,
+ tools,
+ serverConfig,
+ });
+ } else {
+ logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
+ }
+ } catch (error) {
+ logger.warn(
+ `[MCP OAuth] Failed to reconnect ${serverName} after OAuth, but tokens are saved:`,
+ error,
+ );
+ }
+
+ /** ID of the flow that the tool/connection is waiting for */
+ const toolFlowId = flowState.metadata?.toolFlowId;
+ if (toolFlowId) {
+ logger.debug('[MCP OAuth] Completing tool flow', { toolFlowId });
+ const completed = await flowManager.completeFlow(toolFlowId, 'mcp_oauth', tokens);
+ if (!completed) {
+ logger.warn(
+ '[MCP OAuth] Tool flow state not found during completion — waiter will time out',
+ { toolFlowId },
+ );
+ }
+ }
+ }); /* end runWithTenant */
+
+ /** Redirect to success page with flowId and serverName */
+ const redirectUrl = `${basePath}/oauth/success?serverName=${encodeURIComponent(serverName)}`;
+ res.redirect(redirectUrl);
+ } catch (error) {
+ logger.error('[MCP OAuth] OAuth callback error', error);
+ res.redirect(`${basePath}/oauth/error?error=callback_failed`);
+ }
+});
+
+/**
+ * Get OAuth tokens for a completed flow
+ * This is primarily for user-level OAuth flows
+ */
+router.get('/oauth/tokens/:flowId', requireJwtAuth, async (req, res) => {
+ try {
+ const { flowId } = req.params;
+ const user = req.user;
+
+ if (!user?.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+
+ if (!canAccessOAuthFlow(flowId, user.id)) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+
+ const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
+ if (!flowState) {
+ return res.status(404).json({ error: 'Flow not found' });
+ }
+
+ if (flowState.status !== 'COMPLETED') {
+ return res.status(400).json({ error: 'Flow not completed' });
+ }
+
+ res.json({ tokens: flowState.result });
+ } catch (error) {
+ logger.error('[MCP OAuth] Failed to get tokens', error);
+ res.status(500).json({ error: 'Failed to get tokens' });
+ }
+});
+
+/**
+ * Set CSRF binding cookie for OAuth flows initiated outside of HTTP request/response
+ * (e.g. during chat via SSE). The frontend should call this before opening the OAuth URL
+ * so the callback can verify the browser matches the flow initiator.
+ */
+router.post('/:serverName/oauth/bind', requireJwtAuth, setOAuthSession, async (req, res) => {
+ try {
+ const { serverName } = req.params;
+ const user = req.user;
+
+ if (!user?.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+
+ const flowId = getOAuthFlowId(user.id, serverName);
+ setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH);
+
+ res.json({ success: true });
+ } catch (error) {
+ logger.error('[MCP OAuth] Failed to set CSRF binding cookie', error);
+ res.status(500).json({ error: 'Failed to bind OAuth flow' });
+ }
+});
+
+/**
+ * Check OAuth flow status
+ * This endpoint can be used to poll the status of an OAuth flow
+ */
+router.get('/oauth/status/:flowId', requireJwtAuth, async (req, res) => {
+ try {
+ const { flowId } = req.params;
+ const user = req.user;
+
+ if (!user?.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+
+ if (!canAccessOAuthFlow(flowId, user.id)) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+
+ const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
+ if (!flowState) {
+ return res.status(404).json({ error: 'Flow not found' });
+ }
+
+ res.json({
+ status: flowState.status,
+ completed: flowState.status === 'COMPLETED',
+ failed: flowState.status === 'FAILED',
+ error: flowState.error,
+ });
+ } catch (error) {
+ logger.error('[MCP OAuth] Failed to get flow status', error);
+ res.status(500).json({ error: 'Failed to get flow status' });
+ }
+});
+
+/**
+ * Cancel OAuth flow
+ * This endpoint cancels a pending OAuth flow
+ */
+router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => {
+ try {
+ const { serverName } = req.params;
+ const user = req.user;
+
+ if (!user?.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+
+ logger.info(`[MCP OAuth Cancel] Cancelling OAuth flow for ${serverName} by user ${user.id}`);
+
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+ const flowId = getOAuthFlowId(user.id, serverName);
+ const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
+
+ if (!flowState) {
+ logger.debug(`[MCP OAuth Cancel] No active flow found for ${serverName}`);
+ return res.json({
+ success: true,
+ message: 'No active OAuth flow to cancel',
+ });
+ }
+
+ await flowManager.failFlow(flowId, 'mcp_oauth', 'User cancelled OAuth flow');
+
+ logger.info(`[MCP OAuth Cancel] Successfully cancelled OAuth flow for ${serverName}`);
+
+ res.json({
+ success: true,
+ message: `OAuth flow for ${serverName} cancelled successfully`,
+ });
+ } catch (error) {
+ logger.error('[MCP OAuth Cancel] Failed to cancel OAuth flow', error);
+ res.status(500).json({ error: 'Failed to cancel OAuth flow' });
+ }
+});
+
+/**
+ * Reinitialize MCP server
+ * This endpoint allows reinitializing a specific MCP server
+ */
+router.post(
+ '/:serverName/reinitialize',
+ requireJwtAuth,
+ checkMCPUsePermissions,
+ setOAuthSession,
+ async (req, res) => {
+ try {
+ const { serverName } = req.params;
+ const user = createSafeUser(req.user);
+
+ if (!user.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+
+ logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);
+
+ const mcpManager = getMCPManager();
+ const configServers = await resolveConfigServers(req);
+ const serverConfig = await getMCPServersRegistry().getServerConfig(
+ serverName,
+ user.id,
+ configServers,
+ );
+ if (!serverConfig) {
+ return res.status(404).json({
+ error: `MCP server '${serverName}' not found in configuration`,
+ });
+ }
+
+ await mcpManager.disconnectUserConnection(user.id, serverName);
+ logger.info(
+ `[MCP Reinitialize] Disconnected existing user connection for server: ${serverName}`,
+ );
+
+ /** @type {Record> | undefined} */
+ let userMCPAuthMap;
+ if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
+ userMCPAuthMap = await getUserMCPAuthMap({
+ userId: user.id,
+ servers: [serverName],
+ findPluginAuthsByKeys: db.findPluginAuthsByKeys,
+ });
+ }
+
+ const result = await reinitMCPServer({
+ user,
+ serverName,
+ serverConfig,
+ configServers,
+ userMCPAuthMap,
+ });
+
+ if (!result) {
+ return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
+ }
+
+ const { success, message, oauthRequired, oauthUrl } = result;
+
+ if (oauthRequired) {
+ const flowId = getOAuthFlowId(user.id, serverName);
+ setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH);
+ }
+
+ res.json({
+ success,
+ message,
+ oauthUrl,
+ serverName,
+ oauthRequired,
+ });
+ } catch (error) {
+ logger.error('[MCP Reinitialize] Unexpected error', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ },
+);
+
+/**
+ * Get connection status for all MCP servers
+ * This endpoint returns all app level and user-scoped connection statuses from MCPManager without disconnecting idle connections
+ */
+router.get('/connection/status', requireJwtAuth, async (req, res) => {
+ try {
+ const user = req.user;
+
+ if (!user?.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+
+ const { mcpConfig, appConnections, userConnections, oauthServers } = await getMCPSetupData(
+ user.id,
+ { role: user.role, tenantId: getTenantId() },
+ );
+ const connectionStatus = {};
+
+ for (const [serverName, config] of Object.entries(mcpConfig)) {
+ try {
+ connectionStatus[serverName] = await getServerConnectionStatus(
+ user.id,
+ serverName,
+ config,
+ appConnections,
+ userConnections,
+ oauthServers,
+ );
+ } catch (error) {
+ const message = `Failed to get status for server "${serverName}"`;
+ logger.error(`[MCP Connection Status] ${message},`, error);
+ connectionStatus[serverName] = {
+ connectionState: 'error',
+ requiresOAuth: oauthServers.has(serverName),
+ error: message,
+ };
+ }
+ }
+
+ res.json({
+ success: true,
+ connectionStatus,
+ oauthTimeout: mcpSettings.OAUTH_HANDLING_TIMEOUT,
+ });
+ } catch (error) {
+ logger.error('[MCP Connection Status] Failed to get connection status', error);
+ res.status(500).json({ error: 'Failed to get connection status' });
+ }
+});
+
+/**
+ * Get connection status for a single MCP server
+ * This endpoint returns the connection status for a specific server for a given user
+ */
+router.get('/connection/status/:serverName', requireJwtAuth, async (req, res) => {
+ try {
+ const user = req.user;
+ const { serverName } = req.params;
+
+ if (!user?.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+
+ const { mcpConfig, appConnections, userConnections, oauthServers } = await getMCPSetupData(
+ user.id,
+ { role: user.role, tenantId: getTenantId() },
+ );
+
+ if (!mcpConfig[serverName]) {
+ return res
+ .status(404)
+ .json({ error: `MCP server '${serverName}' not found in configuration` });
+ }
+
+ const serverStatus = await getServerConnectionStatus(
+ user.id,
+ serverName,
+ mcpConfig[serverName],
+ appConnections,
+ userConnections,
+ oauthServers,
+ );
+
+ res.json({
+ success: true,
+ serverName,
+ connectionStatus: serverStatus.connectionState,
+ requiresOAuth: serverStatus.requiresOAuth,
+ });
+ } catch (error) {
+ logger.error(
+ `[MCP Per-Server Status] Failed to get connection status for ${req.params.serverName}`,
+ error,
+ );
+ res.status(500).json({ error: 'Failed to get connection status' });
+ }
+});
+
+/**
+ * Check which authentication values exist for a specific MCP server
+ * This endpoint returns only boolean flags indicating if values are set, not the actual values
+ */
+router.get('/:serverName/auth-values', requireJwtAuth, checkMCPUsePermissions, async (req, res) => {
+ try {
+ const { serverName } = req.params;
+ const user = req.user;
+
+ if (!user?.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+
+ const configServers = await resolveConfigServers(req);
+ const serverConfig = await getMCPServersRegistry().getServerConfig(
+ serverName,
+ user.id,
+ configServers,
+ );
+ if (!serverConfig) {
+ return res.status(404).json({
+ error: `MCP server '${serverName}' not found in configuration`,
+ });
+ }
+
+ const pluginKey = `${Constants.mcp_prefix}${serverName}`;
+ const authValueFlags = {};
+
+ if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
+ for (const varName of Object.keys(serverConfig.customUserVars)) {
+ try {
+ const value = await getUserPluginAuthValue(user.id, varName, false, pluginKey);
+ authValueFlags[varName] = !!(value && value.length > 0);
+ } catch (err) {
+ logger.error(
+ `[MCP Auth Value Flags] Error checking ${varName} for user ${user.id}:`,
+ err,
+ );
+ authValueFlags[varName] = false;
+ }
+ }
+ }
+
+ res.json({
+ success: true,
+ serverName,
+ authValueFlags,
+ });
+ } catch (error) {
+ logger.error(
+ `[MCP Auth Value Flags] Failed to check auth value flags for ${req.params.serverName}`,
+ error,
+ );
+ res.status(500).json({ error: 'Failed to check auth value flags' });
+ }
+});
+
+async function getOAuthHeaders(serverName, userId, configServers) {
+ const serverConfig = await getMCPServersRegistry().getServerConfig(
+ serverName,
+ userId,
+ configServers,
+ );
+ return serverConfig?.oauth_headers ?? {};
+}
+
+/**
+MCP Server CRUD Routes (User-Managed MCP Servers)
+*/
+
+/**
+ * Get list of accessible MCP servers
+ * @route GET /api/mcp/servers
+ * @param {Object} req.query - Query parameters for pagination and search
+ * @param {number} [req.query.limit] - Number of results per page
+ * @param {string} [req.query.after] - Pagination cursor
+ * @param {string} [req.query.search] - Search query for title/description
+ * @returns {MCPServerListResponse} 200 - Success response - application/json
+ */
+router.get('/servers', requireJwtAuth, checkMCPUsePermissions, getMCPServersList);
+
+/**
+ * Create a new MCP server
+ * @route POST /api/mcp/servers
+ * @param {MCPServerCreateParams} req.body - The MCP server creation parameters.
+ * @returns {MCPServer} 201 - Success response - application/json
+ */
+router.post('/servers', requireJwtAuth, checkMCPCreate, createMCPServerController);
+
+/**
+ * Get single MCP server by ID
+ * @route GET /api/mcp/servers/:serverName
+ * @param {string} req.params.serverName - MCP server identifier.
+ * @returns {MCPServer} 200 - Success response - application/json
+ */
+router.get(
+ '/servers/:serverName',
+ requireJwtAuth,
+ checkMCPUsePermissions,
+ canAccessMCPServerResource({
+ requiredPermission: PermissionBits.VIEW,
+ resourceIdParam: 'serverName',
+ }),
+ getMCPServerById,
+);
+
+/**
+ * Update MCP server
+ * @route PATCH /api/mcp/servers/:serverName
+ * @param {string} req.params.serverName - MCP server identifier.
+ * @param {MCPServerUpdateParams} req.body - The MCP server update parameters.
+ * @returns {MCPServer} 200 - Success response - application/json
+ */
+router.patch(
+ '/servers/:serverName',
+ requireJwtAuth,
+ checkMCPCreate,
+ canAccessMCPServerResource({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'serverName',
+ }),
+ updateMCPServerController,
+);
+
+/**
+ * Delete MCP server
+ * @route DELETE /api/mcp/servers/:serverName
+ * @param {string} req.params.serverName - MCP server identifier.
+ * @returns {Object} 200 - Success response - application/json
+ */
+router.delete(
+ '/servers/:serverName',
+ requireJwtAuth,
+ checkMCPCreate,
+ canAccessMCPServerResource({
+ requiredPermission: PermissionBits.DELETE,
+ resourceIdParam: 'serverName',
+ }),
+ deleteMCPServerController,
+);
+
+module.exports = router;
diff --git a/api/server/routes/memories.js b/api/server/routes/memories.js
new file mode 100644
index 0000000..e71e94f
--- /dev/null
+++ b/api/server/routes/memories.js
@@ -0,0 +1,298 @@
+const express = require('express');
+const { Tokenizer, generateCheckAccess } = require('@librechat/api');
+const { PermissionTypes, Permissions } = require('librechat-data-provider');
+const {
+ getAllUserMemories,
+ toggleUserMemories,
+ getRoleByName,
+ createMemory,
+ deleteMemory,
+ setMemory,
+} = require('~/models');
+const { requireJwtAuth, configMiddleware } = require('~/server/middleware');
+
+const router = express.Router();
+
+const memoryPayloadLimit = express.json({ limit: '100kb' });
+
+const checkMemoryRead = generateCheckAccess({
+ permissionType: PermissionTypes.MEMORIES,
+ permissions: [Permissions.USE, Permissions.READ],
+ getRoleByName,
+});
+const checkMemoryCreate = generateCheckAccess({
+ permissionType: PermissionTypes.MEMORIES,
+ permissions: [Permissions.USE, Permissions.CREATE],
+ getRoleByName,
+});
+const checkMemoryUpdate = generateCheckAccess({
+ permissionType: PermissionTypes.MEMORIES,
+ permissions: [Permissions.USE, Permissions.UPDATE],
+ getRoleByName,
+});
+const checkMemoryDelete = generateCheckAccess({
+ permissionType: PermissionTypes.MEMORIES,
+ permissions: [Permissions.USE, Permissions.UPDATE],
+ getRoleByName,
+});
+const checkMemoryOptOut = generateCheckAccess({
+ permissionType: PermissionTypes.MEMORIES,
+ permissions: [Permissions.USE, Permissions.OPT_OUT],
+ getRoleByName,
+});
+
+router.use(requireJwtAuth);
+
+/**
+ * GET /memories
+ * Returns all memories for the authenticated user, sorted by updated_at (newest first).
+ * Also includes memory usage percentage based on token limit.
+ */
+router.get('/', checkMemoryRead, configMiddleware, async (req, res) => {
+ try {
+ const memories = await getAllUserMemories(req.user.id);
+
+ const sortedMemories = memories.sort(
+ (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
+ );
+
+ const totalTokens = memories.reduce((sum, memory) => {
+ return sum + (memory.tokenCount || 0);
+ }, 0);
+
+ const appConfig = req.config;
+ const memoryConfig = appConfig?.memory;
+ const tokenLimit = memoryConfig?.tokenLimit;
+ const charLimit = memoryConfig?.charLimit || 10000;
+
+ let usagePercentage = null;
+ if (tokenLimit && tokenLimit > 0) {
+ usagePercentage = Math.min(100, Math.round((totalTokens / tokenLimit) * 100));
+ }
+
+ res.json({
+ memories: sortedMemories,
+ totalTokens,
+ tokenLimit: tokenLimit || null,
+ charLimit,
+ usagePercentage,
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+/**
+ * POST /memories
+ * Creates a new memory entry for the authenticated user.
+ * Body: { key: string, value: string }
+ * Returns 201 and { created: true, memory: } when successful.
+ */
+router.post('/', memoryPayloadLimit, checkMemoryCreate, configMiddleware, async (req, res) => {
+ const { key, value } = req.body;
+
+ if (typeof key !== 'string' || key.trim() === '') {
+ return res.status(400).json({ error: 'Key is required and must be a non-empty string.' });
+ }
+
+ if (typeof value !== 'string' || value.trim() === '') {
+ return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
+ }
+
+ const appConfig = req.config;
+ const memoryConfig = appConfig?.memory;
+ const charLimit = memoryConfig?.charLimit || 10000;
+
+ if (key.length > 1000) {
+ return res.status(400).json({
+ error: `Key exceeds maximum length of 1000 characters. Current length: ${key.length} characters.`,
+ });
+ }
+
+ if (value.length > charLimit) {
+ return res.status(400).json({
+ error: `Value exceeds maximum length of ${charLimit} characters. Current length: ${value.length} characters.`,
+ });
+ }
+
+ try {
+ const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
+
+ const memories = await getAllUserMemories(req.user.id);
+
+ const appConfig = req.config;
+ const memoryConfig = appConfig?.memory;
+ const tokenLimit = memoryConfig?.tokenLimit;
+
+ if (tokenLimit) {
+ const currentTotalTokens = memories.reduce(
+ (sum, memory) => sum + (memory.tokenCount || 0),
+ 0,
+ );
+ if (currentTotalTokens + tokenCount > tokenLimit) {
+ return res.status(400).json({
+ error: `Adding this memory would exceed the token limit of ${tokenLimit}. Current usage: ${currentTotalTokens} tokens.`,
+ });
+ }
+ }
+
+ const result = await createMemory({
+ userId: req.user.id,
+ key: key.trim(),
+ value: value.trim(),
+ tokenCount,
+ });
+
+ if (!result.ok) {
+ return res.status(500).json({ error: 'Failed to create memory.' });
+ }
+
+ const updatedMemories = await getAllUserMemories(req.user.id);
+ const newMemory = updatedMemories.find((m) => m.key === key.trim());
+
+ res.status(201).json({ created: true, memory: newMemory });
+ } catch (error) {
+ if (error.message && error.message.includes('already exists')) {
+ return res.status(409).json({ error: 'Memory with this key already exists.' });
+ }
+ res.status(500).json({ error: error.message });
+ }
+});
+
+/**
+ * PATCH /memories/preferences
+ * Updates the user's memory preferences (e.g., enabling/disabling memories).
+ * Body: { memories: boolean }
+ * Returns 200 and { updated: true, preferences: { memories: boolean } } when successful.
+ */
+router.patch('/preferences', checkMemoryOptOut, async (req, res) => {
+ const { memories } = req.body;
+
+ if (typeof memories !== 'boolean') {
+ return res.status(400).json({ error: 'memories must be a boolean value.' });
+ }
+
+ try {
+ const updatedUser = await toggleUserMemories(req.user.id, memories);
+
+ if (!updatedUser) {
+ return res.status(404).json({ error: 'User not found.' });
+ }
+
+ res.json({
+ updated: true,
+ preferences: {
+ memories: updatedUser.personalization?.memories ?? true,
+ },
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+/**
+ * PATCH /memories/:key
+ * Updates the value of an existing memory entry for the authenticated user.
+ * Body: { key?: string, value: string }
+ * Returns 200 and { updated: true, memory: } when successful.
+ */
+router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, configMiddleware, async (req, res) => {
+ const { key: urlKey } = req.params;
+ const { key: bodyKey, value } = req.body || {};
+
+ if (typeof value !== 'string' || value.trim() === '') {
+ return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
+ }
+
+ const newKey = bodyKey || urlKey;
+ const appConfig = req.config;
+ const memoryConfig = appConfig?.memory;
+ const charLimit = memoryConfig?.charLimit || 10000;
+
+ if (newKey.length > 1000) {
+ return res.status(400).json({
+ error: `Key exceeds maximum length of 1000 characters. Current length: ${newKey.length} characters.`,
+ });
+ }
+
+ if (value.length > charLimit) {
+ return res.status(400).json({
+ error: `Value exceeds maximum length of ${charLimit} characters. Current length: ${value.length} characters.`,
+ });
+ }
+
+ try {
+ const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
+
+ const memories = await getAllUserMemories(req.user.id);
+ const existingMemory = memories.find((m) => m.key === urlKey);
+
+ if (!existingMemory) {
+ return res.status(404).json({ error: 'Memory not found.' });
+ }
+
+ if (newKey !== urlKey) {
+ const keyExists = memories.find((m) => m.key === newKey);
+ if (keyExists) {
+ return res.status(409).json({ error: 'Memory with this key already exists.' });
+ }
+
+ const createResult = await createMemory({
+ userId: req.user.id,
+ key: newKey,
+ value,
+ tokenCount,
+ });
+
+ if (!createResult.ok) {
+ return res.status(500).json({ error: 'Failed to create new memory.' });
+ }
+
+ const deleteResult = await deleteMemory({ userId: req.user.id, key: urlKey });
+ if (!deleteResult.ok) {
+ return res.status(500).json({ error: 'Failed to delete old memory.' });
+ }
+ } else {
+ const result = await setMemory({
+ userId: req.user.id,
+ key: newKey,
+ value,
+ tokenCount,
+ });
+
+ if (!result.ok) {
+ return res.status(500).json({ error: 'Failed to update memory.' });
+ }
+ }
+
+ const updatedMemories = await getAllUserMemories(req.user.id);
+ const updatedMemory = updatedMemories.find((m) => m.key === newKey);
+
+ res.json({ updated: true, memory: updatedMemory });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+/**
+ * DELETE /memories/:key
+ * Deletes a memory entry for the authenticated user.
+ * Returns 200 and { deleted: true } when successful.
+ */
+router.delete('/:key', checkMemoryDelete, async (req, res) => {
+ const { key } = req.params;
+
+ try {
+ const result = await deleteMemory({ userId: req.user.id, key });
+
+ if (!result.ok) {
+ return res.status(404).json({ error: 'Memory not found.' });
+ }
+
+ res.json({ deleted: true });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js
new file mode 100644
index 0000000..f541202
--- /dev/null
+++ b/api/server/routes/messages.js
@@ -0,0 +1,488 @@
+const express = require('express');
+const { v4: uuidv4 } = require('uuid');
+const { logger } = require('@librechat/data-schemas');
+const { ContentTypes, isAssistantsEndpoint } = require('librechat-data-provider');
+const {
+ unescapeLaTeX,
+ countTokens,
+ sendFeedbackScore,
+ traceIdForMessage,
+ mergeQuotedTextForCount,
+} = require('@librechat/api');
+const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update');
+const {
+ requireJwtAuth,
+ validateMessageReq,
+ configMiddleware,
+ sendValidationResponse,
+ prepareMessageRequestValidation,
+} = require('~/server/middleware');
+const db = require('~/models');
+
+const router = express.Router();
+router.use(requireJwtAuth);
+
+router.get('/', async (req, res) => {
+ try {
+ const user = req.user.id ?? '';
+ const {
+ cursor = null,
+ sortBy = 'updatedAt',
+ sortDirection = 'desc',
+ pageSize: pageSizeRaw,
+ conversationId,
+ messageId,
+ search,
+ } = req.query;
+ const pageSize = parseInt(pageSizeRaw, 10) || 25;
+
+ let response;
+ const sortField = ['endpoint', 'createdAt', 'updatedAt'].includes(sortBy)
+ ? sortBy
+ : 'createdAt';
+ const sortOrder = sortDirection === 'asc' ? 1 : -1;
+
+ if (conversationId && messageId) {
+ const messages = await db.getMessages({ conversationId, messageId, user });
+ response = { messages: messages?.length ? [messages[0]] : [], nextCursor: null };
+ } else if (conversationId) {
+ response = await db.getMessagesByCursor(
+ { conversationId, user },
+ { sortField, sortOrder, limit: pageSize, cursor },
+ );
+ } else if (search) {
+ const searchResults = await db.searchMessages(search, { filter: `user = "${user}"` }, true);
+
+ const messages = searchResults.hits || [];
+
+ const result = await db.getConvosQueried(req.user.id, messages, cursor);
+
+ const messageIds = [];
+ const cleanedMessages = [];
+ for (let i = 0; i < messages.length; i++) {
+ let message = messages[i];
+ if (result.convoMap[message.conversationId]) {
+ messageIds.push(message.messageId);
+ cleanedMessages.push(message);
+ }
+ }
+
+ const dbMessages = await db.getMessages({
+ user,
+ messageId: { $in: messageIds },
+ });
+
+ const dbMessageMap = {};
+ for (const dbMessage of dbMessages) {
+ dbMessageMap[dbMessage.messageId] = dbMessage;
+ }
+
+ const activeMessages = [];
+ for (const message of cleanedMessages) {
+ const convo = result.convoMap[message.conversationId];
+ const dbMessage = dbMessageMap[message.messageId];
+
+ activeMessages.push({
+ ...message,
+ title: convo.title,
+ conversationId: message.conversationId,
+ model: convo.model,
+ isCreatedByUser: dbMessage?.isCreatedByUser,
+ endpoint: dbMessage?.endpoint,
+ iconURL: dbMessage?.iconURL,
+ });
+ }
+
+ response = { messages: activeMessages, nextCursor: null };
+ } else {
+ response = { messages: [], nextCursor: null };
+ }
+
+ res.status(200).json(response);
+ } catch (error) {
+ logger.error('Error fetching messages:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * Creates a new branch message from a specific agent's content within a parallel response message.
+ * Filters the original message's content to only include parts attributed to the specified agentId.
+ * Only available for non-user messages with content attributions.
+ *
+ * @route POST /branch
+ * @param {string} req.body.messageId - The ID of the source message
+ * @param {string} req.body.agentId - The agentId to filter content by
+ * @returns {TMessage} The newly created branch message
+ */
+router.post('/branch', async (req, res) => {
+ try {
+ const { messageId, agentId } = req.body;
+ const userId = req.user.id;
+
+ if (!messageId || !agentId) {
+ return res.status(400).json({ error: 'messageId and agentId are required' });
+ }
+
+ const sourceMessage = await db.getMessage({ user: userId, messageId });
+ if (!sourceMessage) {
+ return res.status(404).json({ error: 'Source message not found' });
+ }
+
+ if (sourceMessage.isCreatedByUser) {
+ return res.status(400).json({ error: 'Cannot branch from user messages' });
+ }
+
+ if (!Array.isArray(sourceMessage.content)) {
+ return res.status(400).json({ error: 'Message does not have content' });
+ }
+
+ const hasAgentMetadata = sourceMessage.content.some((part) => part?.agentId);
+ if (!hasAgentMetadata) {
+ return res
+ .status(400)
+ .json({ error: 'Message does not have parallel content with attributions' });
+ }
+
+ /** @type {Array} */
+ const filteredContent = [];
+ for (const part of sourceMessage.content) {
+ if (part?.agentId === agentId) {
+ const { agentId: _a, groupId: _g, ...cleanPart } = part;
+ filteredContent.push(cleanPart);
+ }
+ }
+
+ if (filteredContent.length === 0) {
+ return res.status(400).json({ error: 'No content found for the specified agentId' });
+ }
+
+ const newMessageId = uuidv4();
+ /** @type {import('librechat-data-provider').TMessage} */
+ const newMessage = {
+ messageId: newMessageId,
+ conversationId: sourceMessage.conversationId,
+ parentMessageId: sourceMessage.parentMessageId,
+ attachments: sourceMessage.attachments,
+ isCreatedByUser: false,
+ model: sourceMessage.model,
+ endpoint: sourceMessage.endpoint,
+ sender: sourceMessage.sender,
+ iconURL: sourceMessage.iconURL,
+ content: filteredContent,
+ unfinished: false,
+ error: false,
+ user: userId,
+ };
+
+ const savedMessage = await db.saveMessage(
+ {
+ userId: req?.user?.id,
+ isTemporary: req?.body?.isTemporary,
+ interfaceConfig: req?.config?.interfaceConfig,
+ },
+ newMessage,
+ { context: 'POST /api/messages/branch' },
+ );
+
+ if (!savedMessage) {
+ return res.status(500).json({ error: 'Failed to save branch message' });
+ }
+
+ res.status(201).json(savedMessage);
+ } catch (error) {
+ logger.error('Error creating branch message:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+router.post('/artifact/:messageId', async (req, res) => {
+ try {
+ const { messageId } = req.params;
+ const { index, original, updated } = req.body;
+
+ if (typeof index !== 'number' || index < 0 || original == null || updated == null) {
+ return res.status(400).json({ error: 'Invalid request parameters' });
+ }
+
+ const message = await db.getMessage({ user: req.user.id, messageId });
+ if (!message) {
+ return res.status(404).json({ error: 'Message not found' });
+ }
+
+ const artifacts = findAllArtifacts(message);
+ if (index >= artifacts.length) {
+ return res.status(400).json({ error: 'Artifact index out of bounds' });
+ }
+
+ // Unescape LaTeX preprocessing done by the frontend
+ // The frontend escapes $ signs for display, but the database has unescaped versions
+ const unescapedOriginal = unescapeLaTeX(original);
+ const unescapedUpdated = unescapeLaTeX(updated);
+
+ const targetArtifact = artifacts[index];
+ let updatedText = null;
+
+ if (targetArtifact.source === 'content') {
+ const part = message.content[targetArtifact.partIndex];
+ updatedText = replaceArtifactContent(
+ part.text,
+ targetArtifact,
+ unescapedOriginal,
+ unescapedUpdated,
+ );
+ if (updatedText) {
+ part.text = updatedText;
+ }
+ } else {
+ updatedText = replaceArtifactContent(
+ message.text,
+ targetArtifact,
+ unescapedOriginal,
+ unescapedUpdated,
+ );
+ if (updatedText) {
+ message.text = updatedText;
+ }
+ }
+
+ if (!updatedText) {
+ return res.status(400).json({ error: 'Original content not found in target artifact' });
+ }
+
+ const savedMessage = await db.saveMessage(
+ {
+ userId: req?.user?.id,
+ isTemporary: req?.body?.isTemporary,
+ interfaceConfig: req?.config?.interfaceConfig,
+ },
+ {
+ messageId,
+ conversationId: message.conversationId,
+ text: message.text,
+ content: message.content,
+ user: req.user.id,
+ },
+ { context: 'POST /api/messages/artifact/:messageId' },
+ );
+
+ res.status(200).json({
+ conversationId: savedMessage.conversationId,
+ content: savedMessage.content,
+ text: savedMessage.text,
+ });
+ } catch (error) {
+ logger.error('Error editing artifact:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+router.get('/:conversationId', prepareMessageRequestValidation, async (req, res) => {
+ try {
+ const { conversationId } = req.params;
+ const validation = req.messageRequestValidation;
+ // This intentionally starts a user-scoped read before validation resolves;
+ // the response remains gated on validation success below.
+ const messagesPromise = validation.shouldFetchMessages
+ ? db.getMessages({ conversationId, user: req.user.id }, '-_id -__v -user').then(
+ (messages) => ({ messages }),
+ (error) => ({ error }),
+ )
+ : null;
+
+ const validationResult = await validation.promise;
+ if (!validationResult.ok) {
+ return sendValidationResponse(res, validationResult);
+ }
+
+ const messagesResult = await messagesPromise;
+ if (messagesResult?.error) {
+ throw messagesResult.error;
+ }
+
+ const messages = messagesResult?.messages ?? [];
+ res.status(200).json(messages);
+ } catch (error) {
+ logger.error('Error fetching messages:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+router.post('/:conversationId', validateMessageReq, async (req, res) => {
+ try {
+ const message = { ...req.body, conversationId: req.params.conversationId };
+ const reqCtx = {
+ userId: req?.user?.id,
+ isTemporary: req?.body?.isTemporary,
+ interfaceConfig: req?.config?.interfaceConfig,
+ };
+ const savedMessage = await db.saveMessage(
+ reqCtx,
+ { ...message, user: req.user.id },
+ { context: 'POST /api/messages/:conversationId' },
+ );
+ if (!savedMessage) {
+ return res.status(400).json({ error: 'Message not saved' });
+ }
+ await db.saveConvo(reqCtx, savedMessage, { context: 'POST /api/messages/:conversationId' });
+ res.status(201).json(savedMessage);
+ } catch (error) {
+ logger.error('Error saving message:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+router.get('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
+ try {
+ const { conversationId, messageId } = req.params;
+ const message = await db.getMessages(
+ { conversationId, messageId, user: req.user.id },
+ '-_id -__v -user',
+ );
+ if (!message) {
+ return res.status(404).json({ error: 'Message not found' });
+ }
+ res.status(200).json(message);
+ } catch (error) {
+ logger.error('Error fetching message:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
+ try {
+ const { conversationId, messageId } = req.params;
+ const { text, index, model } = req.body;
+
+ if (index === undefined) {
+ /** A user turn's persisted `quotes` are re-prepended into the prompt on
+ * every send, but this edit only changes `text`. Count the merged
+ * text+quotes so the stored `tokenCount` stays authoritative (matching the
+ * send path); a plain text-only count under-reports by the quote block. */
+ const existing = (
+ await db.getMessages(
+ { conversationId, messageId, user: req.user.id },
+ 'quotes isCreatedByUser',
+ )
+ )?.[0];
+ const textToCount = mergeQuotedTextForCount(
+ text,
+ existing?.quotes,
+ existing?.isCreatedByUser === true,
+ );
+ const tokenCount = await countTokens(textToCount, model);
+ const result = await db.updateMessage(req?.user?.id, { messageId, text, tokenCount });
+ return res.status(200).json(result);
+ }
+
+ if (typeof index !== 'number' || index < 0) {
+ return res.status(400).json({ error: 'Invalid index' });
+ }
+
+ const message = (
+ await db.getMessages({ conversationId, messageId, user: req.user.id }, 'content tokenCount')
+ )?.[0];
+ if (!message) {
+ return res.status(404).json({ error: 'Message not found' });
+ }
+
+ const existingContent = message.content;
+ if (!Array.isArray(existingContent) || index >= existingContent.length) {
+ return res.status(400).json({ error: 'Invalid index' });
+ }
+
+ const updatedContent = [...existingContent];
+ if (!updatedContent[index]) {
+ return res.status(400).json({ error: 'Content part not found' });
+ }
+
+ const currentPartType = updatedContent[index].type;
+ if (currentPartType !== ContentTypes.TEXT && currentPartType !== ContentTypes.THINK) {
+ return res.status(400).json({ error: 'Cannot update non-text content' });
+ }
+
+ const oldText = updatedContent[index][currentPartType];
+ updatedContent[index] = { type: currentPartType, [currentPartType]: text };
+
+ let tokenCount = message.tokenCount;
+ if (tokenCount !== undefined) {
+ const oldTokenCount = await countTokens(oldText, model);
+ const newTokenCount = await countTokens(text, model);
+ tokenCount = Math.max(0, tokenCount - oldTokenCount) + newTokenCount;
+ }
+
+ const result = await db.updateMessage(req?.user?.id, {
+ messageId,
+ content: updatedContent,
+ tokenCount,
+ });
+ return res.status(200).json(result);
+ } catch (error) {
+ logger.error('Error updating message:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+router.put(
+ '/:conversationId/:messageId/feedback',
+ validateMessageReq,
+ configMiddleware,
+ async (req, res) => {
+ try {
+ const { conversationId, messageId } = req.params;
+ const { feedback } = req.body;
+
+ const updatedMessage = await db.updateMessage(
+ req?.user?.id,
+ {
+ messageId,
+ feedback: feedback || null,
+ },
+ { context: 'updateFeedback' },
+ );
+
+ // Best-effort: Assistants messages do not have deterministic AgentRun traces.
+ if (!isAssistantsEndpoint(updatedMessage.endpoint)) {
+ sendFeedbackScore({
+ traceId: traceIdForMessage(messageId),
+ feedback: updatedMessage.feedback,
+ appConfig: req.config,
+ metadata: {
+ messageId: updatedMessage.messageId ?? messageId,
+ parentMessageId: updatedMessage.parentMessageId,
+ conversationId: updatedMessage.conversationId ?? conversationId,
+ sessionId: updatedMessage.conversationId ?? conversationId,
+ userId: req?.user?.id,
+ tenantId: req?.user?.tenantId,
+ endpoint: updatedMessage.endpoint,
+ sender: updatedMessage.sender,
+ isCreatedByUser: updatedMessage.isCreatedByUser,
+ tokenCount: updatedMessage.tokenCount,
+ },
+ }).catch((err) => logger.error('[langfuse] feedback score failed:', err));
+ }
+
+ res.json({
+ messageId,
+ conversationId,
+ feedback: updatedMessage.feedback,
+ });
+ } catch (error) {
+ logger.error('Error updating message feedback:', error);
+ res.status(500).json({ error: 'Failed to update feedback' });
+ }
+ },
+);
+
+router.delete('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
+ try {
+ const { conversationId, messageId } = req.params;
+ await db.deleteMessages({ messageId, conversationId, user: req.user.id });
+ res.status(204).send();
+ } catch (error) {
+ logger.error('Error deleting message:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/models.js b/api/server/routes/models.js
new file mode 100644
index 0000000..e327208
--- /dev/null
+++ b/api/server/routes/models.js
@@ -0,0 +1,8 @@
+const express = require('express');
+const { modelController } = require('~/server/controllers/ModelController');
+const { requireJwtAuth } = require('~/server/middleware/');
+
+const router = express.Router();
+router.get('/', requireJwtAuth, modelController);
+
+module.exports = router;
diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js
new file mode 100644
index 0000000..d5e4c93
--- /dev/null
+++ b/api/server/routes/oauth.js
@@ -0,0 +1,221 @@
+// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
+const express = require('express');
+const passport = require('passport');
+const { randomState } = require('openid-client');
+const { logger } = require('@librechat/data-schemas');
+const { ErrorTypes } = require('librechat-data-provider');
+const {
+ buildOAuthFailureLog,
+ createOpenIDCallbackAuthenticator,
+ createSetBalanceConfig,
+ getOAuthFailureMessage,
+ redirectToAuthFailure,
+} = require('@librechat/api');
+const { checkDomainAllowed, loginLimiter, logHeaders } = require('~/server/middleware');
+const { createOAuthHandler } = require('~/server/controllers/auth/oauth');
+const { findBalanceByUser, upsertBalanceFields } = require('~/models');
+const { getAppConfig } = require('~/server/services/Config');
+
+const setBalanceConfig = createSetBalanceConfig({
+ getAppConfig,
+ findBalanceByUser,
+ upsertBalanceFields,
+});
+
+const router = express.Router();
+
+const domains = {
+ client: process.env.DOMAIN_CLIENT,
+ server: process.env.DOMAIN_SERVER,
+};
+
+const authFailureRedirectOptions = {
+ clientDomain: domains.client,
+ authFailedError: ErrorTypes.AUTH_FAILED,
+};
+
+router.use(logHeaders);
+router.use(loginLimiter);
+
+const oauthHandler = createOAuthHandler();
+const authenticateOpenIDCallback = createOpenIDCallbackAuthenticator({
+ passport,
+ logger,
+ ...authFailureRedirectOptions,
+});
+
+router.get('/error', (req, res) => {
+ /** A single error message is pushed by passport when authentication fails. */
+ const errorMessage = getOAuthFailureMessage(req);
+ logger.warn(
+ '[OAuth] Authentication failed',
+ buildOAuthFailureLog({
+ provider: 'unknown',
+ req,
+ info: { message: errorMessage },
+ defaultMessage: errorMessage,
+ }),
+ );
+
+ redirectToAuthFailure(res, authFailureRedirectOptions);
+});
+
+/**
+ * Google Routes
+ */
+router.get(
+ '/google',
+ passport.authenticate('google', {
+ scope: ['openid', 'profile', 'email'],
+ session: false,
+ }),
+);
+
+router.get(
+ '/google/callback',
+ passport.authenticate('google', {
+ failureRedirect: `${domains.client}/oauth/error`,
+ failureMessage: true,
+ session: false,
+ scope: ['openid', 'profile', 'email'],
+ }),
+ setBalanceConfig,
+ checkDomainAllowed,
+ oauthHandler,
+);
+
+/**
+ * Facebook Routes
+ */
+router.get(
+ '/facebook',
+ passport.authenticate('facebook', {
+ scope: ['public_profile'],
+ profileFields: ['id', 'email', 'name'],
+ session: false,
+ }),
+);
+
+router.get(
+ '/facebook/callback',
+ passport.authenticate('facebook', {
+ failureRedirect: `${domains.client}/oauth/error`,
+ failureMessage: true,
+ session: false,
+ scope: ['public_profile'],
+ profileFields: ['id', 'email', 'name'],
+ }),
+ setBalanceConfig,
+ checkDomainAllowed,
+ oauthHandler,
+);
+
+/**
+ * OpenID Routes
+ */
+router.get('/openid', (req, res, next) => {
+ return passport.authenticate('openid', {
+ session: false,
+ state: randomState(),
+ })(req, res, next);
+});
+
+router.get(
+ '/openid/callback',
+ authenticateOpenIDCallback,
+ setBalanceConfig,
+ checkDomainAllowed,
+ oauthHandler,
+);
+
+/**
+ * GitHub Routes
+ */
+router.get(
+ '/github',
+ passport.authenticate('github', {
+ scope: ['user:email', 'read:user'],
+ session: false,
+ }),
+);
+
+router.get(
+ '/github/callback',
+ passport.authenticate('github', {
+ failureRedirect: `${domains.client}/oauth/error`,
+ failureMessage: true,
+ session: false,
+ scope: ['user:email', 'read:user'],
+ }),
+ setBalanceConfig,
+ checkDomainAllowed,
+ oauthHandler,
+);
+
+/**
+ * Discord Routes
+ */
+router.get(
+ '/discord',
+ passport.authenticate('discord', {
+ scope: ['identify', 'email'],
+ session: false,
+ }),
+);
+
+router.get(
+ '/discord/callback',
+ passport.authenticate('discord', {
+ failureRedirect: `${domains.client}/oauth/error`,
+ failureMessage: true,
+ session: false,
+ scope: ['identify', 'email'],
+ }),
+ setBalanceConfig,
+ checkDomainAllowed,
+ oauthHandler,
+);
+
+/**
+ * Apple Routes
+ */
+router.get(
+ '/apple',
+ passport.authenticate('apple', {
+ session: false,
+ }),
+);
+
+router.post(
+ '/apple/callback',
+ passport.authenticate('apple', {
+ failureRedirect: `${domains.client}/oauth/error`,
+ failureMessage: true,
+ session: false,
+ }),
+ setBalanceConfig,
+ checkDomainAllowed,
+ oauthHandler,
+);
+
+/**
+ * SAML Routes
+ */
+router.get(
+ '/saml',
+ passport.authenticate('saml', {
+ session: false,
+ }),
+);
+
+router.post(
+ '/saml/callback',
+ passport.authenticate('saml', {
+ failureRedirect: `${domains.client}/oauth/error`,
+ failureMessage: true,
+ session: false,
+ }),
+ oauthHandler,
+);
+
+module.exports = router;
diff --git a/api/server/routes/oauth.test.js b/api/server/routes/oauth.test.js
new file mode 100644
index 0000000..b6739ff
--- /dev/null
+++ b/api/server/routes/oauth.test.js
@@ -0,0 +1,191 @@
+const express = require('express');
+const request = require('supertest');
+
+const originalDomainClient = process.env.DOMAIN_CLIENT;
+process.env.DOMAIN_CLIENT = 'http://client.test';
+
+const mockLogger = {
+ warn: jest.fn(),
+ error: jest.fn(),
+ info: jest.fn(),
+ debug: jest.fn(),
+};
+
+const mockOAuthHandler = jest.fn((_req, res) => res.status(204).end());
+const mockOpenIDCallbackMiddleware = jest.fn((_req, _res, next) => next());
+let mockOpenIDCallbackAuthenticatorOptions;
+const mockCreateOpenIDCallbackAuthenticator = jest.fn((options) => {
+ mockOpenIDCallbackAuthenticatorOptions = options;
+ return mockOpenIDCallbackMiddleware;
+});
+const mockBuildOAuthFailureLog = jest.fn(({ provider, req, err, info, defaultMessage }) => ({
+ provider,
+ code: err?.code ?? info?.code ?? info?.error ?? req.query?.error,
+ name: err?.name ?? info?.name,
+ message:
+ err?.message ??
+ info?.message ??
+ info?.error_description ??
+ req.query?.error_description ??
+ defaultMessage,
+ cause_code: err?.cause?.code ?? info?.cause?.code,
+ cause_name: err?.cause?.name ?? info?.cause?.name,
+ has_code: req.query?.code != null,
+ has_state: req.query?.state != null,
+ query_error: req.query?.error,
+ query_error_description: req.query?.error_description,
+ path: req.path,
+ forwarded_for: req.headers?.['x-forwarded-for'],
+ user_agent: req.headers?.['user-agent'],
+}));
+const mockGetOAuthFailureMessage = jest.fn(
+ (req) =>
+ req.session?.messages?.pop() ??
+ req.query?.error_description ??
+ req.query?.error ??
+ 'OAuth authentication failed',
+);
+const mockRedirectToAuthFailure = jest.fn((res, { clientDomain, authFailedError }) =>
+ res.redirect(`${clientDomain}/login?redirect=false&error=${authFailedError}`),
+);
+const mockPassportAuthenticate = jest.fn(() => (_req, _res, next) => next());
+
+jest.mock('passport', () => ({
+ authenticate: (...args) => mockPassportAuthenticate(...args),
+}));
+
+jest.mock('openid-client', () => ({
+ randomState: jest.fn(() => 'random-state'),
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: mockLogger,
+}));
+
+jest.mock('librechat-data-provider', () => ({
+ ...jest.requireActual('librechat-data-provider'),
+ ErrorTypes: {
+ AUTH_FAILED: 'auth_failed',
+ },
+}));
+
+jest.mock('@librechat/api', () => ({
+ buildOAuthFailureLog: (...args) => mockBuildOAuthFailureLog(...args),
+ createOpenIDCallbackAuthenticator: (...args) => mockCreateOpenIDCallbackAuthenticator(...args),
+ createSetBalanceConfig: jest.fn(() => (_req, _res, next) => next()),
+ getOAuthFailureMessage: (...args) => mockGetOAuthFailureMessage(...args),
+ redirectToAuthFailure: (...args) => mockRedirectToAuthFailure(...args),
+}));
+
+jest.mock('~/server/middleware', () => ({
+ checkDomainAllowed: jest.fn((_req, _res, next) => next()),
+ loginLimiter: jest.fn((_req, _res, next) => next()),
+ logHeaders: jest.fn((_req, _res, next) => next()),
+}));
+
+jest.mock('~/server/controllers/auth/oauth', () => ({
+ createOAuthHandler: jest.fn(() => mockOAuthHandler),
+}));
+
+jest.mock('~/models', () => ({
+ findBalanceByUser: jest.fn(),
+ upsertBalanceFields: jest.fn(),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn(),
+}));
+
+afterAll(() => {
+ if (originalDomainClient === undefined) {
+ delete process.env.DOMAIN_CLIENT;
+ return;
+ }
+ process.env.DOMAIN_CLIENT = originalDomainClient;
+});
+
+function getOAuthRouter() {
+ jest.resetModules();
+ return require('./oauth');
+}
+
+function createApp(sessionMessages) {
+ const app = express();
+ app.use((req, _res, next) => {
+ if (sessionMessages) {
+ req.session = { messages: [...sessionMessages] };
+ }
+ next();
+ });
+ app.use('/oauth', getOAuthRouter());
+ app.use((err, _req, res, _next) => {
+ res.status(500).json({ message: err.message });
+ });
+ return app;
+}
+
+describe('OAuth route failure logging', () => {
+ beforeEach(() => {
+ mockLogger.warn.mockClear();
+ mockLogger.error.mockClear();
+ mockLogger.info.mockClear();
+ mockLogger.debug.mockClear();
+ mockOAuthHandler.mockClear();
+ mockOpenIDCallbackMiddleware.mockClear();
+ mockBuildOAuthFailureLog.mockClear();
+ mockGetOAuthFailureMessage.mockClear();
+ mockRedirectToAuthFailure.mockClear();
+ mockPassportAuthenticate.mockClear();
+ mockOpenIDCallbackAuthenticatorOptions = undefined;
+ mockPassportAuthenticate.mockImplementation(() => (_req, _res, next) => next());
+ mockOpenIDCallbackMiddleware.mockImplementation((_req, _res, next) => next());
+ });
+
+ it('wires the package OpenID callback middleware into the route', async () => {
+ const app = createApp();
+
+ await request(app)
+ .get('/oauth/openid/callback?code=secret-code&state=secret-state')
+ .expect(204);
+
+ expect(mockOpenIDCallbackAuthenticatorOptions).toEqual({
+ passport: expect.objectContaining({ authenticate: expect.any(Function) }),
+ logger: mockLogger,
+ clientDomain: 'http://client.test',
+ authFailedError: 'auth_failed',
+ });
+ expect(mockOpenIDCallbackMiddleware).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.any(Object),
+ expect.any(Function),
+ );
+ expect(mockOAuthHandler).toHaveBeenCalled();
+ });
+
+ it('logs structured fallback errors without using Unknown OAuth error', async () => {
+ const app = createApp();
+
+ const response = await request(app)
+ .get('/oauth/error?error=access_denied&error_description=Denied%20by%20provider')
+ .set('x-forwarded-for', '203.0.113.10')
+ .expect(302);
+
+ expect(response.headers.location).toBe(
+ 'http://client.test/login?redirect=false&error=auth_failed',
+ );
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ '[OAuth] Authentication failed',
+ expect.objectContaining({
+ provider: 'unknown',
+ code: 'access_denied',
+ message: 'Denied by provider',
+ query_error: 'access_denied',
+ query_error_description: 'Denied by provider',
+ has_code: false,
+ has_state: false,
+ forwarded_for: '203.0.113.10',
+ }),
+ );
+ expect(JSON.stringify(mockLogger.warn.mock.calls[0])).not.toContain('Unknown OAuth error');
+ });
+});
diff --git a/api/server/routes/presets.js b/api/server/routes/presets.js
new file mode 100644
index 0000000..b8be1b8
--- /dev/null
+++ b/api/server/routes/presets.js
@@ -0,0 +1,48 @@
+const crypto = require('crypto');
+const express = require('express');
+const { logger } = require('@librechat/data-schemas');
+const { getPresets, savePreset, deletePresets } = require('~/models');
+const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
+
+const router = express.Router();
+router.use(requireJwtAuth);
+
+router.get('/', async (req, res) => {
+ const presets = (await getPresets(req.user.id)).map((preset) => preset);
+ res.status(200).json(presets);
+});
+
+router.post('/', async (req, res) => {
+ const update = req.body || {};
+
+ update.presetId = update?.presetId || crypto.randomUUID();
+
+ try {
+ const preset = await savePreset(req.user.id, update);
+ res.status(201).json(preset);
+ } catch (error) {
+ logger.error('[/presets] error saving preset', error);
+ res.status(500).send('There was an error when saving the preset');
+ }
+});
+
+router.post('/delete', async (req, res) => {
+ let filter = {};
+ const { presetId } = req.body || {};
+
+ if (presetId) {
+ filter = { presetId };
+ }
+
+ logger.debug('[/presets/delete] delete preset filter', filter);
+
+ try {
+ const deleteCount = await deletePresets(req.user.id, filter);
+ res.status(201).json(deleteCount);
+ } catch (error) {
+ logger.error('[/presets/delete] error deleting presets', error);
+ res.status(500).send('There was an error deleting the presets');
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/projects.js b/api/server/routes/projects.js
new file mode 100644
index 0000000..fd782a1
--- /dev/null
+++ b/api/server/routes/projects.js
@@ -0,0 +1,25 @@
+const express = require('express');
+const { createProjectHandlers } = require('@librechat/api');
+const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
+const db = require('~/models');
+
+const router = express.Router();
+const handlers = createProjectHandlers({
+ listChatProjects: db.listChatProjects,
+ createChatProject: db.createChatProject,
+ getChatProject: db.getChatProject,
+ updateChatProject: db.updateChatProject,
+ deleteChatProject: db.deleteChatProject,
+ assignConversationToProject: db.assignConversationToProject,
+});
+
+router.use(requireJwtAuth);
+
+router.get('/', handlers.listProjects);
+router.post('/', handlers.createProject);
+router.put('/conversations/:conversationId', handlers.assignConversationToProject);
+router.get('/:projectId', handlers.getProject);
+router.patch('/:projectId', handlers.updateProject);
+router.delete('/:projectId', handlers.deleteProject);
+
+module.exports = router;
diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js
new file mode 100644
index 0000000..5fcf51b
--- /dev/null
+++ b/api/server/routes/prompts.js
@@ -0,0 +1,562 @@
+const express = require('express');
+const { ObjectId } = require('mongodb');
+const { logger, isValidObjectIdString } = require('@librechat/data-schemas');
+const {
+ generateCheckAccess,
+ markPublicPromptGroups,
+ buildPromptGroupFilter,
+ formatPromptGroupsResponse,
+ safeValidatePromptGroupUpdate,
+ createEmptyPromptGroupsResponse,
+ filterAccessibleIdsBySharedLogic,
+} = require('@librechat/api');
+const {
+ Permissions,
+ ResourceType,
+ AccessRoleIds,
+ PrincipalType,
+ PermissionBits,
+ PermissionTypes,
+} = require('librechat-data-provider');
+const { SystemCapabilities } = require('@librechat/data-schemas');
+const {
+ getListPromptGroupsByAccess,
+ getOwnedPromptGroupIds,
+ incrementPromptGroupUsage,
+ makePromptProduction,
+ updatePromptGroup,
+ deletePromptGroup,
+ createPromptGroup,
+ getPromptGroup,
+ getRoleByName,
+ deletePrompt,
+ getPrompts,
+ savePrompt,
+ getPrompt,
+} = require('~/models');
+const {
+ canAccessPromptGroupResource,
+ canAccessPromptViaGroup,
+ promptUsageLimiter,
+ requireJwtAuth,
+} = require('~/server/middleware');
+const {
+ findPubliclyAccessibleResources,
+ getEffectivePermissions,
+ findAccessibleResources,
+ grantPermission,
+} = require('~/server/services/PermissionService');
+const { hasCapability } = require('~/server/middleware/roles/capabilities');
+
+const router = express.Router();
+
+const checkPromptAccess = generateCheckAccess({
+ permissionType: PermissionTypes.PROMPTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+});
+const checkPromptCreate = generateCheckAccess({
+ permissionType: PermissionTypes.PROMPTS,
+ permissions: [Permissions.USE, Permissions.CREATE],
+ getRoleByName,
+});
+
+router.use(requireJwtAuth);
+router.use(checkPromptAccess);
+
+const checkGlobalPromptShare = generateCheckAccess({
+ permissionType: PermissionTypes.PROMPTS,
+ permissions: [Permissions.USE, Permissions.CREATE],
+ getRoleByName,
+});
+
+/**
+ * Route to get single prompt group by its ID
+ * GET /groups/:groupId
+ */
+router.get(
+ '/groups/:groupId',
+ canAccessPromptGroupResource({
+ requiredPermission: PermissionBits.VIEW,
+ }),
+ async (req, res) => {
+ const { groupId } = req.params;
+
+ try {
+ const group = await getPromptGroup({ _id: groupId });
+
+ if (!group) {
+ return res.status(404).send({ message: 'Prompt group not found' });
+ }
+
+ res.status(200).send(group);
+ } catch (error) {
+ logger.error('Error getting prompt group', error);
+ res.status(500).send({ message: 'Error getting prompt group' });
+ }
+ },
+);
+
+/**
+ * Route to fetch all prompt groups (ACL-aware)
+ * GET /all
+ */
+router.get('/all', async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { name, category } = req.query;
+ const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({
+ name,
+ category,
+ });
+
+ let accessibleIds = await findAccessibleResources({
+ userId,
+ role: req.user.role,
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermissions: PermissionBits.VIEW,
+ });
+
+ const [publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([
+ findPubliclyAccessibleResources({
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermissions: PermissionBits.VIEW,
+ }),
+ getOwnedPromptGroupIds(userId),
+ ]);
+
+ const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({
+ accessibleIds,
+ searchShared,
+ searchSharedOnly,
+ publicPromptGroupIds: publiclyAccessibleIds,
+ ownedPromptGroupIds,
+ });
+
+ const result = await getListPromptGroupsByAccess({
+ accessibleIds: filteredAccessibleIds,
+ otherParams: filter,
+ });
+
+ if (!result) {
+ return res.status(200).send([]);
+ }
+
+ const { data: promptGroups = [] } = result;
+ if (!promptGroups.length) {
+ return res.status(200).send([]);
+ }
+
+ const groupsWithPublicFlag = markPublicPromptGroups(promptGroups, publiclyAccessibleIds);
+ res.status(200).send(groupsWithPublicFlag);
+ } catch (error) {
+ logger.error(error);
+ res.status(500).send({ error: 'Error getting prompt groups' });
+ }
+});
+
+/**
+ * Route to fetch paginated prompt groups with filters (ACL-aware)
+ * GET /groups
+ */
+router.get('/groups', async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { pageSize, limit, cursor, name, category } = req.query;
+
+ const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({
+ name,
+ category,
+ });
+
+ let actualLimit = limit;
+ let actualCursor = cursor;
+
+ if (pageSize && !limit) {
+ actualLimit = parseInt(pageSize, 10);
+ }
+
+ if (
+ actualCursor &&
+ (actualCursor === 'undefined' || actualCursor === 'null' || actualCursor.length === 0)
+ ) {
+ actualCursor = null;
+ }
+
+ let accessibleIds = await findAccessibleResources({
+ userId,
+ role: req.user.role,
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermissions: PermissionBits.VIEW,
+ });
+
+ const [publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([
+ findPubliclyAccessibleResources({
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermissions: PermissionBits.VIEW,
+ }),
+ getOwnedPromptGroupIds(userId),
+ ]);
+
+ const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({
+ accessibleIds,
+ searchShared,
+ searchSharedOnly,
+ publicPromptGroupIds: publiclyAccessibleIds,
+ ownedPromptGroupIds,
+ });
+
+ // Cursor-based pagination only
+ const result = await getListPromptGroupsByAccess({
+ accessibleIds: filteredAccessibleIds,
+ otherParams: filter,
+ limit: actualLimit,
+ after: actualCursor,
+ });
+
+ if (!result) {
+ const emptyResponse = createEmptyPromptGroupsResponse({
+ pageNumber: '1',
+ pageSize: actualLimit,
+ actualLimit,
+ });
+ return res.status(200).send(emptyResponse);
+ }
+
+ const { data: promptGroups = [], has_more = false, after = null } = result;
+ const groupsWithPublicFlag = markPublicPromptGroups(promptGroups, publiclyAccessibleIds);
+
+ const response = formatPromptGroupsResponse({
+ promptGroups: groupsWithPublicFlag,
+ pageNumber: '1', // Always 1 for cursor-based pagination
+ pageSize: actualLimit.toString(),
+ hasMore: has_more,
+ after,
+ });
+
+ res.status(200).send(response);
+ } catch (error) {
+ logger.error(error);
+ res.status(500).send({ error: 'Error getting prompt groups' });
+ }
+});
+
+/**
+ * Creates a new prompt group with initial prompt
+ * @param {object} req
+ * @param {TCreatePrompt} req.body
+ * @param {Express.Response} res
+ */
+const createNewPromptGroup = async (req, res) => {
+ try {
+ const { prompt, group } = req.body;
+
+ if (!prompt || !group || !group.name) {
+ return res.status(400).send({ error: 'Prompt and group name are required' });
+ }
+
+ const saveData = {
+ prompt,
+ group,
+ author: req.user.id,
+ authorName: req.user.name,
+ };
+
+ const result = await createPromptGroup(saveData);
+
+ if (result.prompt && result.prompt._id && result.prompt.groupId) {
+ try {
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: req.user.id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: result.prompt.groupId,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: req.user.id,
+ });
+ logger.debug(
+ `[createPromptGroup] Granted owner permissions to user ${req.user.id} for promptGroup ${result.prompt.groupId}`,
+ );
+ } catch (permissionError) {
+ logger.error(
+ `[createPromptGroup] Failed to grant owner permissions for promptGroup ${result.prompt.groupId}:`,
+ permissionError,
+ );
+ }
+ }
+
+ res.status(200).send(result);
+ } catch (error) {
+ logger.error(error);
+ res.status(500).send({ error: 'Error creating prompt group' });
+ }
+};
+
+/**
+ * Adds a new prompt to an existing prompt group
+ * @param {object} req
+ * @param {TCreatePrompt} req.body
+ * @param {Express.Response} res
+ */
+const addPromptToGroup = async (req, res) => {
+ try {
+ const { groupId } = req.params;
+ const { prompt } = req.body;
+
+ if (!prompt) {
+ return res.status(400).send({ error: 'Prompt is required' });
+ }
+
+ if (typeof prompt.prompt !== 'string' || !prompt.prompt.trim()) {
+ return res
+ .status(400)
+ .send({ error: 'Prompt text is required and must be a non-empty string' });
+ }
+
+ if (prompt.type !== 'text' && prompt.type !== 'chat') {
+ return res.status(400).send({ error: 'Prompt type must be "text" or "chat"' });
+ }
+
+ // Ensure the prompt is associated with the correct group
+ prompt.groupId = groupId;
+
+ const saveData = {
+ prompt,
+ author: req.user.id,
+ authorName: req.user.name,
+ };
+
+ const result = await savePrompt(saveData);
+ res.status(200).send(result);
+ } catch (error) {
+ logger.error(error);
+ res.status(500).send({ error: 'Error adding prompt to group' });
+ }
+};
+
+// Create new prompt group (requires CREATE permission)
+router.post('/', checkPromptCreate, createNewPromptGroup);
+
+// Add prompt to existing group (requires EDIT permission on the group)
+router.post(
+ '/groups/:groupId/prompts',
+ checkPromptAccess,
+ canAccessPromptGroupResource({
+ requiredPermission: PermissionBits.EDIT,
+ }),
+ addPromptToGroup,
+);
+
+/**
+ * Records a prompt group usage (increments numberOfGenerations)
+ * POST /groups/:groupId/use
+ */
+router.post(
+ '/groups/:groupId/use',
+ promptUsageLimiter,
+ canAccessPromptGroupResource({
+ requiredPermission: PermissionBits.VIEW,
+ }),
+ async (req, res) => {
+ try {
+ const { groupId } = req.params;
+ if (!isValidObjectIdString(groupId)) {
+ return res.status(400).send({ error: 'Invalid groupId' });
+ }
+ const result = await incrementPromptGroupUsage(groupId);
+ res.status(200).send(result);
+ } catch (error) {
+ logger.error('[recordPromptUsage]', error);
+ if (error.message === 'Invalid groupId') {
+ return res.status(400).send({ error: 'Invalid groupId' });
+ }
+ if (error.message === 'Prompt group not found') {
+ return res.status(404).send({ error: 'Prompt group not found' });
+ }
+ res.status(500).send({ error: 'Error recording prompt usage' });
+ }
+ },
+);
+
+/**
+ * Updates a prompt group
+ * @param {object} req
+ * @param {object} req.params - The request parameters
+ * @param {string} req.params.groupId - The group ID
+ * @param {TUpdatePromptGroupPayload} req.body - The request body
+ * @param {Express.Response} res
+ */
+const patchPromptGroup = async (req, res) => {
+ try {
+ const { groupId } = req.params;
+ // Don't pass author - permissions are now checked by middleware
+ const filter = { _id: groupId };
+
+ const validationResult = safeValidatePromptGroupUpdate(req.body);
+ if (!validationResult.success) {
+ return res.status(400).send({
+ error: 'Invalid request body',
+ details: validationResult.error.errors,
+ });
+ }
+
+ const promptGroup = await updatePromptGroup(filter, validationResult.data);
+ res.status(200).send(promptGroup);
+ } catch (error) {
+ logger.error(error);
+ res.status(500).send({ error: 'Error updating prompt group' });
+ }
+};
+
+router.patch(
+ '/groups/:groupId',
+ checkGlobalPromptShare,
+ canAccessPromptGroupResource({
+ requiredPermission: PermissionBits.EDIT,
+ }),
+ patchPromptGroup,
+);
+
+router.patch(
+ '/:promptId/tags/production',
+ checkPromptCreate,
+ canAccessPromptViaGroup({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'promptId',
+ }),
+ async (req, res) => {
+ try {
+ const { promptId } = req.params;
+ const result = await makePromptProduction(promptId);
+ res.status(200).send(result);
+ } catch (error) {
+ logger.error(error);
+ res.status(500).send({ error: 'Error updating prompt production' });
+ }
+ },
+);
+
+router.get(
+ '/:promptId',
+ canAccessPromptViaGroup({
+ requiredPermission: PermissionBits.VIEW,
+ resourceIdParam: 'promptId',
+ }),
+ async (req, res) => {
+ const { promptId } = req.params;
+ const prompt = await getPrompt({ _id: promptId });
+ res.status(200).send(prompt);
+ },
+);
+
+router.get('/', async (req, res) => {
+ try {
+ const author = req.user.id;
+ const { groupId } = req.query;
+
+ // If requesting prompts for a specific group, check permissions
+ if (groupId) {
+ if (!isValidObjectIdString(groupId)) {
+ return res.status(400).send({ error: 'Invalid groupId' });
+ }
+
+ const permissions = await getEffectivePermissions({
+ userId: req.user.id,
+ role: req.user.role,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: groupId,
+ });
+
+ if (!(permissions & PermissionBits.VIEW)) {
+ return res
+ .status(403)
+ .send({ error: 'Insufficient permissions to view prompts in this group' });
+ }
+
+ // If user has access, fetch all prompts in the group (not just their own)
+ const prompts = await getPrompts({ groupId: new ObjectId(groupId) });
+ return res.status(200).send(prompts);
+ }
+
+ // If no groupId, return user's own prompts
+ const query = { author };
+ let canReadPrompts = false;
+ try {
+ canReadPrompts = await hasCapability(req.user, SystemCapabilities.READ_PROMPTS);
+ } catch (err) {
+ logger.warn(`[GET /prompts] capability check failed, denying bypass: ${err.message}`);
+ }
+ if (canReadPrompts) {
+ logger.debug(`[GET /prompts] READ_PROMPTS bypass for user ${req.user.id}`);
+ delete query.author;
+ }
+ const prompts = await getPrompts(query);
+ res.status(200).send(prompts);
+ } catch (error) {
+ logger.error(error);
+ res.status(500).send({ error: 'Error getting prompts' });
+ }
+});
+
+/**
+ * Deletes a prompt
+ *
+ * @param {ServerRequest} req - The request object.
+ * @param {TDeletePromptVariables} req.params - The request parameters
+ * @param {import('mongoose').ObjectId} req.params.promptId - The prompt ID
+ * @param {Express.Response} res - The response object.
+ * @return {TDeletePromptResponse} A promise that resolves when the prompt is deleted.
+ */
+const deletePromptController = async (req, res) => {
+ try {
+ const { promptId } = req.params;
+ const { groupId } = req.query;
+ if (!groupId || !isValidObjectIdString(groupId)) {
+ return res.status(400).send({ error: 'Invalid or missing groupId' });
+ }
+ const query = { promptId, groupId };
+ const result = await deletePrompt(query);
+ res.status(200).send(result);
+ } catch (error) {
+ logger.error(error);
+ res.status(500).send({ error: 'Error deleting prompt' });
+ }
+};
+
+/**
+ * Delete a prompt group
+ * @param {ServerRequest} req
+ * @param {ServerResponse} res
+ * @returns {Promise}
+ */
+const deletePromptGroupController = async (req, res) => {
+ try {
+ const { groupId: _id } = req.params;
+ // Don't pass author or role - permissions are checked by ACL middleware
+ const message = await deletePromptGroup({ _id });
+ res.send(message);
+ } catch (error) {
+ logger.error('Error deleting prompt group', error);
+ res.status(500).send({ message: 'Error deleting prompt group' });
+ }
+};
+
+router.delete(
+ '/:promptId',
+ checkPromptCreate,
+ canAccessPromptViaGroup({
+ requiredPermission: PermissionBits.DELETE,
+ resourceIdParam: 'promptId',
+ }),
+ deletePromptController,
+);
+router.delete(
+ '/groups/:groupId',
+ checkPromptCreate,
+ canAccessPromptGroupResource({
+ requiredPermission: PermissionBits.DELETE,
+ }),
+ deletePromptGroupController,
+);
+
+module.exports = router;
diff --git a/api/server/routes/prompts.test.js b/api/server/routes/prompts.test.js
new file mode 100644
index 0000000..c979023
--- /dev/null
+++ b/api/server/routes/prompts.test.js
@@ -0,0 +1,1042 @@
+const express = require('express');
+const request = require('supertest');
+const mongoose = require('mongoose');
+const { ObjectId } = require('mongodb');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const {
+ SystemRoles,
+ ResourceType,
+ AccessRoleIds,
+ PrincipalType,
+ PermissionBits,
+} = require('librechat-data-provider');
+const { SystemCapabilities } = require('@librechat/data-schemas');
+
+// Mock modules before importing
+jest.mock('~/server/services/Config', () => ({
+ getCachedTools: jest.fn().mockResolvedValue({}),
+}));
+
+jest.mock('~/models', () => {
+ const mongoose = require('mongoose');
+ const { createMethods } = require('@librechat/data-schemas');
+ const methods = createMethods(mongoose, {
+ removeAllPermissions: async ({ resourceType, resourceId }) => {
+ const AclEntry = mongoose.models.AclEntry;
+ if (AclEntry) {
+ await AclEntry.deleteMany({ resourceType, resourceId });
+ }
+ },
+ });
+ return {
+ ...methods,
+ getRoleByName: jest.fn(),
+ };
+});
+
+jest.mock('~/server/middleware', () => ({
+ requireJwtAuth: (req, res, next) => next(),
+ promptUsageLimiter: (req, res, next) => next(),
+ canAccessPromptViaGroup: jest.requireActual('~/server/middleware').canAccessPromptViaGroup,
+ canAccessPromptGroupResource:
+ jest.requireActual('~/server/middleware').canAccessPromptGroupResource,
+}));
+
+let app;
+let mongoServer;
+let promptRoutes;
+let Prompt, PromptGroup, AclEntry, AccessRole, User, SystemGrant;
+let testUsers, testRoles;
+let grantPermission;
+let currentTestUser; // Track current user for middleware
+
+// Helper function to set user in middleware
+function setTestUser(app, user) {
+ currentTestUser = user;
+}
+
+beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+
+ // Initialize models
+ const dbModels = require('~/db/models');
+ Prompt = dbModels.Prompt;
+ PromptGroup = dbModels.PromptGroup;
+ AclEntry = dbModels.AclEntry;
+ AccessRole = dbModels.AccessRole;
+ User = dbModels.User;
+ SystemGrant = dbModels.SystemGrant;
+
+ // Import permission service
+ const permissionService = require('~/server/services/PermissionService');
+ grantPermission = permissionService.grantPermission;
+
+ // Create test data
+ await setupTestData();
+
+ // Setup Express app
+ app = express();
+ app.use(express.json());
+
+ // Add user middleware before routes
+ app.use((req, res, next) => {
+ if (currentTestUser) {
+ req.user = {
+ ...(currentTestUser.toObject ? currentTestUser.toObject() : currentTestUser),
+ id: currentTestUser._id.toString(),
+ _id: currentTestUser._id,
+ name: currentTestUser.name,
+ role: currentTestUser.role,
+ };
+ }
+ next();
+ });
+
+ // Set default user
+ currentTestUser = testUsers.owner;
+
+ // Import routes after middleware is set up
+ promptRoutes = require('./prompts');
+ app.use('/api/prompts', promptRoutes);
+});
+
+afterEach(() => {
+ // Always reset to owner user after each test for isolation
+ if (currentTestUser !== testUsers.owner) {
+ currentTestUser = testUsers.owner;
+ }
+});
+
+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',
+ resourceType: ResourceType.PROMPTGROUP,
+ permBits: PermissionBits.VIEW,
+ }),
+ editor: await AccessRole.create({
+ accessRoleId: AccessRoleIds.PROMPTGROUP_EDITOR,
+ name: 'Editor',
+ resourceType: ResourceType.PROMPTGROUP,
+ permBits: PermissionBits.VIEW | PermissionBits.EDIT,
+ }),
+ owner: await AccessRole.create({
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ name: 'Owner',
+ 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,
+ }),
+ viewer: await User.create({
+ name: 'Prompt Viewer',
+ email: 'viewer@example.com',
+ role: SystemRoles.USER,
+ }),
+ editor: await User.create({
+ name: 'Prompt Editor',
+ email: 'editor@example.com',
+ role: SystemRoles.USER,
+ }),
+ noAccess: await User.create({
+ name: 'No Access',
+ email: 'noaccess@example.com',
+ role: SystemRoles.USER,
+ }),
+ admin: await User.create({
+ name: 'Admin',
+ email: 'admin@example.com',
+ role: SystemRoles.ADMIN,
+ }),
+ };
+
+ // Seed capabilities for the ADMIN role
+ await SystemGrant.create([
+ {
+ principalType: PrincipalType.ROLE,
+ principalId: SystemRoles.ADMIN,
+ capability: SystemCapabilities.MANAGE_PROMPTS,
+ grantedAt: new Date(),
+ },
+ {
+ principalType: PrincipalType.ROLE,
+ principalId: SystemRoles.ADMIN,
+ capability: SystemCapabilities.READ_PROMPTS,
+ grantedAt: new Date(),
+ },
+ ]);
+
+ // Mock getRoleByName
+ const { getRoleByName } = require('~/models');
+ getRoleByName.mockImplementation((roleName) => {
+ switch (roleName) {
+ case SystemRoles.USER:
+ return { permissions: { PROMPTS: { USE: true, CREATE: true } } };
+ case SystemRoles.ADMIN:
+ return { permissions: { PROMPTS: { USE: true, CREATE: true, SHARE: true } } };
+ default:
+ return null;
+ }
+ });
+}
+
+describe('Prompt Routes - ACL Permissions', () => {
+ let consoleErrorSpy;
+
+ beforeEach(() => {
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
+ });
+
+ afterEach(() => {
+ consoleErrorSpy.mockRestore();
+ });
+
+ // Simple test to verify route is loaded
+ it('should have routes loaded', async () => {
+ // This should at least not crash
+ const response = await request(app).get('/api/prompts/test-404');
+
+ // We expect a 401 or 404, not 500
+ expect(response.status).not.toBe(500);
+ });
+
+ describe('POST /api/prompts - Create Prompt', () => {
+ afterEach(async () => {
+ await Prompt.deleteMany({});
+ await PromptGroup.deleteMany({});
+ await AclEntry.deleteMany({});
+ });
+
+ it('should create a prompt and grant owner permissions', async () => {
+ const promptData = {
+ prompt: {
+ prompt: 'Test prompt content',
+ type: 'text',
+ },
+ group: {
+ name: 'Test Prompt Group',
+ },
+ };
+
+ const response = await request(app).post('/api/prompts').send(promptData);
+
+ expect(response.status).toBe(200);
+ expect(response.body.prompt).toBeDefined();
+ expect(response.body.prompt.prompt).toBe(promptData.prompt.prompt);
+
+ // Check ACL entry was created
+ const aclEntry = await AclEntry.findOne({
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: response.body.prompt.groupId,
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ });
+
+ expect(aclEntry).toBeTruthy();
+ expect(aclEntry.roleId.toString()).toBe(testRoles.owner._id.toString());
+ });
+
+ it('should create a prompt group with prompt and grant owner permissions', async () => {
+ const promptData = {
+ prompt: {
+ prompt: 'Group prompt content',
+ // Remove 'name' from prompt - it's not in the schema
+ },
+ group: {
+ name: 'Test Group',
+ category: 'testing',
+ },
+ };
+
+ const response = await request(app).post('/api/prompts').send(promptData).expect(200);
+
+ expect(response.body.prompt).toBeDefined();
+ expect(response.body.group).toBeDefined();
+ expect(response.body.group.name).toBe(promptData.group.name);
+
+ // Check ACL entry was created for the promptGroup
+ const aclEntry = await AclEntry.findOne({
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: response.body.group._id,
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ });
+
+ expect(aclEntry).toBeTruthy();
+ });
+ });
+
+ describe('GET /api/prompts/:promptId - Get Prompt', () => {
+ let testPrompt;
+ let testGroup;
+
+ beforeEach(async () => {
+ // Create a prompt group first
+ testGroup = await PromptGroup.create({
+ name: 'Test Group',
+ category: 'testing',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ });
+
+ // Create a prompt
+ testPrompt = await Prompt.create({
+ prompt: 'Test prompt for retrieval',
+ name: 'Get Test',
+ author: testUsers.owner._id,
+ type: 'text',
+ groupId: testGroup._id,
+ });
+ });
+
+ afterEach(async () => {
+ await Prompt.deleteMany({});
+ await PromptGroup.deleteMany({});
+ await AclEntry.deleteMany({});
+ });
+
+ it('should retrieve prompt when user has view permissions', async () => {
+ // Grant view permissions on the promptGroup
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
+ grantedBy: testUsers.owner._id,
+ });
+
+ const response = await request(app).get(`/api/prompts/${testPrompt._id}`);
+ expect(response.status).toBe(200);
+ expect(response.body._id).toBe(testPrompt._id.toString());
+ expect(response.body.prompt).toBe(testPrompt.prompt);
+ });
+
+ it('should deny access when user has no permissions', async () => {
+ // Change the user to one without access
+ setTestUser(app, testUsers.noAccess);
+
+ const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(403);
+
+ // Verify error response
+ expect(response.body.error).toBe('Forbidden');
+ expect(response.body.message).toBe('Insufficient permissions to access this promptGroup');
+ });
+
+ it('should allow admin access without explicit permissions', async () => {
+ // Set admin user
+ setTestUser(app, testUsers.admin);
+
+ const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(200);
+
+ expect(response.body._id).toBe(testPrompt._id.toString());
+ });
+ });
+
+ describe('DELETE /api/prompts/:promptId - Delete Prompt', () => {
+ let testPrompt;
+ let testGroup;
+
+ beforeEach(async () => {
+ // Create group with prompt
+ testGroup = await PromptGroup.create({
+ name: 'Delete Test Group',
+ category: 'testing',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ });
+
+ testPrompt = await Prompt.create({
+ prompt: 'Test prompt for deletion',
+ name: 'Delete Test',
+ author: testUsers.owner._id,
+ type: 'text',
+ groupId: testGroup._id,
+ });
+
+ // Add prompt to group
+ testGroup.productionId = testPrompt._id;
+ testGroup.promptIds = [testPrompt._id];
+ await testGroup.save();
+
+ // Grant owner permissions on the promptGroup
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ });
+
+ afterEach(async () => {
+ await Prompt.deleteMany({});
+ await PromptGroup.deleteMany({});
+ await AclEntry.deleteMany({});
+ });
+
+ it('should delete prompt when user has delete permissions', async () => {
+ const response = await request(app)
+ .delete(`/api/prompts/${testPrompt._id}`)
+ .query({ groupId: testGroup._id.toString() })
+ .expect(200);
+
+ expect(response.body.prompt).toBe('Prompt deleted successfully');
+
+ // Verify prompt was deleted
+ const deletedPrompt = await Prompt.findById(testPrompt._id);
+ expect(deletedPrompt).toBeNull();
+
+ // Verify ACL entries were removed
+ const aclEntries = await AclEntry.find({
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testGroup._id,
+ });
+ expect(aclEntries).toHaveLength(0);
+ });
+
+ it('should deny deletion when user lacks delete permissions', async () => {
+ // Create a prompt as a different user (not the one trying to delete)
+ const authorPrompt = await Prompt.create({
+ prompt: 'Test prompt by another user',
+ name: 'Another User Prompt',
+ author: testUsers.editor._id, // Different author
+ type: 'text',
+ groupId: testGroup._id,
+ });
+
+ // Grant only viewer permissions to viewer user on the promptGroup
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.viewer._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
+ grantedBy: testUsers.editor._id,
+ });
+
+ // Set viewer user
+ setTestUser(app, testUsers.viewer);
+
+ await request(app)
+ .delete(`/api/prompts/${authorPrompt._id}`)
+ .query({ groupId: testGroup._id.toString() })
+ .expect(403);
+
+ // Verify prompt still exists
+ const prompt = await Prompt.findById(authorPrompt._id);
+ expect(prompt).toBeTruthy();
+ });
+ });
+
+ describe('PATCH /api/prompts/:promptId/tags/production - Make Production', () => {
+ let testPrompt;
+ let testGroup;
+
+ beforeEach(async () => {
+ // Create group
+ testGroup = await PromptGroup.create({
+ name: 'Production Test Group',
+ category: 'testing',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ });
+
+ testPrompt = await Prompt.create({
+ prompt: 'Test prompt for production',
+ name: 'Production Test',
+ author: testUsers.owner._id,
+ type: 'text',
+ groupId: testGroup._id,
+ });
+ });
+
+ afterEach(async () => {
+ await Prompt.deleteMany({});
+ await PromptGroup.deleteMany({});
+ await AclEntry.deleteMany({});
+ });
+
+ it('should make prompt production when user has edit permissions', async () => {
+ // Grant edit permissions on the promptGroup
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_EDITOR,
+ grantedBy: testUsers.owner._id,
+ });
+
+ // Ensure owner user
+ setTestUser(app, testUsers.owner);
+
+ const response = await request(app)
+ .patch(`/api/prompts/${testPrompt._id}/tags/production`)
+ .expect(200);
+
+ expect(response.body.message).toBe('Prompt production made successfully');
+
+ // Verify the group was updated
+ const updatedGroup = await PromptGroup.findById(testGroup._id);
+ expect(updatedGroup.productionId.toString()).toBe(testPrompt._id.toString());
+ });
+
+ it('should deny making production when user lacks edit permissions', async () => {
+ // Grant only view permissions to viewer on the promptGroup
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.viewer._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
+ grantedBy: testUsers.owner._id,
+ });
+
+ // Set viewer user
+ setTestUser(app, testUsers.viewer);
+
+ await request(app).patch(`/api/prompts/${testPrompt._id}/tags/production`).expect(403);
+
+ // Verify prompt hasn't changed
+ const unchangedGroup = await PromptGroup.findById(testGroup._id);
+ expect(unchangedGroup.productionId.toString()).not.toBe(testPrompt._id.toString());
+ });
+ });
+
+ describe('Public Access', () => {
+ let publicPrompt;
+ let publicGroup;
+
+ beforeEach(async () => {
+ // Create a prompt group
+ publicGroup = await PromptGroup.create({
+ name: 'Public Test Group',
+ category: 'testing',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ });
+
+ // Create a public prompt
+ publicPrompt = await Prompt.create({
+ prompt: 'Public prompt content',
+ name: 'Public Test',
+ author: testUsers.owner._id,
+ type: 'text',
+ groupId: publicGroup._id,
+ });
+
+ // Grant public viewer access on the promptGroup
+ await grantPermission({
+ principalType: PrincipalType.PUBLIC,
+ principalId: null,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: publicGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
+ grantedBy: testUsers.owner._id,
+ });
+ });
+
+ afterEach(async () => {
+ await Prompt.deleteMany({});
+ await PromptGroup.deleteMany({});
+ await AclEntry.deleteMany({});
+ });
+
+ it('should allow any user to view public prompts', async () => {
+ // Change user to someone without explicit permissions
+ setTestUser(app, testUsers.noAccess);
+
+ const response = await request(app).get(`/api/prompts/${publicPrompt._id}`).expect(200);
+
+ expect(response.body._id).toBe(publicPrompt._id.toString());
+ });
+ });
+
+ describe('PATCH /api/prompts/groups/:groupId - Update Prompt Group Security', () => {
+ let testGroup;
+
+ beforeEach(async () => {
+ // Create a prompt group
+ testGroup = await PromptGroup.create({
+ name: 'Security Test Group',
+ category: 'security-test',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ });
+
+ // Grant owner permissions
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ });
+
+ afterEach(async () => {
+ await PromptGroup.deleteMany({});
+ await AclEntry.deleteMany({});
+ });
+
+ it('should allow updating allowed fields (name, category, oneliner)', async () => {
+ const updateData = {
+ name: 'Updated Group Name',
+ category: 'updated-category',
+ oneliner: 'Updated description',
+ };
+
+ const response = await request(app)
+ .patch(`/api/prompts/groups/${testGroup._id}`)
+ .send(updateData)
+ .expect(200);
+
+ expect(response.body.name).toBe(updateData.name);
+ expect(response.body.category).toBe(updateData.category);
+ expect(response.body.oneliner).toBe(updateData.oneliner);
+ });
+
+ it('should reject request with author field (400 Bad Request)', async () => {
+ const maliciousUpdate = {
+ name: 'Legit Update',
+ author: testUsers.noAccess._id.toString(), // Try to change ownership
+ };
+
+ const response = await request(app)
+ .patch(`/api/prompts/groups/${testGroup._id}`)
+ .send(maliciousUpdate)
+ .expect(400);
+
+ // Verify the request was rejected
+ expect(response.body.error).toBe('Invalid request body');
+ expect(response.body.details).toBeDefined();
+ });
+
+ it('should reject request with authorName field (400 Bad Request)', async () => {
+ const maliciousUpdate = {
+ name: 'Legit Update',
+ authorName: 'Malicious Author Name',
+ };
+
+ const response = await request(app)
+ .patch(`/api/prompts/groups/${testGroup._id}`)
+ .send(maliciousUpdate)
+ .expect(400);
+
+ // Verify the request was rejected
+ expect(response.body.error).toBe('Invalid request body');
+ });
+
+ it('should reject request with _id field (400 Bad Request)', async () => {
+ const newId = new ObjectId();
+ const maliciousUpdate = {
+ name: 'Legit Update',
+ _id: newId.toString(),
+ };
+
+ const response = await request(app)
+ .patch(`/api/prompts/groups/${testGroup._id}`)
+ .send(maliciousUpdate)
+ .expect(400);
+
+ // Verify the request was rejected
+ expect(response.body.error).toBe('Invalid request body');
+ });
+
+ it('should reject request with productionId field (400 Bad Request)', async () => {
+ const newProductionId = new ObjectId();
+ const maliciousUpdate = {
+ name: 'Legit Update',
+ productionId: newProductionId.toString(),
+ };
+
+ const response = await request(app)
+ .patch(`/api/prompts/groups/${testGroup._id}`)
+ .send(maliciousUpdate)
+ .expect(400);
+
+ // Verify the request was rejected
+ expect(response.body.error).toBe('Invalid request body');
+ });
+
+ it('should reject request with createdAt field (400 Bad Request)', async () => {
+ const maliciousDate = new Date('2020-01-01');
+ const maliciousUpdate = {
+ name: 'Legit Update',
+ createdAt: maliciousDate.toISOString(),
+ };
+
+ const response = await request(app)
+ .patch(`/api/prompts/groups/${testGroup._id}`)
+ .send(maliciousUpdate)
+ .expect(400);
+
+ // Verify the request was rejected
+ expect(response.body.error).toBe('Invalid request body');
+ });
+
+ it('should reject request with __v field (400 Bad Request)', async () => {
+ const maliciousUpdate = {
+ name: 'Legit Update',
+ __v: 999,
+ };
+
+ const response = await request(app)
+ .patch(`/api/prompts/groups/${testGroup._id}`)
+ .send(maliciousUpdate)
+ .expect(400);
+
+ // Verify the request was rejected
+ expect(response.body.error).toBe('Invalid request body');
+ });
+
+ it('should reject request with multiple sensitive fields (400 Bad Request)', async () => {
+ const maliciousUpdate = {
+ name: 'Legit Update',
+ author: testUsers.noAccess._id.toString(),
+ authorName: 'Hacker',
+ _id: new ObjectId().toString(),
+ productionId: new ObjectId().toString(),
+ createdAt: new Date('2020-01-01').toISOString(),
+ __v: 999,
+ };
+
+ const response = await request(app)
+ .patch(`/api/prompts/groups/${testGroup._id}`)
+ .send(maliciousUpdate)
+ .expect(400);
+
+ // Verify the request was rejected with validation errors
+ expect(response.body.error).toBe('Invalid request body');
+ expect(response.body.details).toBeDefined();
+ expect(Array.isArray(response.body.details)).toBe(true);
+ });
+ });
+
+ describe('Pagination', () => {
+ beforeEach(async () => {
+ // Create multiple prompt groups for pagination testing
+ const groups = [];
+ for (let i = 0; i < 15; i++) {
+ const group = await PromptGroup.create({
+ name: `Test Group ${i + 1}`,
+ category: 'pagination-test',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ updatedAt: new Date(Date.now() - i * 1000), // Stagger updatedAt for consistent ordering
+ });
+ groups.push(group);
+
+ // Grant owner permissions on each group
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: group._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ }
+ });
+
+ afterEach(async () => {
+ await PromptGroup.deleteMany({});
+ await AclEntry.deleteMany({});
+ });
+
+ it('should correctly indicate hasMore when there are more pages', async () => {
+ const response = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '10' })
+ .expect(200);
+
+ expect(response.body.promptGroups).toHaveLength(10);
+ expect(response.body.has_more).toBe(true);
+ expect(response.body.after).toBeTruthy();
+ // Since has_more is true, pages should be a high number (9999 in our fix)
+ expect(parseInt(response.body.pages)).toBeGreaterThan(1);
+ });
+
+ it('should correctly indicate no more pages on the last page', async () => {
+ // First get the cursor for page 2
+ const firstPage = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '10' })
+ .expect(200);
+
+ expect(firstPage.body.has_more).toBe(true);
+ expect(firstPage.body.after).toBeTruthy();
+
+ // Now fetch the second page using the cursor
+ const response = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '10', cursor: firstPage.body.after })
+ .expect(200);
+
+ expect(response.body.promptGroups).toHaveLength(5); // 15 total, 10 on page 1, 5 on page 2
+ expect(response.body.has_more).toBe(false);
+ });
+
+ it('should support cursor-based pagination', async () => {
+ // First page
+ const firstPage = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '5' })
+ .expect(200);
+
+ expect(firstPage.body.promptGroups).toHaveLength(5);
+ expect(firstPage.body.has_more).toBe(true);
+ expect(firstPage.body.after).toBeTruthy();
+
+ // Second page using cursor
+ const secondPage = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '5', cursor: firstPage.body.after })
+ .expect(200);
+
+ expect(secondPage.body.promptGroups).toHaveLength(5);
+ expect(secondPage.body.has_more).toBe(true);
+ expect(secondPage.body.after).toBeTruthy();
+
+ // Verify different groups
+ const firstPageIds = firstPage.body.promptGroups.map((g) => g._id);
+ const secondPageIds = secondPage.body.promptGroups.map((g) => g._id);
+ expect(firstPageIds).not.toEqual(secondPageIds);
+ });
+
+ it('should paginate correctly with category filtering', async () => {
+ // Create groups with different categories
+ await PromptGroup.deleteMany({}); // Clear existing groups
+ await AclEntry.deleteMany({});
+
+ // Create 8 groups with category 'test-cat-1'
+ for (let i = 0; i < 8; i++) {
+ const group = await PromptGroup.create({
+ name: `Category 1 Group ${i + 1}`,
+ category: 'test-cat-1',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ updatedAt: new Date(Date.now() - i * 1000),
+ });
+
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: group._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ }
+
+ // Create 7 groups with category 'test-cat-2'
+ for (let i = 0; i < 7; i++) {
+ const group = await PromptGroup.create({
+ name: `Category 2 Group ${i + 1}`,
+ category: 'test-cat-2',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ updatedAt: new Date(Date.now() - (i + 8) * 1000),
+ });
+
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: group._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ }
+
+ // Test pagination with category filter
+ const firstPage = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '5', category: 'test-cat-1' })
+ .expect(200);
+
+ expect(firstPage.body.promptGroups).toHaveLength(5);
+ expect(firstPage.body.promptGroups.every((g) => g.category === 'test-cat-1')).toBe(true);
+ expect(firstPage.body.has_more).toBe(true);
+ expect(firstPage.body.after).toBeTruthy();
+
+ const secondPage = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '5', cursor: firstPage.body.after, category: 'test-cat-1' })
+ .expect(200);
+
+ expect(secondPage.body.promptGroups).toHaveLength(3); // 8 total, 5 on page 1, 3 on page 2
+ expect(secondPage.body.promptGroups.every((g) => g.category === 'test-cat-1')).toBe(true);
+ expect(secondPage.body.has_more).toBe(false);
+ });
+
+ it('should paginate correctly with name/keyword filtering', async () => {
+ // Create groups with specific names
+ await PromptGroup.deleteMany({}); // Clear existing groups
+ await AclEntry.deleteMany({});
+
+ // Create 12 groups with 'Search' in the name
+ for (let i = 0; i < 12; i++) {
+ const group = await PromptGroup.create({
+ name: `Search Test Group ${i + 1}`,
+ category: 'search-test',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ updatedAt: new Date(Date.now() - i * 1000),
+ });
+
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: group._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ }
+
+ // Create 5 groups without 'Search' in the name
+ for (let i = 0; i < 5; i++) {
+ const group = await PromptGroup.create({
+ name: `Other Group ${i + 1}`,
+ category: 'other-test',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ updatedAt: new Date(Date.now() - (i + 12) * 1000),
+ });
+
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: group._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ }
+
+ // Test pagination with name filter
+ const firstPage = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '10', name: 'Search' })
+ .expect(200);
+
+ expect(firstPage.body.promptGroups).toHaveLength(10);
+ expect(firstPage.body.promptGroups.every((g) => g.name.includes('Search'))).toBe(true);
+ expect(firstPage.body.has_more).toBe(true);
+ expect(firstPage.body.after).toBeTruthy();
+
+ const secondPage = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '10', cursor: firstPage.body.after, name: 'Search' })
+ .expect(200);
+
+ expect(secondPage.body.promptGroups).toHaveLength(2); // 12 total, 10 on page 1, 2 on page 2
+ expect(secondPage.body.promptGroups.every((g) => g.name.includes('Search'))).toBe(true);
+ expect(secondPage.body.has_more).toBe(false);
+ });
+
+ it('should paginate correctly with combined filters', async () => {
+ // Create groups with various combinations
+ await PromptGroup.deleteMany({}); // Clear existing groups
+ await AclEntry.deleteMany({});
+
+ // Create 6 groups matching both category and name filters
+ for (let i = 0; i < 6; i++) {
+ const group = await PromptGroup.create({
+ name: `API Test Group ${i + 1}`,
+ category: 'api-category',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ updatedAt: new Date(Date.now() - i * 1000),
+ });
+
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: group._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ }
+
+ // Create groups that only match one filter
+ for (let i = 0; i < 4; i++) {
+ const group = await PromptGroup.create({
+ name: `API Other Group ${i + 1}`,
+ category: 'other-category',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ updatedAt: new Date(Date.now() - (i + 6) * 1000),
+ });
+
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: group._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ }
+
+ // Test pagination with both filters
+ const response = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '5', name: 'API', category: 'api-category' })
+ .expect(200);
+
+ expect(response.body.promptGroups).toHaveLength(5);
+ expect(
+ response.body.promptGroups.every(
+ (g) => g.name.includes('API') && g.category === 'api-category',
+ ),
+ ).toBe(true);
+ expect(response.body.has_more).toBe(true);
+ expect(response.body.after).toBeTruthy();
+
+ // Page 2
+ const page2 = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '5', cursor: response.body.after, name: 'API', category: 'api-category' })
+ .expect(200);
+
+ expect(page2.body.promptGroups).toHaveLength(1); // 6 total, 5 on page 1, 1 on page 2
+ expect(page2.body.has_more).toBe(false);
+ });
+ });
+});
diff --git a/api/server/routes/roles.js b/api/server/routes/roles.js
new file mode 100644
index 0000000..ec4c4fe
--- /dev/null
+++ b/api/server/routes/roles.js
@@ -0,0 +1,204 @@
+const express = require('express');
+const { logger, SystemCapabilities } = require('@librechat/data-schemas');
+const {
+ SystemRoles,
+ roleDefaults,
+ PermissionTypes,
+ agentPermissionsSchema,
+ promptPermissionsSchema,
+ memoryPermissionsSchema,
+ mcpServersPermissionsSchema,
+ marketplacePermissionsSchema,
+ peoplePickerPermissionsSchema,
+ remoteAgentsPermissionsSchema,
+ skillPermissionsSchema,
+} = require('librechat-data-provider');
+const { hasCapability, requireCapability } = require('~/server/middleware/roles/capabilities');
+const { updateRoleByName, getRoleByName } = require('~/models');
+const { requireJwtAuth } = require('~/server/middleware');
+
+const router = express.Router();
+router.use(requireJwtAuth);
+const manageRoles = requireCapability(SystemCapabilities.MANAGE_ROLES);
+
+/**
+ * Permission configuration mapping
+ * Maps route paths to their corresponding schemas and permission types
+ */
+const permissionConfigs = {
+ prompts: {
+ schema: promptPermissionsSchema,
+ permissionType: PermissionTypes.PROMPTS,
+ errorMessage: 'Invalid prompt permissions.',
+ },
+ agents: {
+ schema: agentPermissionsSchema,
+ permissionType: PermissionTypes.AGENTS,
+ errorMessage: 'Invalid agent permissions.',
+ },
+ memories: {
+ schema: memoryPermissionsSchema,
+ permissionType: PermissionTypes.MEMORIES,
+ errorMessage: 'Invalid memory permissions.',
+ },
+ 'people-picker': {
+ schema: peoplePickerPermissionsSchema,
+ permissionType: PermissionTypes.PEOPLE_PICKER,
+ errorMessage: 'Invalid people picker permissions.',
+ },
+ 'mcp-servers': {
+ schema: mcpServersPermissionsSchema,
+ permissionType: PermissionTypes.MCP_SERVERS,
+ errorMessage: 'Invalid MCP servers permissions.',
+ },
+ marketplace: {
+ schema: marketplacePermissionsSchema,
+ permissionType: PermissionTypes.MARKETPLACE,
+ errorMessage: 'Invalid marketplace permissions.',
+ },
+ 'remote-agents': {
+ schema: remoteAgentsPermissionsSchema,
+ permissionType: PermissionTypes.REMOTE_AGENTS,
+ errorMessage: 'Invalid remote agents permissions.',
+ },
+ skills: {
+ schema: skillPermissionsSchema,
+ permissionType: PermissionTypes.SKILLS,
+ errorMessage: 'Invalid skill permissions.',
+ },
+};
+
+/**
+ * Generic handler for updating permissions
+ * @param {string} permissionKey - The key from permissionConfigs
+ * @returns {Function} Express route handler
+ */
+const createPermissionUpdateHandler = (permissionKey) => {
+ const config = permissionConfigs[permissionKey];
+
+ return async (req, res) => {
+ const { roleName } = req.params;
+ const updates = req.body;
+
+ try {
+ const parsedUpdates = config.schema.partial().parse(updates);
+
+ const role = await getRoleByName(roleName);
+ if (!role) {
+ return res.status(404).send({ message: 'Role not found' });
+ }
+
+ const currentPermissions =
+ role.permissions?.[config.permissionType] || role[config.permissionType] || {};
+
+ const mergedUpdates = {
+ permissions: {
+ ...role.permissions,
+ [config.permissionType]: {
+ ...currentPermissions,
+ ...parsedUpdates,
+ },
+ },
+ };
+
+ const updatedRole = await updateRoleByName(roleName, mergedUpdates);
+ res.status(200).send(updatedRole);
+ } catch (error) {
+ return res.status(400).send({ message: config.errorMessage, error: error.errors });
+ }
+ };
+};
+
+/**
+ * GET /api/roles/:roleName
+ * Get a specific role by name
+ */
+router.get('/:roleName', async (req, res) => {
+ const { roleName } = req.params;
+
+ try {
+ const isOwnRole = req.user?.role === roleName;
+ const isDefaultRole = Object.hasOwn(roleDefaults, roleName);
+ /** READ_ROLES only gates reading other roles; own role and non-admin default roles skip the probe */
+ const requiresReadRoles = !isOwnRole && (roleName === SystemRoles.ADMIN || !isDefaultRole);
+ if (requiresReadRoles) {
+ let hasReadRoles = false;
+ try {
+ hasReadRoles = await hasCapability(
+ {
+ id: req.user?.id ?? req.user?._id?.toString() ?? '',
+ role: req.user?.role ?? '',
+ tenantId: req.user?.tenantId,
+ idOnTheSource: req.user?.idOnTheSource ?? null,
+ },
+ SystemCapabilities.READ_ROLES,
+ );
+ } catch (err) {
+ logger.warn(`[GET /roles/:roleName] capability check failed: ${err.message}`);
+ }
+ if (!hasReadRoles) {
+ return res.status(403).send({ message: 'Unauthorized' });
+ }
+ }
+
+ const role = await getRoleByName(roleName, '-_id -__v');
+ if (!role) {
+ return res.status(404).send({ message: 'Role not found' });
+ }
+
+ res.status(200).send(role);
+ } catch (error) {
+ logger.error('[GET /roles/:roleName] Error:', error);
+ return res.status(500).send({ message: 'Failed to retrieve role' });
+ }
+});
+
+/**
+ * PUT /api/roles/:roleName/prompts
+ * Update prompt permissions for a specific role
+ */
+router.put('/:roleName/prompts', manageRoles, createPermissionUpdateHandler('prompts'));
+
+/**
+ * PUT /api/roles/:roleName/agents
+ * Update agent permissions for a specific role
+ */
+router.put('/:roleName/agents', manageRoles, createPermissionUpdateHandler('agents'));
+
+/**
+ * PUT /api/roles/:roleName/memories
+ * Update memory permissions for a specific role
+ */
+router.put('/:roleName/memories', manageRoles, createPermissionUpdateHandler('memories'));
+
+/**
+ * PUT /api/roles/:roleName/people-picker
+ * Update people picker permissions for a specific role
+ */
+router.put('/:roleName/people-picker', manageRoles, createPermissionUpdateHandler('people-picker'));
+
+/**
+ * PUT /api/roles/:roleName/mcp-servers
+ * Update MCP servers permissions for a specific role
+ */
+router.put('/:roleName/mcp-servers', manageRoles, createPermissionUpdateHandler('mcp-servers'));
+
+/**
+ * PUT /api/roles/:roleName/marketplace
+ * Update marketplace permissions for a specific role
+ */
+router.put('/:roleName/marketplace', manageRoles, createPermissionUpdateHandler('marketplace'));
+
+/**
+ * PUT /api/roles/:roleName/remote-agents
+ * Update remote agents (API) permissions for a specific role
+ */
+router.put('/:roleName/remote-agents', manageRoles, createPermissionUpdateHandler('remote-agents'));
+
+/**
+ * PUT /api/roles/:roleName/skills
+ * Update skill permissions for a specific role
+ */
+router.put('/:roleName/skills', manageRoles, createPermissionUpdateHandler('skills'));
+
+module.exports = router;
diff --git a/api/server/routes/rum.js b/api/server/routes/rum.js
new file mode 100644
index 0000000..cc5c2f2
--- /dev/null
+++ b/api/server/routes/rum.js
@@ -0,0 +1,28 @@
+const express = require('express');
+const { getRumProxyBodyLimit, isRumProxyEnabled, proxyRumRequest } = require('@librechat/api');
+const { requireRumProxyAuth } = require('~/server/middleware');
+
+const router = express.Router();
+const rawOtlpBody = express.raw({
+ limit: getRumProxyBodyLimit(),
+ type: ['application/x-protobuf', 'application/octet-stream'],
+});
+
+function requireRumProxyEnabled(_req, res, next) {
+ if (!isRumProxyEnabled()) {
+ return res.status(404).json({ message: 'RUM proxy is not configured' });
+ }
+
+ return next();
+}
+
+router.post(
+ '/v1/traces',
+ requireRumProxyEnabled,
+ requireRumProxyAuth,
+ rawOtlpBody,
+ proxyRumRequest,
+);
+router.post('/v1/logs', requireRumProxyEnabled, requireRumProxyAuth, rawOtlpBody, proxyRumRequest);
+
+module.exports = router;
diff --git a/api/server/routes/search.js b/api/server/routes/search.js
new file mode 100644
index 0000000..2cd2fc3
--- /dev/null
+++ b/api/server/routes/search.js
@@ -0,0 +1,28 @@
+const express = require('express');
+const { MeiliSearch } = require('meilisearch');
+const { isEnabled } = require('@librechat/api');
+const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
+
+const router = express.Router();
+
+router.use(requireJwtAuth);
+
+router.get('/enable', async function (req, res) {
+ if (!isEnabled(process.env.SEARCH)) {
+ return res.send(false);
+ }
+
+ try {
+ const client = new MeiliSearch({
+ host: process.env.MEILI_HOST,
+ apiKey: process.env.MEILI_MASTER_KEY,
+ });
+
+ const { status } = await client.health();
+ return res.send(status === 'available');
+ } catch (error) {
+ return res.send(false);
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/settings.js b/api/server/routes/settings.js
new file mode 100644
index 0000000..38b353d
--- /dev/null
+++ b/api/server/routes/settings.js
@@ -0,0 +1,34 @@
+const express = require('express');
+const { createToolFavoritesHandlers } = require('@librechat/api');
+const {
+ updateFavoritesController,
+ getFavoritesController,
+} = require('~/server/controllers/FavoritesController');
+const {
+ getSkillStatesController,
+ updateSkillStatesController,
+} = require('~/server/controllers/SkillStatesController');
+const { requireJwtAuth } = require('~/server/middleware');
+const { getToolFavorites, addToolFavorite, removeToolFavorite } = require('~/models');
+
+const router = express.Router();
+
+const toolFavorites = createToolFavoritesHandlers({
+ getToolFavorites,
+ addToolFavorite,
+ removeToolFavorite,
+});
+
+router.get('/favorites/tools', requireJwtAuth, toolFavorites.listToolFavorites);
+router.put('/favorites/tools/:itemType/:itemId', requireJwtAuth, toolFavorites.addToolFavorite);
+router.delete(
+ '/favorites/tools/:itemType/:itemId',
+ requireJwtAuth,
+ toolFavorites.removeToolFavorite,
+);
+router.get('/favorites', requireJwtAuth, getFavoritesController);
+router.post('/favorites', requireJwtAuth, updateFavoritesController);
+router.get('/skills/active', requireJwtAuth, getSkillStatesController);
+router.post('/skills/active', requireJwtAuth, updateSkillStatesController);
+
+module.exports = router;
diff --git a/api/server/routes/share.js b/api/server/routes/share.js
new file mode 100644
index 0000000..05af670
--- /dev/null
+++ b/api/server/routes/share.js
@@ -0,0 +1,545 @@
+const mongoose = require('mongoose');
+const express = require('express');
+const {
+ isEnabled,
+ generateCheckAccess,
+ grantCreationPermissions,
+ ensureLinkPermissions,
+ isFileSnapshotEnabled,
+ isFileSnapshotKillSwitchActive,
+ buildSharedLinkStartupPayload,
+ deleteSharedLinkWithCleanup,
+ updateSharedLinkPermissionsExpiration,
+ isActiveExpirationDate,
+ getSharedLinkExpiration,
+} = require('@librechat/api');
+const {
+ logger,
+ getTenantId,
+ runAsSystem,
+ tenantStorage,
+ SYSTEM_TENANT_ID,
+ createTempChatExpirationDate,
+} = require('@librechat/data-schemas');
+const { FileSources, PermissionTypes, Permissions } = require('librechat-data-provider');
+const {
+ getFiles,
+ updateFile,
+ getSharedMessages,
+ createSharedLink,
+ updateSharedLink,
+ getSharedLinks,
+ getSharedLink,
+ getSharedLinkFile,
+ backfillSharedLinkFiles,
+ getRoleByName,
+} = require('~/models');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { cleanFileName, getContentDisposition } = require('~/server/utils/files');
+const canAccessSharedLink = require('~/server/middleware/canAccessSharedLink');
+const { forkSharedConversation } = require('~/server/utils/import/fork');
+const { createForkLimiters } = require('~/server/middleware/limiters');
+const optionalShareFileAuth = require('~/server/middleware/optionalShareFileAuth');
+const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth');
+const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
+const configMiddleware = require('~/server/middleware/config/app');
+const { getAppConfig } = require('~/server/services/Config/app');
+const router = express.Router();
+
+const checkSharedLinksAccess = generateCheckAccess({
+ permissionType: PermissionTypes.SHARED_LINKS,
+ permissions: [Permissions.CREATE],
+ getRoleByName,
+});
+
+const resolveSharedLinkExpiration = (req, conversationId) =>
+ getSharedLinkExpiration(
+ { req, conversationId },
+ {
+ getConvo: async (userId, sourceConversationId) => {
+ const Conversation = mongoose.models.Conversation;
+ return Conversation.findOne(
+ { conversationId: sourceConversationId, user: userId },
+ 'isTemporary expiredAt',
+ ).lean();
+ },
+ createExpirationDate: createTempChatExpirationDate,
+ logger,
+ },
+ );
+
+/**
+ * Shared messages
+ */
+const allowSharedLinks =
+ process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
+
+/** Run within the snapshot file's tenant context (mirrors canAccessSharedLink). */
+const runWithTenant = (tenantId, fn) =>
+ tenantId ? tenantStorage.run({ tenantId }, fn) : runAsSystem(fn);
+
+/** Mirrors the owner preview route: pending records older than this are swept to
+ * 'failed' on the next poll so the client poller terminates. */
+const PREVIEW_LAZY_SWEEP_CUTOFF_MS = 2 * 60 * 1000;
+
+const getShareStartupPayload = async () => {
+ const tenantId = getTenantId();
+ const appConfig = await getAppConfig(
+ tenantId && tenantId !== SYSTEM_TENANT_ID ? { tenantId } : { baseOnly: true },
+ );
+ return buildSharedLinkStartupPayload(appConfig);
+};
+
+/**
+ * MIME types that are safe to render inline. Everything else (text/html, SVG,
+ * and other active content) is served as an `attachment` so a public viewer
+ * can't execute uploaded bytes under the app origin by opening the URL directly.
+ */
+const SAFE_INLINE_TYPES = new Set([
+ 'image/png',
+ 'image/jpeg',
+ 'image/jpg',
+ 'image/gif',
+ 'image/webp',
+ 'image/bmp',
+ 'image/avif',
+ 'image/x-icon',
+ 'application/pdf',
+]);
+
+/**
+ * Resolve a snapshotted file for a shared link. A file_id absent from the
+ * share's snapshot is denied (404) — this prevents a viewer from reaching files
+ * outside the shared-link snapshot. Only legacy shares (no `fileSnapshots` field
+ * at all) trigger a lazy backfill; an ordinary miss does not rebuild. The live
+ * file record is also required: if the original was deleted/expired, return a
+ * clean 404 instead of letting the stream error after headers are sent.
+ */
+const resolveShareFile = async (req, res, next) => {
+ try {
+ // Global kill switch only (env-based, viewer-independent): disabling stops
+ // serving for every link. The viewer's own config must NOT affect serving.
+ if (isFileSnapshotKillSwitchActive()) {
+ return res.status(404).json({ message: 'Shared file access is disabled' });
+ }
+
+ const { shareId, file_id } = req.params;
+ const { file, hasSnapshots, optedOut } = await getSharedLinkFile(shareId, file_id);
+ // Per-link opt-out: never serve and never backfill an opted-out link.
+ if (optedOut) {
+ return res.status(404).json({ message: 'File not found in shared link' });
+ }
+ let snapshot = file;
+ if (!snapshot && !hasSnapshots) {
+ snapshot = await backfillSharedLinkFiles(shareId, file_id);
+ }
+ if (!snapshot) {
+ logger.warn(
+ `[shareFileAccess] File ${file_id} not in snapshot for share ${shareId} (route ${req.originalUrl})`,
+ );
+ return res.status(404).json({ message: 'File not found in shared link' });
+ }
+
+ const [liveFile] = await getFiles({ file_id }, null, {});
+ if (!liveFile) {
+ logger.warn(
+ `[shareFileAccess] Snapshotted file ${file_id} no longer available for share ${shareId}`,
+ );
+ return res.status(404).json({ message: 'File no longer available' });
+ }
+
+ // Pin to the snapshotted version so an old link can't surface post-share content
+ // after a reused file_id (e.g. code-exec same-filename outputs) is overwritten.
+ // previewRevision changes for deferred/office files; `bytes` catches other
+ // overwrites that change size, and is stable across S3 URL refresh and the
+ // pending->ready transition (which don't alter file size). Same-size content
+ // swaps remain a best-effort gap inherent to the no-byte-copy design.
+ const revisionChanged =
+ (snapshot.previewRevision ?? null) !== (liveFile.previewRevision ?? null);
+ const bytesChanged =
+ snapshot.bytes != null && liveFile.bytes != null && snapshot.bytes !== liveFile.bytes;
+ if (revisionChanged || bytesChanged) {
+ logger.warn(
+ `[shareFileAccess] Snapshot version mismatch for file ${file_id} (share ${shareId})`,
+ );
+ return res.status(404).json({ message: 'File no longer available' });
+ }
+
+ req.shareFile = snapshot;
+ req.liveFile = liveFile;
+ return next();
+ } catch (error) {
+ logger.error('[shareFileAccess] Error resolving shared file:', error);
+ return res.status(500).json({ message: 'Error resolving shared file' });
+ }
+};
+
+/** Stream (or redirect to) a snapshotted file from its original stored object. */
+const streamSharedFile = async (req, res, file, requestedDisposition) => {
+ const source = file.source || FileSources.local;
+ const { getDownloadStream, getDownloadURL } = getStrategyFunctions(source);
+
+ // Inline only safe preview types; anything else is forced to attachment.
+ const disposition =
+ requestedDisposition === 'inline' && SAFE_INLINE_TYPES.has(file.type) ? 'inline' : 'attachment';
+
+ // Redirect to a signed storage URL only when explicitly requested (?direct=true);
+ // by default stream through the server so blob (XHR) callers work without bucket CORS.
+ const isDirectSource = source === FileSources.s3 || source === FileSources.cloudfront;
+ if (req.query.direct === 'true' && getDownloadURL && isDirectSource) {
+ try {
+ const url = await getDownloadURL({
+ req,
+ file,
+ customFilename: cleanFileName(file.filename),
+ contentType: file.type || 'application/octet-stream',
+ });
+ if (url) {
+ res.setHeader('Cache-Control', 'no-store');
+ return res.redirect(302, url);
+ }
+ } catch (error) {
+ logger.warn('[shareFileAccess] download URL generation failed, streaming instead:', error);
+ }
+ }
+
+ if (!getDownloadStream) {
+ return res.status(501).send('Not Implemented');
+ }
+
+ // Strip any cache-busting query string (e.g. code-output images add `?v=...`) so
+ // the local stream resolves the real filename, not a literal `*.png?v=...` path.
+ const streamPath = (file.storageKey || file.filepath || '').split('?')[0];
+ const fileStream = await getDownloadStream(req, streamPath);
+ fileStream.on('error', (error) => {
+ logger.error('[shareFileAccess] Stream error:', error);
+ });
+
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ res.setHeader('Content-Disposition', getContentDisposition(file.filename, disposition));
+ res.setHeader(
+ 'Content-Type',
+ disposition === 'inline' ? file.type || 'application/octet-stream' : 'application/octet-stream',
+ );
+ res.setHeader('Cache-Control', 'private, max-age=3600');
+ return fileStream.pipe(res);
+};
+
+if (allowSharedLinks) {
+ const { forkIpLimiter, forkUserLimiter } = createForkLimiters();
+
+ router.get('/:shareId/config', optionalJwtAuth, canAccessSharedLink, async (_req, res) => {
+ try {
+ const payload = await getShareStartupPayload();
+ res.set('Cache-Control', 'private, no-store');
+ res.status(200).json(payload);
+ } catch (error) {
+ logger.error('Error getting shared startup config:', error);
+ res.status(500).json({ message: 'Error getting shared startup config' });
+ }
+ });
+
+ router.get(
+ '/:shareId',
+ optionalJwtAuth,
+ canAccessSharedLink,
+ configMiddleware,
+ async (req, res) => {
+ try {
+ const share = await getSharedMessages(req.params.shareId, req.shareResourceId, {
+ // Viewer-independent: the per-link choice (stored on the share) decides
+ // file inclusion; only a global env kill switch can force it off here.
+ snapshotFiles: !isFileSnapshotKillSwitchActive(),
+ });
+ if (share) {
+ res.set('Cache-Control', 'private, no-store');
+ res.status(200).json(share);
+ } else {
+ res.status(404).end();
+ }
+ } catch (error) {
+ logger.error('Error getting shared messages:', error);
+ res.status(500).json({ message: 'Error getting shared messages' });
+ }
+ },
+ );
+
+ router.post(
+ '/:shareId/fork',
+ requireJwtAuth,
+ forkIpLimiter,
+ forkUserLimiter,
+ canAccessSharedLink,
+ async (req, res) => {
+ try {
+ const result = await forkSharedConversation({
+ shareId: req.params.shareId,
+ shareResourceId: req.shareResourceId,
+ requestUserId: req.user.id,
+ userRole: req.user.role,
+ userTenantId: req.user.tenantId,
+ targetMessageIndex: req.body?.targetMessageIndex,
+ // Viewer-independent: honor the global shared-file kill switch, matching
+ // the GET share route so disabled file snapshots aren't copied into forks.
+ snapshotFiles: !isFileSnapshotKillSwitchActive(),
+ });
+ if (!result) {
+ return res.status(404).json({ message: 'Shared conversation not found' });
+ }
+ res.status(201).json(result);
+ } catch (error) {
+ logger.error('Error forking shared conversation:', error);
+ res.status(500).json({ message: 'Error forking shared conversation' });
+ }
+ },
+ );
+
+ /**
+ * Preview status for a snapshotted file. Read live from the file record so the
+ * status is always current (deferred previews may resolve after the share was
+ * created) and large extracted text is never embedded in the share document.
+ */
+ router.get(
+ '/:shareId/files/:file_id/preview',
+ optionalJwtAuth,
+ optionalShareFileAuth,
+ canAccessSharedLink,
+ configMiddleware,
+ resolveShareFile,
+ async (req, res) => {
+ try {
+ const { file_id } = req.params;
+ let liveFile = req.liveFile;
+ // Lazy-sweep orphaned pending records to 'failed' so the client preview
+ // poller reaches a terminal state (mirrors the owner preview route).
+ if (liveFile?.status === 'pending' && liveFile.updatedAt instanceof Date) {
+ const ageMs = Date.now() - liveFile.updatedAt.getTime();
+ if (ageMs > PREVIEW_LAZY_SWEEP_CUTOFF_MS) {
+ const swept = await updateFile(
+ { file_id, status: 'failed', previewError: 'orphaned' },
+ { status: 'pending', updatedAt: liveFile.updatedAt },
+ );
+ if (swept) {
+ liveFile = swept;
+ }
+ }
+ }
+ const status = liveFile?.status || 'ready';
+ const payload = { file_id, status };
+ if (status === 'ready' && liveFile?.text != null) {
+ payload.text = liveFile.text;
+ payload.textFormat = liveFile.textFormat ?? null;
+ } else if (status === 'failed' && liveFile?.previewError) {
+ payload.previewError = liveFile.previewError;
+ }
+ res.set('Cache-Control', 'private, no-store');
+ return res.status(200).json(payload);
+ } catch (error) {
+ logger.error('[shareFileAccess] Error fetching shared preview:', error);
+ return res.status(500).json({ message: 'Error fetching preview' });
+ }
+ },
+ );
+
+ /** Download a snapshotted file (attachment disposition). */
+ router.get(
+ '/:shareId/files/:file_id/download',
+ optionalJwtAuth,
+ optionalShareFileAuth,
+ canAccessSharedLink,
+ configMiddleware,
+ resolveShareFile,
+ async (req, res) => {
+ try {
+ await runWithTenant(req.shareFile.tenantId, () =>
+ streamSharedFile(req, res, req.shareFile, 'attachment'),
+ );
+ } catch (error) {
+ logger.error('[shareFileAccess] Error downloading shared file:', error);
+ if (!res.headersSent) {
+ res.status(500).send('Error downloading file');
+ }
+ }
+ },
+ );
+
+ /** Inline-serve a snapshotted file (image src, generic view). */
+ router.get(
+ '/:shareId/files/:file_id',
+ optionalJwtAuth,
+ optionalShareFileAuth,
+ canAccessSharedLink,
+ configMiddleware,
+ resolveShareFile,
+ async (req, res) => {
+ try {
+ await runWithTenant(req.shareFile.tenantId, () =>
+ streamSharedFile(req, res, req.shareFile, 'inline'),
+ );
+ } catch (error) {
+ logger.error('[shareFileAccess] Error serving shared file:', error);
+ if (!res.headersSent) {
+ res.status(500).send('Error serving file');
+ }
+ }
+ },
+ );
+}
+
+/**
+ * Shared links
+ */
+router.get('/', requireJwtAuth, async (req, res) => {
+ try {
+ const params = {
+ pageParam: req.query.cursor,
+ pageSize: Math.max(1, parseInt(req.query.pageSize) || 10),
+ sortBy: ['createdAt', 'title'].includes(req.query.sortBy) ? req.query.sortBy : 'createdAt',
+ sortDirection: ['asc', 'desc'].includes(req.query.sortDirection)
+ ? req.query.sortDirection
+ : 'desc',
+ search: req.query.search ? decodeURIComponent(req.query.search.trim()) : undefined,
+ };
+
+ const result = await getSharedLinks(
+ req.user.id,
+ params.pageParam,
+ params.pageSize,
+ params.sortBy,
+ params.sortDirection,
+ params.search,
+ );
+
+ res.status(200).send({
+ links: result.links,
+ nextCursor: result.nextCursor,
+ hasNextPage: result.hasNextPage,
+ });
+ } catch (error) {
+ logger.error('Error getting shared links:', error);
+ res.status(500).json({
+ message: 'Error getting shared links',
+ error: error.message,
+ });
+ }
+});
+
+router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
+ try {
+ const share = await getSharedLink(req.user.id, req.params.conversationId);
+
+ if (share._id && share.success) {
+ await ensureLinkPermissions(share._id, req.user.id);
+ }
+
+ return res.status(200).json({
+ _id: share._id,
+ success: share.success,
+ shareId: share.shareId,
+ targetMessageId: share.targetMessageId,
+ snapshotFiles: share.snapshotFiles,
+ conversationId: req.params.conversationId,
+ });
+ } catch (error) {
+ logger.error('Error getting shared link:', error);
+ res.status(500).json({ message: 'Error getting shared link' });
+ }
+});
+
+router.post(
+ '/:conversationId',
+ requireJwtAuth,
+ configMiddleware,
+ checkSharedLinksAccess,
+ async (req, res) => {
+ try {
+ const { targetMessageId } = req.body;
+ const expiredAt = await resolveSharedLinkExpiration(req, req.params.conversationId);
+ if (expiredAt != null && !isActiveExpirationDate(expiredAt)) {
+ return res.status(404).end();
+ }
+
+ const role = await getRoleByName(req.user.role);
+ const sharedLinksPerms = role?.permissions?.[PermissionTypes.SHARED_LINKS] || {};
+ const grantPublic = sharedLinksPerms[Permissions.SHARE_PUBLIC] === true;
+ // Per-link opt-out: snapshot only when the feature is enabled AND the user
+ // did not uncheck "share files" (body flag absent defaults to enabled).
+ const snapshotFiles = isFileSnapshotEnabled(req.config) && req.body?.snapshotFiles !== false;
+
+ const created = await createSharedLink(
+ req.user.id,
+ req.params.conversationId,
+ targetMessageId,
+ expiredAt,
+ snapshotFiles,
+ );
+ if (created) {
+ await grantCreationPermissions(created._id, req.user.id, grantPublic, expiredAt);
+ res.status(200).json(created);
+ } else {
+ res.status(404).end();
+ }
+ } catch (error) {
+ logger.error('Error creating shared link:', error);
+ res.status(500).json({ message: 'Error creating shared link' });
+ }
+ },
+);
+
+router.patch('/:shareId', requireJwtAuth, configMiddleware, async (req, res) => {
+ try {
+ const { targetMessageId } = req.body ?? {};
+ if (targetMessageId !== undefined && typeof targetMessageId !== 'string') {
+ return res.status(400).json({ message: 'targetMessageId must be a string' });
+ }
+
+ let expiredAt;
+ const SharedLink = mongoose.models.SharedLink;
+ const existing = await SharedLink.findOne(
+ { shareId: req.params.shareId, user: req.user.id },
+ 'conversationId',
+ ).lean();
+ if (existing?.conversationId) {
+ expiredAt = await resolveSharedLinkExpiration(req, existing.conversationId);
+ }
+ if (expiredAt != null && !isActiveExpirationDate(expiredAt)) {
+ return res.status(404).end();
+ }
+
+ const updatedShare = await updateSharedLink(
+ req.user.id,
+ req.params.shareId,
+ targetMessageId,
+ expiredAt,
+ isFileSnapshotEnabled(req.config) && req.body?.snapshotFiles !== false,
+ );
+ if (updatedShare) {
+ if (updatedShare._id && expiredAt !== undefined) {
+ await updateSharedLinkPermissionsExpiration(updatedShare._id, expiredAt);
+ }
+ res.status(200).json(updatedShare);
+ } else {
+ res.status(404).end();
+ }
+ } catch (error) {
+ logger.error('Error updating shared link:', error);
+ res.status(500).json({ message: 'Error updating shared link' });
+ }
+});
+
+router.delete('/:shareId', requireJwtAuth, async (req, res) => {
+ try {
+ const result = await deleteSharedLinkWithCleanup(req.user.id, req.params.shareId);
+
+ if (!result) {
+ return res.status(404).json({ message: 'Share not found' });
+ }
+
+ return res.status(200).json(result);
+ } catch (error) {
+ logger.error('Error deleting shared link:', error);
+ return res.status(400).json({ message: 'Error deleting shared link' });
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/skills.js b/api/server/routes/skills.js
new file mode 100644
index 0000000..99339d2
--- /dev/null
+++ b/api/server/routes/skills.js
@@ -0,0 +1,370 @@
+const path = require('path');
+const crypto = require('crypto');
+const multer = require('multer');
+const express = require('express');
+const {
+ createSkillsHandlers,
+ createImportHandler,
+ generateCheckAccess,
+ getStorageMetadata,
+ resolveRequestTenantId,
+ restoreTenantContextFromReq,
+} = require('@librechat/api');
+const { isValidObjectIdString, logger } = require('@librechat/data-schemas');
+const {
+ PermissionBits,
+ PermissionTypes,
+ Permissions,
+ FileContext,
+ mergeFileConfig,
+} = require('librechat-data-provider');
+const {
+ createSkill,
+ getSkillById,
+ updateSkill,
+ deleteSkill,
+ upsertSkillFile,
+ deleteSkillFile,
+ getSkillFileByPath,
+ getRoleByName,
+} = require('~/models');
+const { requireJwtAuth, canAccessSkillResource } = require('~/server/middleware');
+const {
+ findAccessibleResources,
+ findPubliclyAccessibleResources,
+ hasPublicPermission,
+ grantPermission,
+} = require('~/server/services/PermissionService');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { createFileLimiters } = require('~/server/middleware/limiters/uploadLimiters');
+const { maybeRunGitHubSkillSyncForRequest } = require('~/server/services/Skills/sync');
+const configMiddleware = require('~/server/middleware/config/app');
+const { getFileStrategy } = require('~/server/utils/getFileStrategy');
+const {
+ getSkillDbMethods,
+ withDeploymentSkillIds,
+ getSkillStrategyFunctions,
+} = require('~/server/services/Endpoints/agents/skillDeps');
+
+const router = express.Router();
+
+// ---------------------------------------------------------------------------
+// Multer: memory storage for skill imports (zip processed in-memory)
+// ---------------------------------------------------------------------------
+const ALLOWED_EXTENSIONS = new Set(['.md', '.zip', '.skill']);
+const MAX_IMPORT_SIZE = 50 * 1024 * 1024; // 50 MB
+
+const memoryStorage = multer.memoryStorage();
+
+function getSkillImportSizeLimit(req) {
+ const fileConfig = mergeFileConfig(req.config?.fileConfig);
+ return fileConfig.skills?.fileSizeLimit ?? MAX_IMPORT_SIZE;
+}
+
+const skillImportFilter = (_req, file, cb) => {
+ const ext = path.extname(file.originalname).toLowerCase();
+ if (ALLOWED_EXTENSIONS.has(ext)) {
+ cb(null, true);
+ } else {
+ // N.B. The error handler at the bottom of this file matches this "Only " prefix.
+ cb(new Error('Only .md, .zip, and .skill files are allowed'), false);
+ }
+};
+
+const skillUpload = (req, res, next) =>
+ multer({
+ storage: memoryStorage,
+ fileFilter: skillImportFilter,
+ limits: { fileSize: getSkillImportSizeLimit(req) },
+ }).single('file')(req, res, next);
+
+// Per-file upload (for adding individual files to an existing skill)
+const MAX_SINGLE_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
+const singleFileUpload = multer({
+ storage: memoryStorage,
+ limits: { fileSize: MAX_SINGLE_FILE_SIZE },
+});
+
+// ---------------------------------------------------------------------------
+// Role-based capability gates
+// ---------------------------------------------------------------------------
+const checkSkillAccess = generateCheckAccess({
+ permissionType: PermissionTypes.SKILLS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+});
+const checkSkillCreate = generateCheckAccess({
+ permissionType: PermissionTypes.SKILLS,
+ permissions: [Permissions.USE, Permissions.CREATE],
+ getRoleByName,
+});
+
+// ---------------------------------------------------------------------------
+// Rate limiters (reuse existing file upload limiters)
+// ---------------------------------------------------------------------------
+const { fileUploadIpLimiter, fileUploadUserLimiter } = createFileLimiters();
+const skillDbMethods = getSkillDbMethods();
+
+router.use(requireJwtAuth);
+router.use(configMiddleware);
+router.use(checkSkillAccess);
+
+// ---------------------------------------------------------------------------
+// CRUD handlers
+// ---------------------------------------------------------------------------
+const handlers = createSkillsHandlers({
+ createSkill,
+ getSkillById: skillDbMethods.getSkillById,
+ listSkillsByAccess: skillDbMethods.listSkillsByAccess,
+ updateSkill,
+ deleteSkill,
+ listSkillFiles: skillDbMethods.listSkillFiles,
+ deleteSkillFile,
+ getSkillFileByPath: skillDbMethods.getSkillFileByPath,
+ updateSkillFileContent: skillDbMethods.updateSkillFileContent,
+ getStrategyFunctions: getSkillStrategyFunctions,
+ findAccessibleResources: async (params) =>
+ params.resourceType === 'skill' && params.requiredPermissions === PermissionBits.VIEW
+ ? withDeploymentSkillIds(await findAccessibleResources(params))
+ : findAccessibleResources(params),
+ findPubliclyAccessibleResources: async (params) =>
+ params.resourceType === 'skill' && params.requiredPermissions === PermissionBits.VIEW
+ ? withDeploymentSkillIds(await findPubliclyAccessibleResources(params))
+ : findPubliclyAccessibleResources(params),
+ hasPublicPermission: async (params) =>
+ params.resourceType === 'skill' && params.requiredPermissions === PermissionBits.VIEW
+ ? withDeploymentSkillIds([]).some((id) => id.toString() === params.resourceId.toString()) ||
+ hasPublicPermission(params)
+ : hasPublicPermission(params),
+ grantPermission,
+ isValidObjectIdString,
+});
+
+// ---------------------------------------------------------------------------
+// File storage helper: resolve the active strategy's saveBuffer
+// ---------------------------------------------------------------------------
+function resolveSkillStorage(req, { isImage = false } = {}) {
+ const source = getFileStrategy(req.config, { context: FileContext.skill_file, isImage });
+ const strategy = getStrategyFunctions(source);
+ if (!strategy.saveBuffer) {
+ throw new Error(`Storage backend "${source}" does not support file writes`);
+ }
+ return { saveBuffer: strategy.saveBuffer, source };
+}
+
+// ---------------------------------------------------------------------------
+// Import handler (zip/md/skill → create skill + files)
+// ---------------------------------------------------------------------------
+const importHandler = createImportHandler({
+ limits: (req) => ({
+ maxZipBytes: getSkillImportSizeLimit(req),
+ }),
+ createSkill,
+ getSkillById,
+ deleteSkill,
+ upsertSkillFile,
+ saveBuffer: (req, { userId, buffer, fileName, basePath, isImage, tenantId }) => {
+ const requestTenantId = tenantId ?? resolveRequestTenantId(req);
+ const storage = resolveSkillStorage(req, { isImage });
+ return storage
+ .saveBuffer({ userId, buffer, fileName, basePath, tenantId: requestTenantId })
+ .then((filepath) => ({
+ filepath,
+ source: storage.source,
+ ...getStorageMetadata({ filepath, source: storage.source }),
+ }));
+ },
+ deleteFile: (req, file) => {
+ const { deleteFile } = getStrategyFunctions(file.source);
+ if (deleteFile) {
+ return deleteFile(req, file);
+ }
+ return Promise.resolve();
+ },
+ grantPermission,
+});
+
+// ---------------------------------------------------------------------------
+// Per-file upload handler (add a single file to an existing skill)
+// ---------------------------------------------------------------------------
+async function uploadFileHandler(req, res) {
+ try {
+ const { file } = req;
+ if (!file) {
+ return res.status(400).json({ error: 'No file provided' });
+ }
+
+ const skillId = req.params.id;
+ const relativePath = req.body.relativePath;
+ if (!relativePath) {
+ return res.status(400).json({ error: 'relativePath is required in form body' });
+ }
+ if (relativePath.toUpperCase() === 'SKILL.MD') {
+ return res.status(400).json({ error: 'SKILL.md is reserved; update the skill body instead' });
+ }
+ // Reject traversal, absolute paths, empty/dot segments — matches model-layer validator
+ // so storage writes don't happen before DB rejects the path.
+ if (
+ !/^[a-zA-Z0-9._\-/]+$/.test(relativePath) ||
+ /^\//.test(relativePath) ||
+ relativePath.split('/').some((s) => s === '' || s === '.' || s === '..')
+ ) {
+ return res.status(400).json({ error: 'Invalid file path' });
+ }
+
+ const tenantId = resolveRequestTenantId(req);
+
+ // Look up existing file before saving — needed to clean up old blob on replace
+ const existingFile = await getSkillFileByPath(skillId, relativePath);
+
+ const fileId = crypto.randomUUID();
+ const filename = file.originalname;
+ const storageFileName = `${fileId}__${filename}`;
+
+ const isImage = (file.mimetype || '').startsWith('image/');
+ const storage = resolveSkillStorage(req, { isImage });
+ const filepath = await storage.saveBuffer({
+ userId: req.user.id,
+ buffer: file.buffer,
+ fileName: storageFileName,
+ basePath: 'uploads',
+ tenantId,
+ });
+ const storageMetadata = getStorageMetadata({ filepath, source: storage.source });
+
+ let result;
+ try {
+ result = await upsertSkillFile({
+ skillId,
+ relativePath,
+ file_id: fileId,
+ filename,
+ filepath,
+ ...storageMetadata,
+ source: storage.source,
+ mimeType: file.mimetype || 'application/octet-stream',
+ bytes: file.size,
+ isExecutable: false,
+ author: req.user._id,
+ tenantId,
+ });
+ } catch (dbError) {
+ // Clean up the stored blob so it doesn't leak on DB failure
+ try {
+ const { deleteFile } = getStrategyFunctions(storage.source);
+ if (deleteFile) {
+ await deleteFile(req, { filepath, user: req.user.id, tenantId });
+ }
+ } catch (cleanupErr) {
+ logger.error('[uploadFile] Failed to clean up orphaned blob:', cleanupErr);
+ }
+ throw dbError;
+ }
+
+ // Clean up old blob if this was a replace (different filepath means new storage object)
+ if (existingFile && existingFile.filepath !== filepath) {
+ const { deleteFile: delOld } = getStrategyFunctions(existingFile.source);
+ if (delOld) {
+ delOld(req, {
+ filepath: existingFile.filepath,
+ user: existingFile.author ?? req.user.id,
+ tenantId: existingFile.tenantId ?? tenantId,
+ }).catch((e) => logger.error('[uploadFile] Old blob cleanup failed:', e));
+ }
+ }
+
+ return res.status(200).json(result);
+ } catch (error) {
+ if (error.code === 'SKILL_FILE_VALIDATION_FAILED') {
+ return res.status(400).json({ error: error.message });
+ }
+ logger.error('[uploadFile] Error:', error);
+ return res.status(500).json({ error: 'Failed to upload file' });
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Routes
+// ---------------------------------------------------------------------------
+async function maybeStartRequestSkillSync(req, _res, next) {
+ try {
+ await maybeRunGitHubSkillSyncForRequest(req);
+ } catch (error) {
+ logger.error('[GET /skills] Failed to start request-scoped skill sync:', error);
+ }
+ next();
+}
+
+// Import: accepts .md / .zip / .skill via multipart
+router.post(
+ '/import',
+ checkSkillCreate,
+ fileUploadIpLimiter,
+ fileUploadUserLimiter,
+ skillUpload,
+ restoreTenantContextFromReq,
+ importHandler,
+);
+
+router.get('/', maybeStartRequestSkillSync, handlers.list);
+router.post('/', checkSkillCreate, handlers.create);
+
+router.get(
+ '/:id',
+ canAccessSkillResource({ requiredPermission: PermissionBits.VIEW }),
+ handlers.get,
+);
+
+router.patch(
+ '/:id',
+ checkSkillCreate,
+ canAccessSkillResource({ requiredPermission: PermissionBits.EDIT }),
+ handlers.patch,
+);
+
+router.delete(
+ '/:id',
+ checkSkillCreate,
+ canAccessSkillResource({ requiredPermission: PermissionBits.DELETE }),
+ handlers.delete,
+);
+
+router.get(
+ '/:id/files',
+ canAccessSkillResource({ requiredPermission: PermissionBits.VIEW }),
+ handlers.listFiles,
+);
+
+// Per-file upload (live — replaces 501 stub)
+router.post(
+ '/:id/files',
+ canAccessSkillResource({ requiredPermission: PermissionBits.EDIT }),
+ fileUploadIpLimiter,
+ fileUploadUserLimiter,
+ singleFileUpload.single('file'),
+ restoreTenantContextFromReq,
+ uploadFileHandler,
+);
+
+router.get(
+ '/:id/files/:relativePath',
+ canAccessSkillResource({ requiredPermission: PermissionBits.VIEW }),
+ handlers.downloadFile,
+);
+
+router.delete(
+ '/:id/files/:relativePath',
+ canAccessSkillResource({ requiredPermission: PermissionBits.EDIT }),
+ handlers.deleteFile,
+);
+
+// Multer + file-filter error handler — surface as 400, forward everything else
+
+router.use((err, _req, res, next) => {
+ if (err && (err.name === 'MulterError' || err.message?.startsWith('Only '))) {
+ return res.status(400).json({ error: err.message });
+ }
+ return next(err);
+});
+
+module.exports = router;
diff --git a/api/server/routes/skills.tenant.test.js b/api/server/routes/skills.tenant.test.js
new file mode 100644
index 0000000..4586e3c
--- /dev/null
+++ b/api/server/routes/skills.tenant.test.js
@@ -0,0 +1,377 @@
+process.env.TENANT_ISOLATION_STRICT = 'true';
+
+const express = require('express');
+const request = require('supertest');
+const JSZip = require('jszip');
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { tenantStorage } = require('@librechat/data-schemas');
+const {
+ SystemRoles,
+ ResourceType,
+ AccessRoleIds,
+ PrincipalType,
+ PermissionBits,
+} = require('librechat-data-provider');
+
+const TEST_TENANT = 'tenant-skills-strict';
+
+jest.mock('~/server/services/Config', () => ({
+ getCachedTools: jest.fn().mockResolvedValue({}),
+ getAppConfig: jest.fn().mockResolvedValue({
+ fileStrategy: 'local',
+ paths: { uploads: '/tmp/uploads', images: '/tmp/images' },
+ }),
+}));
+
+jest.mock('~/server/middleware/config/app', () => (req, _res, next) => {
+ req.config = {
+ fileStrategy: 'local',
+ paths: { uploads: '/tmp/uploads', images: '/tmp/images' },
+ };
+ next();
+});
+
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn().mockReturnValue({
+ saveBuffer: jest.fn().mockResolvedValue('/uploads/test/file.txt'),
+ getDownloadStream: jest.fn().mockResolvedValue({
+ pipe: jest.fn(),
+ on: jest.fn(),
+ [Symbol.asyncIterator]: async function* () {
+ yield Buffer.from('test content');
+ },
+ }),
+ }),
+}));
+
+jest.mock('~/server/utils/getFileStrategy', () => ({
+ getFileStrategy: jest.fn().mockReturnValue('local'),
+}));
+
+jest.mock('~/models', () => {
+ const mongoose = require('mongoose');
+ const { createMethods } = require('@librechat/data-schemas');
+ const methods = createMethods(mongoose, {
+ removeAllPermissions: async ({ resourceType, resourceId }) => {
+ const AclEntry = mongoose.models.AclEntry;
+ if (AclEntry) {
+ await AclEntry.deleteMany({ resourceType, resourceId });
+ }
+ },
+ });
+ return {
+ ...methods,
+ getRoleByName: jest.fn(),
+ };
+});
+
+jest.mock('~/server/middleware', () => {
+ const actual = jest.requireActual('~/server/middleware');
+ const { tenantStorage } = require('@librechat/data-schemas');
+
+ return {
+ requireJwtAuth: (req, _res, next) => {
+ const tenantId = req.tenantId ?? req.user?.tenantId;
+ if (!tenantId) {
+ next();
+ return;
+ }
+ tenantStorage.run({ tenantId }, async () => next());
+ },
+ canAccessSkillResource: actual.canAccessSkillResource,
+ };
+});
+
+let app;
+let mongoServer;
+let Skill;
+let SkillFile;
+let AclEntry;
+let AccessRole;
+let User;
+let testUsers;
+let testRoles;
+let currentTestUser;
+let currentRequestTenantId;
+
+function setTestUser(user) {
+ currentTestUser = user;
+}
+
+function toRequestUser(user) {
+ const raw = user.toObject ? user.toObject() : user;
+ return {
+ ...raw,
+ id: user._id.toString(),
+ _id: user._id,
+ name: user.name,
+ role: user.role,
+ tenantId: user.tenantId,
+ };
+}
+
+async function runInTenant(fn) {
+ return tenantStorage.run({ tenantId: TEST_TENANT }, fn);
+}
+
+beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ await mongoose.connect(mongoServer.getUri());
+
+ const dbModels = require('~/db/models');
+ Skill = dbModels.Skill;
+ SkillFile = dbModels.SkillFile;
+ AclEntry = dbModels.AclEntry;
+ AccessRole = dbModels.AccessRole;
+ User = dbModels.User;
+
+ await runInTenant(setupTestData);
+
+ app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ if (currentRequestTenantId) {
+ req.tenantId = currentRequestTenantId;
+ }
+ if (currentTestUser) {
+ req.user = toRequestUser(currentTestUser);
+ }
+ next();
+ });
+
+ currentTestUser = testUsers.owner;
+ app.use('/api/skills', require('./skills'));
+});
+
+afterEach(async () => {
+ await runInTenant(async () => {
+ await Skill.deleteMany({});
+ await SkillFile.deleteMany({});
+ await AclEntry.deleteMany({});
+ });
+ currentTestUser = testUsers.owner;
+ currentRequestTenantId = undefined;
+});
+
+afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ delete process.env.TENANT_ISOLATION_STRICT;
+ jest.clearAllMocks();
+});
+
+async function setupTestData() {
+ testRoles = {
+ owner: await AccessRole.create({
+ accessRoleId: AccessRoleIds.SKILL_OWNER,
+ name: 'Owner',
+ resourceType: ResourceType.SKILL,
+ permBits:
+ PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
+ tenantId: TEST_TENANT,
+ }),
+ };
+
+ testUsers = {
+ owner: await User.create({
+ name: 'Strict Skill Owner',
+ email: 'strict-skill-owner@test.com',
+ role: SystemRoles.USER,
+ tenantId: TEST_TENANT,
+ }),
+ };
+
+ const { getRoleByName } = require('~/models');
+ getRoleByName.mockImplementation((roleName) => {
+ if (roleName === SystemRoles.USER || roleName === SystemRoles.ADMIN) {
+ return {
+ permissions: {
+ SKILLS: {
+ USE: true,
+ CREATE: true,
+ SHARE: true,
+ SHARE_PUBLIC: true,
+ },
+ },
+ };
+ }
+ return null;
+ });
+}
+
+async function createSkillAsOwner(overrides = {}) {
+ return request(app)
+ .post('/api/skills')
+ .send({
+ name: 'strict-file-skill',
+ description: 'A strict tenant skill used in multipart route tests.',
+ body: '# Strict File Skill',
+ ...overrides,
+ });
+}
+
+describe('Skill multipart routes under strict tenant isolation', () => {
+ it('imports a skill zip and writes skill, ACL, and files in the request tenant', async () => {
+ const zip = new JSZip();
+ zip.file(
+ 'SKILL.md',
+ [
+ '---',
+ 'name: strict-import',
+ 'description: Strict tenant import route test skill.',
+ '---',
+ '# Strict Import',
+ ].join('\n'),
+ );
+ zip.file('scripts/run.sh', 'echo strict');
+ const buffer = await zip.generateAsync({ type: 'nodebuffer' });
+
+ const res = await request(app).post('/api/skills/import').attach('file', buffer, {
+ filename: 'strict-import.skill',
+ contentType: 'application/zip',
+ });
+
+ expect(res.status).toBe(201);
+ expect(res.body.tenantId).toBe(TEST_TENANT);
+ expect(res.body._importSummary.filesSucceeded).toBe(1);
+
+ const { skill, acl, file } = await runInTenant(async () => {
+ const skill = await Skill.findOne({ name: 'strict-import' }).lean();
+ const acl = await AclEntry.findOne({
+ resourceType: ResourceType.SKILL,
+ resourceId: skill._id,
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ }).lean();
+ const file = await SkillFile.findOne({
+ skillId: skill._id,
+ relativePath: 'scripts/run.sh',
+ }).lean();
+ return { skill, acl, file };
+ });
+
+ expect(skill.tenantId).toBe(TEST_TENANT);
+ expect(acl).toEqual(
+ expect.objectContaining({
+ tenantId: TEST_TENANT,
+ roleId: testRoles.owner._id,
+ }),
+ );
+ expect(file).toEqual(
+ expect.objectContaining({
+ tenantId: TEST_TENANT,
+ relativePath: 'scripts/run.sh',
+ }),
+ );
+ });
+
+ it('imports using the resolved request tenant when user tenant differs', async () => {
+ currentRequestTenantId = TEST_TENANT;
+ setTestUser({
+ ...testUsers.owner.toObject(),
+ tenantId: 'stale-user-tenant',
+ });
+
+ const res = await request(app)
+ .post('/api/skills/import')
+ .attach('file', Buffer.from('# Request Tenant Markdown'), {
+ filename: 'request-tenant.md',
+ contentType: 'text/markdown',
+ });
+
+ expect(res.status).toBe(201);
+ expect(res.body.tenantId).toBe(TEST_TENANT);
+
+ const savedSkill = await runInTenant(async () =>
+ Skill.findOne({ name: 'request-tenant' }).lean(),
+ );
+ expect(savedSkill).toEqual(
+ expect.objectContaining({
+ tenantId: TEST_TENANT,
+ }),
+ );
+ });
+
+ it('rejects multipart import in strict mode when the request has no tenant', async () => {
+ setTestUser({
+ _id: new mongoose.Types.ObjectId(),
+ name: 'No Tenant',
+ role: SystemRoles.USER,
+ });
+
+ const res = await request(app)
+ .post('/api/skills/import')
+ .attach('file', Buffer.from('# No Tenant'), {
+ filename: 'no-tenant.md',
+ contentType: 'text/markdown',
+ });
+
+ expect(res.status).toBe(403);
+ expect(res.body.error).toMatch(/Tenant context required/);
+ });
+
+ it('uploads an individual skill file in the request tenant', async () => {
+ const created = await createSkillAsOwner();
+ expect(created.status).toBe(201);
+
+ const res = await request(app)
+ .post(`/api/skills/${created.body._id}/files`)
+ .field('relativePath', 'scripts/manual.sh')
+ .attach('file', Buffer.from('echo manual'), {
+ filename: 'manual.sh',
+ contentType: 'text/x-shellscript',
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.body.tenantId).toBe(TEST_TENANT);
+
+ const savedFile = await runInTenant(async () =>
+ SkillFile.findOne({
+ skillId: created.body._id,
+ relativePath: 'scripts/manual.sh',
+ }).lean(),
+ );
+ expect(savedFile).toEqual(
+ expect.objectContaining({
+ tenantId: TEST_TENANT,
+ relativePath: 'scripts/manual.sh',
+ }),
+ );
+ });
+
+ it('uploads an individual skill file using the resolved request tenant', async () => {
+ const created = await createSkillAsOwner({ name: 'request-tenant-file-skill' });
+ expect(created.status).toBe(201);
+
+ currentRequestTenantId = TEST_TENANT;
+ setTestUser({
+ ...testUsers.owner.toObject(),
+ tenantId: 'stale-user-tenant',
+ });
+
+ const res = await request(app)
+ .post(`/api/skills/${created.body._id}/files`)
+ .field('relativePath', 'scripts/request-tenant.sh')
+ .attach('file', Buffer.from('echo request tenant'), {
+ filename: 'request-tenant.sh',
+ contentType: 'text/x-shellscript',
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.body.tenantId).toBe(TEST_TENANT);
+
+ const savedFile = await runInTenant(async () =>
+ SkillFile.findOne({
+ skillId: created.body._id,
+ relativePath: 'scripts/request-tenant.sh',
+ }).lean(),
+ );
+ expect(savedFile).toEqual(
+ expect.objectContaining({
+ tenantId: TEST_TENANT,
+ relativePath: 'scripts/request-tenant.sh',
+ }),
+ );
+ });
+});
diff --git a/api/server/routes/skills.test.js b/api/server/routes/skills.test.js
new file mode 100644
index 0000000..c48c0ff
--- /dev/null
+++ b/api/server/routes/skills.test.js
@@ -0,0 +1,628 @@
+const express = require('express');
+const request = require('supertest');
+const JSZip = require('jszip');
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+
+jest.mock('librechat-data-provider', () => {
+ const actual = jest.requireActual('librechat-data-provider');
+ return {
+ ...actual,
+ mergeFileConfig: jest.fn((dynamic) => {
+ const skillFileSizeLimit = dynamic?.skills?.fileSizeLimit;
+ return {
+ ...actual.fileConfig,
+ ...dynamic,
+ skills: {
+ ...(actual.fileConfig.skills ?? { fileSizeLimit: 50 * 1024 * 1024 }),
+ ...(skillFileSizeLimit !== undefined
+ ? { fileSizeLimit: skillFileSizeLimit * 1024 * 1024 }
+ : {}),
+ },
+ };
+ }),
+ };
+});
+
+const {
+ SystemRoles,
+ ResourceType,
+ AccessRoleIds,
+ PrincipalType,
+ PermissionBits,
+} = require('librechat-data-provider');
+
+let mockFileConfig;
+const mockMaybeRunGitHubSkillSyncForRequest = jest.fn(async () => false);
+
+jest.mock('~/server/services/Config', () => ({
+ getCachedTools: jest.fn().mockResolvedValue({}),
+ getAppConfig: jest.fn().mockResolvedValue({
+ fileStrategy: 'local',
+ paths: { uploads: '/tmp/uploads', images: '/tmp/images' },
+ }),
+}));
+
+jest.mock('~/server/middleware/config/app', () => (req, _res, next) => {
+ req.config = {
+ fileStrategy: 'local',
+ paths: { uploads: '/tmp/uploads', images: '/tmp/images' },
+ fileConfig: mockFileConfig,
+ };
+ next();
+});
+
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn().mockReturnValue({
+ saveBuffer: jest.fn().mockResolvedValue('/uploads/test/file.txt'),
+ getDownloadStream: jest.fn().mockResolvedValue({
+ pipe: jest.fn(),
+ on: jest.fn(),
+ [Symbol.asyncIterator]: async function* () {
+ yield Buffer.from('test content');
+ },
+ }),
+ }),
+}));
+
+jest.mock('~/server/utils/getFileStrategy', () => ({
+ getFileStrategy: jest.fn().mockReturnValue('local'),
+}));
+
+jest.mock('~/server/services/Skills/sync', () => ({
+ maybeRunGitHubSkillSyncForRequest: mockMaybeRunGitHubSkillSyncForRequest,
+}));
+
+jest.mock('~/models', () => {
+ const mongoose = require('mongoose');
+ const { createMethods } = require('@librechat/data-schemas');
+ const methods = createMethods(mongoose, {
+ removeAllPermissions: async ({ resourceType, resourceId }) => {
+ const AclEntry = mongoose.models.AclEntry;
+ if (AclEntry) {
+ await AclEntry.deleteMany({ resourceType, resourceId });
+ }
+ },
+ });
+ // Override getRoleByName to return a permissive SKILLS capability block for all
+ // test users. The real role seeding relies on `initializeRoles` which this
+ // suite intentionally skips to keep setup minimal.
+ return {
+ ...methods,
+ getRoleByName: jest.fn(),
+ };
+});
+
+jest.mock('~/server/middleware', () => ({
+ requireJwtAuth: (req, res, next) => next(),
+ canAccessSkillResource: jest.requireActual('~/server/middleware').canAccessSkillResource,
+}));
+
+let app;
+let mongoServer;
+let skillRoutes;
+let Skill;
+let SkillFile;
+let AclEntry;
+let AccessRole;
+let User;
+let testUsers;
+let testRoles;
+let grantPermission;
+let currentTestUser;
+
+function setTestUser(user) {
+ currentTestUser = user;
+}
+
+beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ await mongoose.connect(mongoServer.getUri());
+
+ const dbModels = require('~/db/models');
+ Skill = dbModels.Skill;
+ SkillFile = dbModels.SkillFile;
+ AclEntry = dbModels.AclEntry;
+ AccessRole = dbModels.AccessRole;
+ User = dbModels.User;
+
+ const permissionService = require('~/server/services/PermissionService');
+ grantPermission = permissionService.grantPermission;
+
+ await setupTestData();
+
+ app = express();
+ app.use(express.json());
+ app.use((req, res, next) => {
+ if (currentTestUser) {
+ req.user = {
+ ...(currentTestUser.toObject ? currentTestUser.toObject() : currentTestUser),
+ id: currentTestUser._id.toString(),
+ _id: currentTestUser._id,
+ name: currentTestUser.name,
+ role: currentTestUser.role,
+ };
+ }
+ next();
+ });
+
+ currentTestUser = testUsers.owner;
+ skillRoutes = require('./skills');
+ app.use('/api/skills', skillRoutes);
+});
+
+afterEach(async () => {
+ await Skill.deleteMany({});
+ await SkillFile.deleteMany({});
+ await AclEntry.deleteMany({});
+ currentTestUser = testUsers.owner;
+ mockFileConfig = undefined;
+ mockMaybeRunGitHubSkillSyncForRequest.mockClear();
+});
+
+afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ jest.clearAllMocks();
+});
+
+async function setupTestData() {
+ testRoles = {
+ viewer: await AccessRole.create({
+ accessRoleId: AccessRoleIds.SKILL_VIEWER,
+ name: 'Viewer',
+ resourceType: ResourceType.SKILL,
+ permBits: PermissionBits.VIEW,
+ }),
+ editor: await AccessRole.create({
+ accessRoleId: AccessRoleIds.SKILL_EDITOR,
+ name: 'Editor',
+ resourceType: ResourceType.SKILL,
+ permBits: PermissionBits.VIEW | PermissionBits.EDIT,
+ }),
+ owner: await AccessRole.create({
+ accessRoleId: AccessRoleIds.SKILL_OWNER,
+ name: 'Owner',
+ resourceType: ResourceType.SKILL,
+ permBits:
+ PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
+ }),
+ };
+
+ testUsers = {
+ owner: await User.create({
+ name: 'Skill Owner',
+ email: 'skill-owner@test.com',
+ role: SystemRoles.USER,
+ }),
+ editor: await User.create({
+ name: 'Skill Editor',
+ email: 'skill-editor@test.com',
+ role: SystemRoles.USER,
+ }),
+ noAccess: await User.create({
+ name: 'No Access',
+ email: 'no-access@test.com',
+ role: SystemRoles.USER,
+ }),
+ };
+
+ const { getRoleByName } = require('~/models');
+ getRoleByName.mockImplementation((roleName) => {
+ if (roleName === SystemRoles.USER || roleName === SystemRoles.ADMIN) {
+ return {
+ permissions: {
+ SKILLS: {
+ USE: true,
+ CREATE: true,
+ SHARE: true,
+ SHARE_PUBLIC: true,
+ },
+ },
+ };
+ }
+ return null;
+ });
+}
+
+async function createSkillAsOwner(overrides = {}) {
+ // Description is deliberately kept above the 20-char short-description
+ // warning threshold so existing tests don't trip the coaching warning.
+ const res = await request(app)
+ .post('/api/skills')
+ .send({
+ name: 'demo-skill',
+ description: 'A small demo skill used in routing integration tests.',
+ body: '# Demo',
+ ...overrides,
+ });
+ return res;
+}
+
+describe('Skill routes', () => {
+ let errSpy;
+ beforeEach(() => {
+ errSpy = jest.spyOn(console, 'error').mockImplementation();
+ });
+ afterEach(() => errSpy.mockRestore());
+
+ describe('POST /api/skills', () => {
+ it('creates a skill and grants SKILL_OWNER ACL', async () => {
+ const res = await createSkillAsOwner();
+ expect(res.status).toBe(201);
+ expect(res.body._id).toBeDefined();
+ expect(res.body.version).toBe(1);
+ expect(res.body.name).toBe('demo-skill');
+ // No warnings on a description that comfortably clears the threshold.
+ expect(res.body.warnings).toBeUndefined();
+
+ const acl = await AclEntry.findOne({
+ resourceType: ResourceType.SKILL,
+ resourceId: res.body._id,
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ });
+ expect(acl).toBeTruthy();
+ expect(acl.roleId.toString()).toBe(testRoles.owner._id.toString());
+ });
+
+ it('attaches a TOO_SHORT warning on create when description is under 20 chars', async () => {
+ const res = await createSkillAsOwner({
+ name: 'short-desc-skill',
+ description: 'Too short.',
+ });
+ expect(res.status).toBe(201);
+ expect(res.body._id).toBeDefined();
+ expect(Array.isArray(res.body.warnings)).toBe(true);
+ expect(res.body.warnings).toEqual([
+ expect.objectContaining({
+ field: 'description',
+ code: 'TOO_SHORT',
+ severity: 'warning',
+ }),
+ ]);
+ });
+
+ it('rejects names starting with reserved brand prefixes', async () => {
+ const anthropic = await createSkillAsOwner({ name: 'anthropic-helper' });
+ expect(anthropic.status).toBe(400);
+ const claude = await createSkillAsOwner({ name: 'claude-helper' });
+ expect(claude.status).toBe(400);
+ });
+
+ it('allows names that merely contain reserved brand words as substrings', async () => {
+ const res = await createSkillAsOwner({ name: 'research-anthropic-helper' });
+ expect(res.status).toBe(201);
+ });
+
+ it('rejects reserved CLI command names', async () => {
+ const res = await createSkillAsOwner({ name: 'settings' });
+ expect(res.status).toBe(400);
+ });
+
+ it('rejects frontmatter with unknown keys', async () => {
+ const res = await createSkillAsOwner({
+ name: 'bad-frontmatter-skill',
+ frontmatter: { 'not-a-real-key': 'value' },
+ });
+ expect(res.status).toBe(400);
+ expect(res.body.issues).toEqual(
+ expect.arrayContaining([expect.objectContaining({ code: 'UNKNOWN_KEY' })]),
+ );
+ });
+
+ it('rejects missing description with 400', async () => {
+ const res = await request(app).post('/api/skills').send({ name: 'x-skill', body: '' });
+ expect(res.status).toBe(400);
+ });
+
+ it('rejects invalid name with 400 validation failure', async () => {
+ const res = await createSkillAsOwner({ name: 'BAD NAME' });
+ expect(res.status).toBe(400);
+ expect(res.body.issues).toBeDefined();
+ });
+
+ it('rejects duplicate names with 409', async () => {
+ const a = await createSkillAsOwner();
+ expect(a.status).toBe(201);
+ const b = await createSkillAsOwner();
+ expect(b.status).toBe(409);
+ });
+ });
+
+ describe('POST /api/skills/import', () => {
+ it('enforces fileConfig.skills.fileSizeLimit before import handling', async () => {
+ mockFileConfig = {
+ skills: {
+ fileSizeLimit: 1,
+ },
+ };
+
+ const res = await request(app)
+ .post('/api/skills/import')
+ .attach('file', Buffer.alloc(2 * 1024 * 1024), {
+ filename: 'too-large.skill',
+ contentType: 'application/zip',
+ });
+
+ const { mergeFileConfig } = require('librechat-data-provider');
+ expect(mergeFileConfig).toHaveBeenCalledWith(mockFileConfig);
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/file too large/i);
+ });
+
+ it('persists storage metadata for imported skill files', async () => {
+ const savedFilepath =
+ 'https://cdn.example.com/r/us-east-2/uploads/user123/imported-script.sh';
+ const saveBuffer = jest.fn().mockResolvedValue(savedFilepath);
+ const { getFileStrategy } = require('~/server/utils/getFileStrategy');
+ const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+ getFileStrategy.mockReturnValueOnce('cloudfront');
+ getStrategyFunctions.mockReturnValueOnce({ saveBuffer });
+
+ const zip = new JSZip();
+ zip.file(
+ 'SKILL.md',
+ [
+ '---',
+ 'name: imported-skill',
+ 'description: Imported skill description for route tests.',
+ '---',
+ '# Imported Skill',
+ ].join('\n'),
+ );
+ zip.file('scripts/imported-script.sh', 'echo imported');
+ const buffer = await zip.generateAsync({ type: 'nodebuffer' });
+
+ const res = await request(app).post('/api/skills/import').attach('file', buffer, {
+ filename: 'imported-skill.skill',
+ contentType: 'application/zip',
+ });
+
+ expect(res.status).toBe(201);
+ expect(saveBuffer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: testUsers.owner._id.toString(),
+ basePath: 'uploads',
+ }),
+ );
+
+ const savedFile = await SkillFile.findOne({
+ relativePath: 'scripts/imported-script.sh',
+ }).lean();
+ expect(savedFile).toEqual(
+ expect.objectContaining({
+ filepath: savedFilepath,
+ source: 'cloudfront',
+ storageKey: 'r/us-east-2/uploads/user123/imported-script.sh',
+ storageRegion: 'us-east-2',
+ }),
+ );
+ });
+ });
+
+ describe('GET /api/skills', () => {
+ it('returns only skills the caller can access', async () => {
+ const mine = await createSkillAsOwner({ name: 'mine-skill' });
+ expect(mine.status).toBe(201);
+
+ setTestUser(testUsers.noAccess);
+ const other = await createSkillAsOwner({ name: 'other-skill' });
+ expect(other.status).toBe(201);
+ // Note: the user middleware grants owner perms to whichever user created, so both
+ // users see their own skill only.
+
+ setTestUser(testUsers.owner);
+ const res = await request(app).get('/api/skills');
+ expect(res.status).toBe(200);
+ expect(mockMaybeRunGitHubSkillSyncForRequest).toHaveBeenCalledWith(
+ expect.objectContaining({
+ config: expect.objectContaining({ fileStrategy: 'local' }),
+ user: expect.objectContaining({ id: testUsers.owner._id.toString() }),
+ }),
+ );
+ expect(res.body.skills.length).toBe(1);
+ expect(res.body.skills[0].name).toBe('mine-skill');
+ });
+ });
+
+ describe('GET /api/skills/:id', () => {
+ it('returns 403 when the user has no access', async () => {
+ const created = await createSkillAsOwner();
+ expect(created.status).toBe(201);
+ setTestUser(testUsers.noAccess);
+ const res = await request(app).get(`/api/skills/${created.body._id}`);
+ expect(res.status).toBe(403);
+ });
+
+ it('returns the skill to the owner with isPublic flag', async () => {
+ const created = await createSkillAsOwner();
+ const res = await request(app).get(`/api/skills/${created.body._id}`);
+ expect(res.status).toBe(200);
+ expect(res.body.name).toBe('demo-skill');
+ expect(res.body.isPublic).toBe(false);
+ });
+ });
+
+ describe('PATCH /api/skills/:id (optimistic concurrency)', () => {
+ it('updates with correct expectedVersion and bumps version', async () => {
+ const created = await createSkillAsOwner();
+ const res = await request(app)
+ .patch(`/api/skills/${created.body._id}`)
+ .send({ expectedVersion: 1, description: 'Updated description' });
+ expect(res.status).toBe(200);
+ expect(res.body.version).toBe(2);
+ expect(res.body.description).toBe('Updated description');
+ });
+
+ it('returns 409 on stale expectedVersion', async () => {
+ const created = await createSkillAsOwner();
+ const first = await request(app)
+ .patch(`/api/skills/${created.body._id}`)
+ .send({ expectedVersion: 1, description: 'First' });
+ expect(first.status).toBe(200);
+
+ const stale = await request(app)
+ .patch(`/api/skills/${created.body._id}`)
+ .send({ expectedVersion: 1, description: 'Stale' });
+ expect(stale.status).toBe(409);
+ expect(stale.body.error).toBe('skill_version_conflict');
+ expect(stale.body.current.version).toBe(2);
+ });
+
+ it('rejects updates without expectedVersion', async () => {
+ const created = await createSkillAsOwner();
+ const res = await request(app)
+ .patch(`/api/skills/${created.body._id}`)
+ .send({ description: 'no version' });
+ expect(res.status).toBe(400);
+ });
+
+ it('returns 403 for a user without EDIT permission', async () => {
+ const created = await createSkillAsOwner();
+ setTestUser(testUsers.noAccess);
+ const res = await request(app)
+ .patch(`/api/skills/${created.body._id}`)
+ .send({ expectedVersion: 1, description: 'nope' });
+ expect(res.status).toBe(403);
+ });
+ });
+
+ describe('DELETE /api/skills/:id', () => {
+ it('deletes and cascades ACL entries', async () => {
+ const created = await createSkillAsOwner();
+ const res = await request(app).delete(`/api/skills/${created.body._id}`);
+ expect(res.status).toBe(200);
+ expect(res.body.deleted).toBe(true);
+
+ const remainingAcl = await AclEntry.countDocuments({
+ resourceType: ResourceType.SKILL,
+ resourceId: created.body._id,
+ });
+ expect(remainingAcl).toBe(0);
+ });
+
+ it('returns 403 for a non-owner', async () => {
+ const created = await createSkillAsOwner();
+ setTestUser(testUsers.noAccess);
+ const res = await request(app).delete(`/api/skills/${created.body._id}`);
+ expect(res.status).toBe(403);
+ });
+ });
+
+ describe('GET /api/skills/:id/files', () => {
+ it('returns an empty list for a skill with no files', async () => {
+ const created = await createSkillAsOwner();
+ const res = await request(app).get(`/api/skills/${created.body._id}/files`);
+ expect(res.status).toBe(200);
+ expect(res.body.files).toEqual([]);
+ });
+ });
+
+ describe('POST /api/skills/:id/files (live)', () => {
+ it('returns 400 when no file is provided', async () => {
+ const created = await createSkillAsOwner();
+ const res = await request(app).post(`/api/skills/${created.body._id}/files`);
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/no file/i);
+ });
+ });
+
+ describe('GET /api/skills/:id/files/:relativePath', () => {
+ it('returns SKILL.md content from skill body', async () => {
+ const created = await createSkillAsOwner();
+ const res = await request(app).get(`/api/skills/${created.body._id}/files/SKILL.md`);
+ expect(res.status).toBe(200);
+ expect(res.body.mimeType).toBe('text/markdown');
+ expect(res.body.isBinary).toBe(false);
+ expect(res.body.filename).toBe('SKILL.md');
+ expect(res.body.content).toBeDefined();
+ });
+
+ it('returns 404 for a nonexistent file', async () => {
+ const created = await createSkillAsOwner();
+ const res = await request(app).get(
+ `/api/skills/${created.body._id}/files/scripts%2Fmissing.sh`,
+ );
+ expect(res.status).toBe(404);
+ });
+ });
+
+ describe('DELETE /api/skills/:id/files/:relativePath', () => {
+ const { upsertSkillFile } = require('~/models');
+
+ it('deletes an existing skill file, bumps skill version, and returns 200', async () => {
+ const created = await createSkillAsOwner();
+ await upsertSkillFile({
+ skillId: created.body._id,
+ relativePath: 'scripts/parse.sh',
+ file_id: 'file-1',
+ filename: 'parse.sh',
+ filepath: '/tmp/parse.sh',
+ source: 'local',
+ mimeType: 'text/x-shellscript',
+ bytes: 42,
+ author: testUsers.owner._id,
+ });
+
+ const beforeSkill = await request(app).get(`/api/skills/${created.body._id}`);
+ expect(beforeSkill.body.fileCount).toBe(1);
+ expect(beforeSkill.body.version).toBe(2);
+
+ const res = await request(app).delete(
+ `/api/skills/${created.body._id}/files/scripts%2Fparse.sh`,
+ );
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({
+ skillId: created.body._id,
+ relativePath: 'scripts/parse.sh',
+ deleted: true,
+ });
+
+ const afterSkill = await request(app).get(`/api/skills/${created.body._id}`);
+ expect(afterSkill.body.fileCount).toBe(0);
+ expect(afterSkill.body.version).toBe(3);
+ });
+
+ it('returns 404 when the file does not exist', async () => {
+ const created = await createSkillAsOwner();
+ const res = await request(app).delete(
+ `/api/skills/${created.body._id}/files/scripts%2Fmissing.sh`,
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it('returns 403 for a non-owner', async () => {
+ const created = await createSkillAsOwner();
+ setTestUser(testUsers.noAccess);
+ const res = await request(app).delete(
+ `/api/skills/${created.body._id}/files/scripts%2Fparse.sh`,
+ );
+ expect(res.status).toBe(403);
+ });
+ });
+
+ describe('Sharing via ACL (editor grant)', () => {
+ it('allows an editor to patch a shared skill', async () => {
+ const created = await createSkillAsOwner();
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.editor._id,
+ resourceType: ResourceType.SKILL,
+ resourceId: created.body._id,
+ accessRoleId: AccessRoleIds.SKILL_EDITOR,
+ grantedBy: testUsers.owner._id,
+ });
+
+ setTestUser(testUsers.editor);
+ const res = await request(app)
+ .patch(`/api/skills/${created.body._id}`)
+ .send({ expectedVersion: 1, description: 'Edited by editor' });
+ expect(res.status).toBe(200);
+
+ // Editor should NOT be able to delete
+ const del = await request(app).delete(`/api/skills/${created.body._id}`);
+ expect(del.status).toBe(403);
+ });
+ });
+});
diff --git a/api/server/routes/static.js b/api/server/routes/static.js
new file mode 100644
index 0000000..12dfc0e
--- /dev/null
+++ b/api/server/routes/static.js
@@ -0,0 +1,11 @@
+const express = require('express');
+const { isEnabled } = require('@librechat/api');
+const staticCache = require('../utils/staticCache');
+const paths = require('~/config/paths');
+
+const skipGzipScan = !isEnabled(process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN);
+
+const router = express.Router();
+router.use(staticCache(paths.imageOutput, { skipGzipScan }));
+
+module.exports = router;
diff --git a/api/server/routes/tags.js b/api/server/routes/tags.js
new file mode 100644
index 0000000..a1fa1f7
--- /dev/null
+++ b/api/server/routes/tags.js
@@ -0,0 +1,124 @@
+const express = require('express');
+const { logger } = require('@librechat/data-schemas');
+const { generateCheckAccess } = require('@librechat/api');
+const { PermissionTypes, Permissions } = require('librechat-data-provider');
+const {
+ updateTagsForConversation,
+ updateConversationTag,
+ createConversationTag,
+ deleteConversationTag,
+ getConversationTags,
+ getRoleByName,
+} = require('~/models');
+const { requireJwtAuth } = require('~/server/middleware');
+
+const router = express.Router();
+
+const checkBookmarkAccess = generateCheckAccess({
+ permissionType: PermissionTypes.BOOKMARKS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+});
+
+router.use(requireJwtAuth);
+router.use(checkBookmarkAccess);
+
+/**
+ * GET /
+ * Retrieves all conversation tags for the authenticated user.
+ * @param {Object} req - Express request object
+ * @param {Object} res - Express response object
+ */
+router.get('/', async (req, res) => {
+ try {
+ const tags = await getConversationTags(req.user.id);
+ if (tags) {
+ res.status(200).json(tags);
+ } else {
+ res.status(404).end();
+ }
+ } catch (error) {
+ logger.error('Error getting conversation tags:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * POST /
+ * Creates a new conversation tag for the authenticated user.
+ * @param {Object} req - Express request object
+ * @param {Object} res - Express response object
+ */
+router.post('/', async (req, res) => {
+ try {
+ const tag = await createConversationTag(req.user.id, req.body);
+ res.status(200).json(tag);
+ } catch (error) {
+ logger.error('Error creating conversation tag:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * PUT /:tag
+ * Updates an existing conversation tag for the authenticated user.
+ * @param {Object} req - Express request object
+ * @param {Object} res - Express response object
+ */
+router.put('/:tag', async (req, res) => {
+ try {
+ const decodedTag = decodeURIComponent(req.params.tag);
+ const tag = await updateConversationTag(req.user.id, decodedTag, req.body);
+ if (tag) {
+ res.status(200).json(tag);
+ } else {
+ res.status(404).json({ error: 'Tag not found' });
+ }
+ } catch (error) {
+ logger.error('Error updating conversation tag:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * DELETE /:tag
+ * Deletes a conversation tag for the authenticated user.
+ * @param {Object} req - Express request object
+ * @param {Object} res - Express response object
+ */
+router.delete('/:tag', async (req, res) => {
+ try {
+ const decodedTag = decodeURIComponent(req.params.tag);
+ const tag = await deleteConversationTag(req.user.id, decodedTag);
+ if (tag) {
+ res.status(200).json(tag);
+ } else {
+ res.status(404).json({ error: 'Tag not found' });
+ }
+ } catch (error) {
+ logger.error('Error deleting conversation tag:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * PUT /convo/:conversationId
+ * Updates the tags for a conversation.
+ * @param {Object} req - Express request object
+ * @param {Object} res - Express response object
+ */
+router.put('/convo/:conversationId', async (req, res) => {
+ try {
+ const conversationTags = await updateTagsForConversation(
+ req.user.id,
+ req.params.conversationId,
+ req.body.tags,
+ );
+ res.status(200).json(conversationTags);
+ } catch (error) {
+ logger.error('Error updating conversation tags', error);
+ res.status(500).send('Error updating conversation tags');
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/types/assistants.js b/api/server/routes/types/assistants.js
new file mode 100644
index 0000000..974bf58
--- /dev/null
+++ b/api/server/routes/types/assistants.js
@@ -0,0 +1,53 @@
+/**
+ * Enum for the possible tools that can be enabled on an assistant.
+ * @readonly
+ * @enum {string}
+ */
+// eslint-disable-next-line no-unused-vars
+const Tools = {
+ code_interpreter: 'code_interpreter',
+ retrieval: 'retrieval',
+ function: 'function',
+};
+
+/**
+ * Represents a tool with its type.
+ * @typedef {Object} Tool
+ * @property {Tools} toolName - The name of the tool and its corresponding type from the Tools enum.
+ */
+
+/**
+ * @typedef {Object} Assistant
+ * @property {string} id - The identifier, which can be referenced in API endpoints.
+ * @property {number} created_at - The Unix timestamp (in seconds) for when the assistant was created.
+ * @property {string|null} description - The maximum length is 512 characters.
+ * @property {Array} file_ids - A list of file IDs attached to this assistant.
+ * @property {string|null} instructions - The system instructions that the assistant uses. The maximum length is 32768 characters.
+ * @property {Object|null} metadata - Set of 16 key-value pairs that can be attached to an object.
+ * @property {string} model - ID of the model to use.
+ * @property {string|null} name - The name of the assistant. The maximum length is 256 characters.
+ * @property {string} object - The object type, which is always 'assistant'.
+ * @property {Tool[]} tools - A list of tools enabled on the assistant.
+ */
+
+/**
+ * @typedef {Object} AssistantCreateParams
+ * @property {string} model - ID of the model to use.
+ * @property {string|null} [description] - The description of the assistant.
+ * @property {Array} [file_ids] - A list of file IDs attached to this assistant.
+ * @property {string|null} [instructions] - The system instructions that the assistant uses.
+ * @property {Object|null} [metadata] - Set of 16 key-value pairs that can be attached to an object.
+ * @property {string|null} [name] - The name of the assistant.
+ * @property {Tool[]} tools - A list of tools enabled on the assistant.
+ */
+
+/**
+ * @typedef {Object} AssistantUpdateParams
+ * // Similar properties to AssistantCreateParams, but all optional
+ */
+
+/**
+ * @typedef {Object} AssistantListParams
+ * @property {string|null} [before] - A cursor for use in pagination.
+ * @property {'asc'|'desc'} [order] - Sort order by the created_at timestamp of the objects.
+ */
diff --git a/api/server/routes/user.js b/api/server/routes/user.js
new file mode 100644
index 0000000..dbe42f1
--- /dev/null
+++ b/api/server/routes/user.js
@@ -0,0 +1,32 @@
+const express = require('express');
+const {
+ updateUserPluginsController,
+ resendVerificationController,
+ getTermsStatusController,
+ acceptTermsController,
+ verifyEmailController,
+ deleteUserController,
+ getUserController,
+} = require('~/server/controllers/UserController');
+const {
+ verifyEmailLimiter,
+ verifyEmailSubmissionLimiter,
+ configMiddleware,
+ canDeleteAccount,
+ requireJwtAuth,
+} = require('~/server/middleware');
+
+const settings = require('./settings');
+
+const router = express.Router();
+
+router.use('/settings', settings);
+router.get('/', requireJwtAuth, getUserController);
+router.get('/terms', requireJwtAuth, getTermsStatusController);
+router.post('/terms/accept', requireJwtAuth, acceptTermsController);
+router.post('/plugins', requireJwtAuth, updateUserPluginsController);
+router.delete('/delete', requireJwtAuth, canDeleteAccount, configMiddleware, deleteUserController);
+router.post('/verify', verifyEmailSubmissionLimiter, verifyEmailController);
+router.post('/verify/resend', verifyEmailLimiter, resendVerificationController);
+
+module.exports = router;
diff --git a/api/server/routes/user.verify-ratelimit.test.js b/api/server/routes/user.verify-ratelimit.test.js
new file mode 100644
index 0000000..c61ade5
--- /dev/null
+++ b/api/server/routes/user.verify-ratelimit.test.js
@@ -0,0 +1,71 @@
+const express = require('express');
+const request = require('supertest');
+
+const mockVerifyEmailSubmissionLimiter = jest.fn((req, res, next) => next());
+const mockVerifyEmailController = jest.fn((req, res) => res.status(204).end());
+
+jest.mock('~/server/controllers/UserController', () => ({
+ getUserController: jest.fn((req, res) => res.status(204).end()),
+ deleteUserController: jest.fn((req, res) => res.status(204).end()),
+ acceptTermsController: jest.fn((req, res) => res.status(204).end()),
+ verifyEmailController: (...args) => mockVerifyEmailController(...args),
+ getTermsStatusController: jest.fn((req, res) => res.status(204).end()),
+ updateUserPluginsController: jest.fn((req, res) => res.status(204).end()),
+ resendVerificationController: jest.fn((req, res) => res.status(204).end()),
+}));
+
+jest.mock('~/server/middleware', () => {
+ const pass = (req, res, next) => next();
+ return {
+ requireJwtAuth: pass,
+ canDeleteAccount: pass,
+ configMiddleware: pass,
+ verifyEmailLimiter: pass,
+ verifyEmailSubmissionLimiter: (...args) => mockVerifyEmailSubmissionLimiter(...args),
+ };
+});
+
+jest.mock('./settings', () => {
+ const express = require('express');
+ return express.Router();
+});
+
+const userRouter = require('./user');
+
+describe('POST /api/user/verify rate limiting', () => {
+ let app;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockVerifyEmailSubmissionLimiter.mockImplementation((req, res, next) => next());
+ mockVerifyEmailController.mockImplementation((req, res) => res.status(204).end());
+
+ app = express();
+ app.use(express.json());
+ app.use('/api/user', userRouter);
+ });
+
+ it('limits email verification before checking the token', async () => {
+ await request(app).post('/api/user/verify').send({ token: 'token' }).expect(204);
+
+ expect(mockVerifyEmailSubmissionLimiter).toHaveBeenCalledTimes(1);
+ expect(mockVerifyEmailController).toHaveBeenCalledTimes(1);
+ expect(mockVerifyEmailSubmissionLimiter.mock.invocationCallOrder[0]).toBeLessThan(
+ mockVerifyEmailController.mock.invocationCallOrder[0],
+ );
+ });
+
+ it('does not check the verification token after the limiter rejects the request', async () => {
+ mockVerifyEmailSubmissionLimiter.mockImplementation((req, res) =>
+ res.status(429).json({ message: 'Too many verification attempts' }),
+ );
+
+ const response = await request(app)
+ .post('/api/user/verify')
+ .send({ token: 'token' })
+ .expect(429);
+
+ expect(response.body).toEqual({ message: 'Too many verification attempts' });
+ expect(mockVerifyEmailController).not.toHaveBeenCalled();
+ });
+});
diff --git a/api/server/services/ActionService.js b/api/server/services/ActionService.js
new file mode 100644
index 0000000..db8a536
--- /dev/null
+++ b/api/server/services/ActionService.js
@@ -0,0 +1,512 @@
+const jwt = require('jsonwebtoken');
+const { nanoid } = require('nanoid');
+const { GraphEvents, sleep } = require('@librechat/agents');
+const { tool } = require('@librechat/agents/langchain/tools');
+const { logger, encryptV2, decryptV2 } = require('@librechat/data-schemas');
+const {
+ sendEvent,
+ logAxiosError,
+ refreshAccessToken,
+ GenerationJobManager,
+ createSSRFSafeAgents,
+ validateActionOAuthMetadata,
+} = require('@librechat/api');
+const {
+ Time,
+ CacheKeys,
+ StepTypes,
+ Constants,
+ AuthTypeEnum,
+ actionDelimiter,
+ isImageVisionTool,
+ actionDomainSeparator,
+} = require('librechat-data-provider');
+const {
+ findToken,
+ updateToken,
+ createToken,
+ getActions,
+ deleteActions,
+ deleteAssistant,
+} = require('~/models');
+const { getActionFlowStateManager } = require('~/config');
+const { getLogStores } = require('~/cache');
+
+const JWT_SECRET = process.env.JWT_SECRET;
+const toolNameRegex = /^[a-zA-Z0-9_-]+$/;
+const protocolRegex = /^https?:\/\//;
+const replaceSeparatorRegex = new RegExp(actionDomainSeparator, 'g');
+
+/**
+ * Validates tool name against regex pattern and updates if necessary.
+ * @param {object} params - The parameters for the function.
+ * @param {object} params.req - Express Request.
+ * @param {FunctionTool} params.tool - The tool object.
+ * @param {string} params.assistant_id - The assistant ID
+ * @returns {object|null} - Updated tool object or null if invalid and not an action.
+ */
+const validateAndUpdateTool = async ({ req, tool, assistant_id }) => {
+ let actions;
+ if (isImageVisionTool(tool)) {
+ return null;
+ }
+ if (!toolNameRegex.test(tool.function.name)) {
+ const [functionName, domain] = tool.function.name.split(actionDelimiter);
+ actions = await getActions({ assistant_id, user: req.user.id }, true);
+ const matchingActions = actions.filter((action) => {
+ const metadata = action.metadata;
+ if (!metadata) {
+ return false;
+ }
+ const strippedMetaDomain = stripProtocol(metadata.domain);
+ return strippedMetaDomain === domain || metadata.domain === domain;
+ });
+ const action = matchingActions[0];
+ if (!action) {
+ return null;
+ }
+
+ const parsedDomain = await domainParser(domain, true);
+
+ if (!parsedDomain) {
+ return null;
+ }
+
+ tool.function.name = `${functionName}${actionDelimiter}${parsedDomain}`;
+ }
+ return tool;
+};
+
+/** @param {string} domain */
+function stripProtocol(domain) {
+ const stripped = domain.replace(protocolRegex, '');
+ const pathIdx = stripped.indexOf('/');
+ return pathIdx === -1 ? stripped : stripped.substring(0, pathIdx);
+}
+
+/**
+ * Encodes a domain using the legacy scheme (full URL including protocol).
+ * Used for backward-compatible matching against agents saved before the collision fix.
+ * @param {string} domain
+ * @returns {string}
+ */
+function legacyDomainEncode(domain) {
+ if (!domain) {
+ return '';
+ }
+ if (domain.length <= Constants.ENCODED_DOMAIN_LENGTH) {
+ return domain.replace(/\./g, actionDomainSeparator);
+ }
+ const modifiedDomain = Buffer.from(domain).toString('base64');
+ return modifiedDomain.substring(0, Constants.ENCODED_DOMAIN_LENGTH);
+}
+
+/**
+ * Encodes or decodes a domain name to/from base64, or replacing periods with a custom separator.
+ *
+ * Necessary due to `[a-zA-Z0-9_-]*` Regex Validation, limited to a 64-character maximum.
+ * Strips protocol prefix before encoding to prevent base64 collisions
+ * (all `https://` URLs share the same 10-char base64 prefix).
+ *
+ * @param {string} domain - The domain name to encode/decode.
+ * @param {boolean} inverse - False to decode from base64, true to encode to base64.
+ * @returns {Promise} Encoded or decoded domain string.
+ */
+async function domainParser(domain, inverse = false) {
+ if (!domain) {
+ return;
+ }
+
+ const domainsCache = getLogStores(CacheKeys.ENCODED_DOMAINS);
+
+ if (inverse) {
+ const hostname = stripProtocol(domain);
+ const cachedDomain = await domainsCache.get(hostname);
+ if (cachedDomain) {
+ return hostname;
+ }
+
+ if (hostname.length <= Constants.ENCODED_DOMAIN_LENGTH) {
+ return hostname.replace(/\./g, actionDomainSeparator);
+ }
+
+ const modifiedDomain = Buffer.from(hostname).toString('base64');
+ const key = modifiedDomain.substring(0, Constants.ENCODED_DOMAIN_LENGTH);
+ await domainsCache.set(key, modifiedDomain);
+ return key;
+ }
+
+ const cachedDomain = await domainsCache.get(domain);
+ if (!cachedDomain) {
+ return domain.replace(replaceSeparatorRegex, '.');
+ }
+
+ try {
+ return Buffer.from(cachedDomain, 'base64').toString('utf-8');
+ } catch (error) {
+ logger.error(`Failed to parse domain (possibly not base64): ${domain}`, error);
+ return domain;
+ }
+}
+
+/**
+ * Loads action sets based on the user and assistant ID.
+ *
+ * @param {Object} searchParams - The parameters for loading action sets.
+ * @param {string} searchParams.user - The user identifier.
+ * @param {string} [searchParams.agent_id]- The agent identifier.
+ * @param {string} [searchParams.assistant_id]- The assistant identifier.
+ * @returns {Promise} A promise that resolves to an array of actions or `null` if no match.
+ */
+async function loadActionSets(searchParams) {
+ return await getActions(searchParams, true);
+}
+
+/**
+ * Creates a general tool for an entire action set.
+ *
+ * @param {Object} params - The parameters for loading action sets.
+ * @param {string} params.userId
+ * @param {ServerResponse} params.res
+ * @param {Action} params.action - The action set. Necessary for decrypting authentication values.
+ * @param {ActionRequest} params.requestBuilder - The ActionRequest builder class to execute the API call.
+ * @param {string | undefined} [params.name] - The name of the tool.
+ * @param {string | undefined} [params.description] - The description for the tool.
+ * @param {import('zod').ZodTypeAny | undefined} [params.zodSchema] - The Zod schema for tool input validation/definition
+ * @param {{ oauth_client_id?: string; oauth_client_secret?: string; }} params.encrypted - The encrypted values for the action.
+ * @param {string | null} [params.streamId] - The stream ID for resumable streams.
+ * @param {boolean} [params.useSSRFProtection] - When true, uses SSRF-safe HTTP agents that validate resolved IPs at connect time.
+ * @param {string[] | null} [params.allowedAddresses] - Optional admin exemption list of host:port pairs that bypass the SSRF private-IP block.
+ * @returns { Promise unknown}> } An object with `_call` method to execute the tool input.
+ */
+async function createActionTool({
+ userId,
+ res,
+ action,
+ requestBuilder,
+ zodSchema,
+ name,
+ description,
+ encrypted,
+ streamId = null,
+ useSSRFProtection = false,
+ allowedAddresses,
+}) {
+ const ssrfAgents = useSSRFProtection ? createSSRFSafeAgents(allowedAddresses) : undefined;
+ /** @type {(toolInput: Object | string, config: GraphRunnableConfig) => Promise} */
+ const _call = async (toolInput, config) => {
+ try {
+ /** @type {import('librechat-data-provider').ActionMetadataRuntime} */
+ const metadata = action.metadata;
+ const executor = requestBuilder.createExecutor();
+ const preparedExecutor = executor.setParams(toolInput ?? {});
+
+ if (metadata.auth && metadata.auth.type !== AuthTypeEnum.None) {
+ try {
+ if (metadata.auth.type === AuthTypeEnum.OAuth && metadata.auth.authorization_url) {
+ await validateActionOAuthMetadata(metadata.auth, allowedAddresses);
+
+ const action_id = action.action_id;
+ const identifier = `${userId}:${action.action_id}`;
+ const requestLogin = async () => {
+ const { args: _args, stepId, ...toolCall } = config.toolCall ?? {};
+ if (!stepId) {
+ throw new Error('Tool call is missing stepId');
+ }
+ const statePayload = {
+ nonce: nanoid(),
+ user: userId,
+ action_id,
+ };
+
+ const stateToken = jwt.sign(statePayload, JWT_SECRET, { expiresIn: '10m' });
+ try {
+ const redirectUri = `${process.env.DOMAIN_CLIENT}/api/actions/${action_id}/oauth/callback`;
+ const params = new URLSearchParams({
+ client_id: metadata.oauth_client_id,
+ scope: metadata.auth.scope,
+ redirect_uri: redirectUri,
+ access_type: 'offline',
+ response_type: 'code',
+ state: stateToken,
+ });
+
+ const authURL = `${metadata.auth.authorization_url}?${params.toString()}`;
+ /** @type {{ id: string; delta: AgentToolCallDelta }} */
+ const data = {
+ id: stepId,
+ delta: {
+ type: StepTypes.TOOL_CALLS,
+ tool_calls: [{ ...toolCall, args: '' }],
+ auth: authURL,
+ expires_at: Date.now() + Time.TWO_MINUTES,
+ },
+ };
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getActionFlowStateManager(flowsCache);
+ await flowManager.createFlowWithHandler(
+ `${identifier}:oauth_login:${config.metadata.thread_id}:${config.metadata.run_id}`,
+ 'oauth_login',
+ async () => {
+ const eventData = { event: GraphEvents.ON_RUN_STEP_DELTA, data };
+ if (streamId) {
+ await GenerationJobManager.emitChunk(streamId, eventData);
+ } else {
+ sendEvent(res, eventData);
+ }
+ logger.debug('Sent OAuth login request to client', { action_id, identifier });
+ return true;
+ },
+ config?.signal,
+ );
+ logger.debug('Waiting for OAuth Authorization response', { action_id, identifier });
+ const result = await flowManager.createFlow(
+ identifier,
+ 'oauth',
+ {
+ state: stateToken,
+ userId: userId,
+ client_url: metadata.auth.client_url,
+ redirect_uri: `${process.env.DOMAIN_SERVER}/api/actions/${action_id}/oauth/callback`,
+ token_exchange_method: metadata.auth.token_exchange_method,
+ allowedAddresses,
+ /** Encrypted values */
+ encrypted_oauth_client_id: encrypted.oauth_client_id,
+ encrypted_oauth_client_secret: encrypted.oauth_client_secret,
+ },
+ config?.signal,
+ );
+ logger.debug('Received OAuth Authorization response', { action_id, identifier });
+ data.delta.auth = undefined;
+ data.delta.expires_at = undefined;
+ const successEventData = { event: GraphEvents.ON_RUN_STEP_DELTA, data };
+ if (streamId) {
+ await GenerationJobManager.emitChunk(streamId, successEventData);
+ } else {
+ sendEvent(res, successEventData);
+ }
+ await sleep(3000);
+ metadata.oauth_access_token = result.access_token;
+ metadata.oauth_refresh_token = result.refresh_token;
+ const expiresAt = new Date(Date.now() + result.expires_in * 1000);
+ metadata.oauth_token_expires_at = expiresAt.toISOString();
+ } catch (error) {
+ const errorMessage = 'Failed to authenticate OAuth tool';
+ logger.error(errorMessage, error);
+ throw new Error(errorMessage);
+ }
+ };
+
+ const tokenPromises = [];
+ tokenPromises.push(findToken({ userId, type: 'oauth', identifier }));
+ tokenPromises.push(
+ findToken({
+ userId,
+ type: 'oauth_refresh',
+ identifier: `${identifier}:refresh`,
+ }),
+ );
+ const [tokenData, refreshTokenData] = await Promise.all(tokenPromises);
+
+ if (tokenData) {
+ // Valid token exists, add it to metadata for setAuth
+ metadata.oauth_access_token = await decryptV2(tokenData.token);
+ if (refreshTokenData) {
+ metadata.oauth_refresh_token = await decryptV2(refreshTokenData.token);
+ }
+ metadata.oauth_token_expires_at = tokenData.expiresAt.toISOString();
+ } else if (!refreshTokenData) {
+ // No tokens exist, need to authenticate
+ await requestLogin();
+ } else if (refreshTokenData) {
+ // Refresh token is still valid, use it to get new access token
+ try {
+ const refresh_token = await decryptV2(refreshTokenData.token);
+ const refreshTokens = async () =>
+ await refreshAccessToken(
+ {
+ userId,
+ identifier,
+ refresh_token,
+ client_url: metadata.auth.client_url,
+ encrypted_oauth_client_id: encrypted.oauth_client_id,
+ token_exchange_method: metadata.auth.token_exchange_method,
+ encrypted_oauth_client_secret: encrypted.oauth_client_secret,
+ allowedAddresses,
+ },
+ {
+ findToken,
+ updateToken,
+ createToken,
+ },
+ );
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getActionFlowStateManager(flowsCache);
+ const refreshData = await flowManager.createFlowWithHandler(
+ `${identifier}:refresh`,
+ 'oauth_refresh',
+ refreshTokens,
+ config?.signal,
+ );
+ metadata.oauth_access_token = refreshData.access_token;
+ if (refreshData.refresh_token) {
+ metadata.oauth_refresh_token = refreshData.refresh_token;
+ }
+ const expiresAt = new Date(Date.now() + refreshData.expires_in * 1000);
+ metadata.oauth_token_expires_at = expiresAt.toISOString();
+ } catch (error) {
+ logger.error('Failed to refresh token, requesting new login:', error);
+ await requestLogin();
+ }
+ } else {
+ await requestLogin();
+ }
+ }
+
+ await preparedExecutor.setAuth(metadata);
+ } catch (error) {
+ if (
+ error.message.includes('No access token found') ||
+ error.message.includes('Access token is expired')
+ ) {
+ throw error;
+ }
+ throw new Error(`Authentication failed: ${error.message}`);
+ }
+ }
+
+ const response = await preparedExecutor.execute(ssrfAgents);
+
+ if (typeof response.data === 'object') {
+ return JSON.stringify(response.data);
+ }
+ return response.data;
+ } catch (error) {
+ const message = `API call to ${action.metadata.domain} failed:`;
+ return logAxiosError({ message, error });
+ }
+ };
+
+ if (name) {
+ return tool(_call, {
+ name: name.replace(replaceSeparatorRegex, '_'),
+ description: description || '',
+ schema: zodSchema,
+ });
+ }
+
+ return {
+ _call,
+ };
+}
+
+/**
+ * Encrypts a sensitive value.
+ * @param {string} value
+ * @returns {Promise}
+ */
+async function encryptSensitiveValue(value) {
+ // Encode API key to handle special characters like ":"
+ const encodedValue = encodeURIComponent(value);
+ return await encryptV2(encodedValue);
+}
+
+/**
+ * Decrypts a sensitive value.
+ * @param {string} value
+ * @returns {Promise}
+ */
+async function decryptSensitiveValue(value) {
+ const decryptedValue = await decryptV2(value);
+ return decodeURIComponent(decryptedValue);
+}
+
+/**
+ * Encrypts sensitive metadata values for an action.
+ *
+ * @param {ActionMetadata} metadata - The action metadata to encrypt.
+ * @returns {Promise} The updated action metadata with encrypted values.
+ */
+async function encryptMetadata(metadata) {
+ const encryptedMetadata = { ...metadata };
+
+ // ServiceHttp
+ if (metadata.auth && metadata.auth.type === AuthTypeEnum.ServiceHttp) {
+ if (metadata.api_key) {
+ encryptedMetadata.api_key = await encryptSensitiveValue(metadata.api_key);
+ }
+ }
+
+ // OAuth
+ else if (metadata.auth && metadata.auth.type === AuthTypeEnum.OAuth) {
+ if (metadata.oauth_client_id) {
+ encryptedMetadata.oauth_client_id = await encryptSensitiveValue(metadata.oauth_client_id);
+ }
+ if (metadata.oauth_client_secret) {
+ encryptedMetadata.oauth_client_secret = await encryptSensitiveValue(
+ metadata.oauth_client_secret,
+ );
+ }
+ }
+
+ return encryptedMetadata;
+}
+
+/**
+ * Decrypts sensitive metadata values for an action.
+ *
+ * @param {ActionMetadata} metadata - The action metadata to decrypt.
+ * @returns {Promise} The updated action metadata with decrypted values.
+ */
+async function decryptMetadata(metadata) {
+ const decryptedMetadata = { ...metadata };
+
+ // ServiceHttp
+ if (metadata.auth && metadata.auth.type === AuthTypeEnum.ServiceHttp) {
+ if (metadata.api_key) {
+ decryptedMetadata.api_key = await decryptSensitiveValue(metadata.api_key);
+ }
+ }
+
+ // OAuth
+ else if (metadata.auth && metadata.auth.type === AuthTypeEnum.OAuth) {
+ if (metadata.oauth_client_id) {
+ decryptedMetadata.oauth_client_id = await decryptSensitiveValue(metadata.oauth_client_id);
+ }
+ if (metadata.oauth_client_secret) {
+ decryptedMetadata.oauth_client_secret = await decryptSensitiveValue(
+ metadata.oauth_client_secret,
+ );
+ }
+ }
+
+ return decryptedMetadata;
+}
+
+/**
+ * Deletes an action and its corresponding assistant.
+ * @param {Object} params - The parameters for the function.
+ * @param {OpenAIClient} params.req - The Express Request object.
+ * @param {string} params.assistant_id - The ID of the assistant.
+ */
+const deleteAssistantActions = async ({ req, assistant_id }) => {
+ try {
+ await deleteActions({ assistant_id, user: req.user.id });
+ await deleteAssistant({ assistant_id, user: req.user.id });
+ } catch (error) {
+ const message = 'Trouble deleting Assistant Actions for Assistant ID: ' + assistant_id;
+ logger.error(message, error);
+ throw new Error(message);
+ }
+};
+
+module.exports = {
+ deleteAssistantActions,
+ validateAndUpdateTool,
+ legacyDomainEncode,
+ createActionTool,
+ encryptMetadata,
+ decryptMetadata,
+ loadActionSets,
+ domainParser,
+};
diff --git a/api/server/services/ActionService.spec.js b/api/server/services/ActionService.spec.js
new file mode 100644
index 0000000..5241997
--- /dev/null
+++ b/api/server/services/ActionService.spec.js
@@ -0,0 +1,539 @@
+const { Constants, actionDelimiter, actionDomainSeparator } = require('librechat-data-provider');
+const { domainParser, legacyDomainEncode, validateAndUpdateTool } = require('./ActionService');
+
+jest.mock('keyv');
+
+jest.mock('~/models', () => ({
+ getActions: jest.fn(),
+ deleteActions: jest.fn(),
+}));
+
+const { getActions } = require('~/models');
+
+let mockDomainCache = {};
+jest.mock('~/cache/getLogStores', () => {
+ return jest.fn().mockImplementation(() => ({
+ get: async (key) => mockDomainCache[key] ?? null,
+ set: async (key, value) => {
+ mockDomainCache[key] = value;
+ return true;
+ },
+ }));
+});
+
+beforeEach(() => {
+ mockDomainCache = {};
+ getActions.mockReset();
+});
+
+const SEP = actionDomainSeparator;
+const DELIM = actionDelimiter;
+const MAX = Constants.ENCODED_DOMAIN_LENGTH;
+const domainSepRegex = new RegExp(SEP, 'g');
+
+describe('domainParser', () => {
+ describe('nullish input', () => {
+ it.each([null, undefined, ''])('returns undefined for %j', async (input) => {
+ expect(await domainParser(input, true)).toBeUndefined();
+ expect(await domainParser(input, false)).toBeUndefined();
+ });
+ });
+
+ describe('short-path encoding (hostname ≤ threshold)', () => {
+ it.each([
+ ['examp.com', `examp${SEP}com`],
+ ['swapi.tech', `swapi${SEP}tech`],
+ ['a.b', `a${SEP}b`],
+ ])('replaces dots in %s → %s', async (domain, expected) => {
+ expect(await domainParser(domain, true)).toBe(expected);
+ });
+
+ it('handles domain exactly at threshold length', async () => {
+ const domain = 'a'.repeat(MAX - 4) + '.com';
+ expect(domain).toHaveLength(MAX);
+ const result = await domainParser(domain, true);
+ expect(result).toBe(domain.replace(/\./g, SEP));
+ });
+ });
+
+ describe('base64-path encoding (hostname > threshold)', () => {
+ it('produces a key of exactly ENCODED_DOMAIN_LENGTH chars', async () => {
+ const result = await domainParser('api.example.com', true);
+ expect(result).toHaveLength(MAX);
+ });
+
+ it('encodes hostname, not full URL', async () => {
+ const hostname = 'api.example.com';
+ const expectedKey = Buffer.from(hostname).toString('base64').substring(0, MAX);
+ expect(await domainParser(hostname, true)).toBe(expectedKey);
+ });
+
+ it('populates decode cache for round-trip', async () => {
+ const hostname = 'longdomainname.com';
+ const key = await domainParser(hostname, true);
+
+ expect(mockDomainCache[key]).toBe(Buffer.from(hostname).toString('base64'));
+ expect(await domainParser(key, false)).toBe(hostname);
+ });
+ });
+
+ describe('protocol stripping', () => {
+ it('https:// URL and bare hostname produce identical encoding', async () => {
+ const encoded = await domainParser('https://swapi.tech', true);
+ expect(encoded).toBe(await domainParser('swapi.tech', true));
+ expect(encoded).toBe(`swapi${SEP}tech`);
+ });
+
+ it('http:// URL and bare hostname produce identical encoding', async () => {
+ const encoded = await domainParser('http://api.example.com', true);
+ expect(encoded).toBe(await domainParser('api.example.com', true));
+ });
+
+ it('different https:// domains produce unique keys', async () => {
+ const keys = await Promise.all([
+ domainParser('https://api.example.com', true),
+ domainParser('https://api.weather.com', true),
+ domainParser('https://data.github.com', true),
+ ]);
+ const unique = new Set(keys);
+ expect(unique.size).toBe(keys.length);
+ });
+
+ it('long hostname after stripping still uses base64 path', async () => {
+ const result = await domainParser('https://api.example.com', true);
+ expect(result).toHaveLength(MAX);
+ expect(result).not.toContain(SEP);
+ });
+
+ it('short hostname after stripping uses dot-replacement path', async () => {
+ const result = await domainParser('https://a.b.c', true);
+ expect(result).toBe(`a${SEP}b${SEP}c`);
+ });
+
+ it('strips path and query from full URL before encoding', async () => {
+ const result = await domainParser('https://api.example.com/v1/endpoint?foo=bar', true);
+ expect(result).toBe(await domainParser('api.example.com', true));
+ });
+ });
+
+ describe('unicode domains', () => {
+ it('encodes unicode hostname via base64 path', async () => {
+ const domain = 'täst.example.com';
+ const result = await domainParser(domain, true);
+ expect(result).toHaveLength(MAX);
+ expect(result).toBe(Buffer.from(domain).toString('base64').substring(0, MAX));
+ });
+
+ it('round-trips unicode hostname through encode then decode', async () => {
+ const domain = 'täst.example.com';
+ const key = await domainParser(domain, true);
+ expect(await domainParser(key, false)).toBe(domain);
+ });
+
+ it('strips protocol before encoding unicode hostname', async () => {
+ const withProto = 'https://täst.example.com';
+ const bare = 'täst.example.com';
+ expect(await domainParser(withProto, true)).toBe(await domainParser(bare, true));
+ });
+ });
+
+ describe('decode path', () => {
+ it('short-path encoded domain decodes via separator replacement', async () => {
+ expect(await domainParser(`examp${SEP}com`, false)).toBe('examp.com');
+ });
+
+ it('base64-path encoded domain decodes via cache lookup', async () => {
+ const hostname = 'api.example.com';
+ const key = await domainParser(hostname, true);
+ expect(await domainParser(key, false)).toBe(hostname);
+ });
+
+ it('returns input unchanged for unknown non-separator strings', async () => {
+ expect(await domainParser('not_base64_encoded', false)).toBe('not_base64_encoded');
+ });
+
+ it('returns a string without throwing for corrupt cache entries', async () => {
+ mockDomainCache['corrupt_key'] = '!!!';
+ const result = await domainParser('corrupt_key', false);
+ expect(typeof result).toBe('string');
+ });
+ });
+});
+
+describe('legacyDomainEncode', () => {
+ it.each(['', null, undefined])('returns empty string for %j', (input) => {
+ expect(legacyDomainEncode(input)).toBe('');
+ });
+
+ it('is synchronous (returns a string, not a Promise)', () => {
+ const result = legacyDomainEncode('examp.com');
+ expect(result).toBe(`examp${SEP}com`);
+ expect(result).not.toBeInstanceOf(Promise);
+ });
+
+ it('uses dot-replacement for short domains', () => {
+ expect(legacyDomainEncode('examp.com')).toBe(`examp${SEP}com`);
+ });
+
+ it('uses base64 prefix of full input for long domains', () => {
+ const domain = 'https://swapi.tech';
+ const expected = Buffer.from(domain).toString('base64').substring(0, MAX);
+ expect(legacyDomainEncode(domain)).toBe(expected);
+ });
+
+ it('all https:// URLs collide to the same key', () => {
+ const results = [
+ legacyDomainEncode('https://api.example.com'),
+ legacyDomainEncode('https://api.weather.com'),
+ legacyDomainEncode('https://totally.different.host'),
+ ];
+ expect(new Set(results).size).toBe(1);
+ });
+
+ it('matches what old domainParser would have produced', () => {
+ const domain = 'https://api.example.com';
+ const legacy = legacyDomainEncode(domain);
+ expect(legacy).toBe(Buffer.from(domain).toString('base64').substring(0, MAX));
+ });
+
+ it('produces same result as new domainParser for short bare hostnames', async () => {
+ const domain = 'swapi.tech';
+ expect(legacyDomainEncode(domain)).toBe(await domainParser(domain, true));
+ });
+});
+
+describe('validateAndUpdateTool', () => {
+ const mockReq = { user: { id: 'user123' } };
+
+ it('returns tool unchanged when name passes tool-name regex', async () => {
+ const tool = { function: { name: 'getPeople_action_swapi---tech' } };
+ const result = await validateAndUpdateTool({
+ req: mockReq,
+ tool,
+ assistant_id: 'asst_1',
+ });
+ expect(result).toEqual(tool);
+ expect(getActions).not.toHaveBeenCalled();
+ });
+
+ it('matches action when metadata.domain has https:// prefix and tool domain is bare hostname', async () => {
+ getActions.mockResolvedValue([{ metadata: { domain: 'https://api.example.com' } }]);
+
+ const tool = { function: { name: `getPeople${DELIM}api.example.com` } };
+ const result = await validateAndUpdateTool({
+ req: mockReq,
+ tool,
+ assistant_id: 'asst_1',
+ });
+
+ expect(result).not.toBeNull();
+ expect(result.function.name).toMatch(/^getPeople_action_/);
+ expect(result.function.name).not.toContain('.');
+ });
+
+ it('matches action when metadata.domain has no protocol', async () => {
+ getActions.mockResolvedValue([{ metadata: { domain: 'api.example.com' } }]);
+
+ const tool = { function: { name: `getPeople${DELIM}api.example.com` } };
+ const result = await validateAndUpdateTool({
+ req: mockReq,
+ tool,
+ assistant_id: 'asst_1',
+ });
+
+ expect(result).not.toBeNull();
+ expect(result.function.name).toMatch(/^getPeople_action_/);
+ });
+
+ it('returns null when no action matches the domain', async () => {
+ getActions.mockResolvedValue([{ metadata: { domain: 'https://other.domain.com' } }]);
+
+ const tool = { function: { name: `getPeople${DELIM}api.example.com` } };
+ const result = await validateAndUpdateTool({
+ req: mockReq,
+ tool,
+ assistant_id: 'asst_1',
+ });
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null when action has no metadata', async () => {
+ getActions.mockResolvedValue([{ metadata: null }]);
+
+ const tool = { function: { name: `getPeople${DELIM}api.example.com` } };
+ const result = await validateAndUpdateTool({
+ req: mockReq,
+ tool,
+ assistant_id: 'asst_1',
+ });
+
+ expect(result).toBeNull();
+ });
+});
+
+describe('backward-compatible tool name matching', () => {
+ function normalizeToolName(name) {
+ return name.replace(domainSepRegex, '_');
+ }
+
+ function buildToolName(functionName, encodedDomain) {
+ return `${functionName}${DELIM}${encodedDomain}`;
+ }
+
+ describe('definition-phase matching', () => {
+ it('new encoding matches agent tools stored with new encoding', async () => {
+ const metadataDomain = 'https://swapi.tech';
+ const encoded = await domainParser(metadataDomain, true);
+ const normalized = normalizeToolName(encoded);
+
+ const storedTool = buildToolName('getPeople', encoded);
+ const defToolName = `getPeople${DELIM}${normalized}`;
+
+ expect(normalizeToolName(storedTool)).toBe(defToolName);
+ });
+
+ it('legacy encoding matches agent tools stored with legacy encoding', async () => {
+ const metadataDomain = 'https://swapi.tech';
+ const legacy = legacyDomainEncode(metadataDomain);
+ const legacyNormalized = normalizeToolName(legacy);
+
+ const storedTool = buildToolName('getPeople', legacy);
+ const legacyDefName = `getPeople${DELIM}${legacyNormalized}`;
+
+ expect(normalizeToolName(storedTool)).toBe(legacyDefName);
+ });
+
+ it('new definition matches old stored tools via legacy fallback', async () => {
+ const metadataDomain = 'https://swapi.tech';
+ const newDomain = await domainParser(metadataDomain, true);
+ const legacyDomain = legacyDomainEncode(metadataDomain);
+ const newNorm = normalizeToolName(newDomain);
+ const legacyNorm = normalizeToolName(legacyDomain);
+
+ const oldStoredTool = buildToolName('getPeople', legacyDomain);
+ const newToolName = `getPeople${DELIM}${newNorm}`;
+ const legacyToolName = `getPeople${DELIM}${legacyNorm}`;
+
+ const storedNormalized = normalizeToolName(oldStoredTool);
+ const hasMatch = storedNormalized === newToolName || storedNormalized === legacyToolName;
+ expect(hasMatch).toBe(true);
+ });
+
+ it('pre-normalized Set eliminates per-tool normalization', async () => {
+ const metadataDomain = 'https://api.example.com';
+ const domain = await domainParser(metadataDomain, true);
+ const legacyDomain = legacyDomainEncode(metadataDomain);
+ const normalizedDomain = normalizeToolName(domain);
+ const legacyNormalized = normalizeToolName(legacyDomain);
+
+ const storedTools = [
+ buildToolName('getWeather', legacyDomain),
+ buildToolName('getForecast', domain),
+ ];
+
+ const preNormalized = new Set(storedTools.map((t) => normalizeToolName(t)));
+
+ const toolName = `getWeather${DELIM}${normalizedDomain}`;
+ const legacyToolName = `getWeather${DELIM}${legacyNormalized}`;
+ expect(preNormalized.has(toolName) || preNormalized.has(legacyToolName)).toBe(true);
+ });
+ });
+
+ describe('execution-phase tool lookup', () => {
+ it('model-called tool name resolves via normalizedToDomain map (new encoding)', async () => {
+ const metadataDomain = 'https://api.example.com';
+ const domain = await domainParser(metadataDomain, true);
+ const normalized = normalizeToolName(domain);
+
+ const normalizedToDomain = new Map();
+ normalizedToDomain.set(normalized, domain);
+
+ const modelToolName = `getWeather${DELIM}${normalized}`;
+
+ let matched = '';
+ for (const [norm, canonical] of normalizedToDomain.entries()) {
+ if (modelToolName.includes(norm)) {
+ matched = canonical;
+ break;
+ }
+ }
+
+ expect(matched).toBe(domain);
+
+ const functionName = modelToolName.replace(`${DELIM}${normalizeToolName(matched)}`, '');
+ expect(functionName).toBe('getWeather');
+ });
+
+ it('model-called tool name resolves via legacy entry in normalizedToDomain map', async () => {
+ const metadataDomain = 'https://api.example.com';
+ const domain = await domainParser(metadataDomain, true);
+ const legacyDomain = legacyDomainEncode(metadataDomain);
+ const legacyNorm = normalizeToolName(legacyDomain);
+
+ const normalizedToDomain = new Map();
+ normalizedToDomain.set(normalizeToolName(domain), domain);
+ normalizedToDomain.set(legacyNorm, domain);
+
+ const legacyModelToolName = `getWeather${DELIM}${legacyNorm}`;
+
+ let matched = '';
+ for (const [norm, canonical] of normalizedToDomain.entries()) {
+ if (legacyModelToolName.includes(norm)) {
+ matched = canonical;
+ break;
+ }
+ }
+
+ expect(matched).toBe(domain);
+ });
+
+ it('legacy guard skips duplicate map entry for short bare hostnames', async () => {
+ const domain = 'swapi.tech';
+ const newEncoding = await domainParser(domain, true);
+ const legacyEncoding = legacyDomainEncode(domain);
+
+ expect(newEncoding).toBe(legacyEncoding);
+
+ const normalizedToDomain = new Map();
+ normalizedToDomain.set(newEncoding, newEncoding);
+ if (legacyEncoding !== newEncoding) {
+ normalizedToDomain.set(legacyEncoding, newEncoding);
+ }
+ expect(normalizedToDomain.size).toBe(1);
+ });
+ });
+
+ describe('processRequiredActions matching (assistants path)', () => {
+ it('legacy tool from OpenAI matches via normalizedToDomain with both encodings', async () => {
+ const metadataDomain = 'https://swapi.tech';
+ const domain = await domainParser(metadataDomain, true);
+ const legacyDomain = legacyDomainEncode(metadataDomain);
+
+ const normalizedToDomain = new Map();
+ normalizedToDomain.set(domain, domain);
+ if (legacyDomain !== domain) {
+ normalizedToDomain.set(legacyDomain, domain);
+ }
+
+ const legacyToolName = buildToolName('getPeople', legacyDomain);
+
+ let currentDomain = '';
+ let matchedKey = '';
+ for (const [key, canonical] of normalizedToDomain.entries()) {
+ if (legacyToolName.includes(key)) {
+ currentDomain = canonical;
+ matchedKey = key;
+ break;
+ }
+ }
+
+ expect(currentDomain).toBe(domain);
+ expect(matchedKey).toBe(legacyDomain);
+
+ const functionName = legacyToolName.replace(`${DELIM}${matchedKey}`, '');
+ expect(functionName).toBe('getPeople');
+ });
+
+ it('new tool name matches via the canonical domain key', async () => {
+ const metadataDomain = 'https://swapi.tech';
+ const domain = await domainParser(metadataDomain, true);
+ const legacyDomain = legacyDomainEncode(metadataDomain);
+
+ const normalizedToDomain = new Map();
+ normalizedToDomain.set(domain, domain);
+ if (legacyDomain !== domain) {
+ normalizedToDomain.set(legacyDomain, domain);
+ }
+
+ const newToolName = buildToolName('getPeople', domain);
+
+ let currentDomain = '';
+ let matchedKey = '';
+ for (const [key, canonical] of normalizedToDomain.entries()) {
+ if (newToolName.includes(key)) {
+ currentDomain = canonical;
+ matchedKey = key;
+ break;
+ }
+ }
+
+ expect(currentDomain).toBe(domain);
+ expect(matchedKey).toBe(domain);
+
+ const functionName = newToolName.replace(`${DELIM}${matchedKey}`, '');
+ expect(functionName).toBe('getPeople');
+ });
+ });
+
+ describe('save-route cleanup', () => {
+ it('tool filter removes tools matching new encoding', async () => {
+ const metadataDomain = 'https://swapi.tech';
+ const domain = await domainParser(metadataDomain, true);
+ const legacyDomain = legacyDomainEncode(metadataDomain);
+
+ const tools = [
+ buildToolName('getPeople', domain),
+ buildToolName('unrelated', 'other---domain'),
+ ];
+
+ const filtered = tools.filter((t) => !t.includes(domain) && !t.includes(legacyDomain));
+
+ expect(filtered).toEqual([buildToolName('unrelated', 'other---domain')]);
+ });
+
+ it('tool filter removes tools matching legacy encoding', async () => {
+ const metadataDomain = 'https://swapi.tech';
+ const domain = await domainParser(metadataDomain, true);
+ const legacyDomain = legacyDomainEncode(metadataDomain);
+
+ const tools = [
+ buildToolName('getPeople', legacyDomain),
+ buildToolName('unrelated', 'other---domain'),
+ ];
+
+ const filtered = tools.filter((t) => !t.includes(domain) && !t.includes(legacyDomain));
+
+ expect(filtered).toEqual([buildToolName('unrelated', 'other---domain')]);
+ });
+ });
+
+ describe('delete-route domain extraction', () => {
+ it('domain extracted from actions array is usable as-is for tool filtering', async () => {
+ const metadataDomain = 'https://api.example.com';
+ const domain = await domainParser(metadataDomain, true);
+ const actionId = 'abc123';
+ const actionEntry = `${domain}${DELIM}${actionId}`;
+
+ const [storedDomain] = actionEntry.split(DELIM);
+ expect(storedDomain).toBe(domain);
+
+ const tools = [buildToolName('getWeather', domain), buildToolName('getPeople', 'other')];
+
+ const filtered = tools.filter((t) => !t.includes(storedDomain));
+ expect(filtered).toEqual([buildToolName('getPeople', 'other')]);
+ });
+ });
+
+ describe('multi-action agents (collision scenario)', () => {
+ it('two https:// actions now produce distinct tool names', async () => {
+ const domain1 = await domainParser('https://api.weather.com', true);
+ const domain2 = await domainParser('https://api.spacex.com', true);
+
+ const tool1 = buildToolName('getData', domain1);
+ const tool2 = buildToolName('getData', domain2);
+
+ expect(tool1).not.toBe(tool2);
+ });
+
+ it('two https:// actions used to collide in legacy encoding', () => {
+ const legacy1 = legacyDomainEncode('https://api.weather.com');
+ const legacy2 = legacyDomainEncode('https://api.spacex.com');
+
+ const tool1 = buildToolName('getData', legacy1);
+ const tool2 = buildToolName('getData', legacy2);
+
+ expect(tool1).toBe(tool2);
+ });
+ });
+});
diff --git a/api/server/services/Agents/ownerContact.js b/api/server/services/Agents/ownerContact.js
new file mode 100644
index 0000000..f73e704
--- /dev/null
+++ b/api/server/services/Agents/ownerContact.js
@@ -0,0 +1,87 @@
+const { logger } = require('@librechat/data-schemas');
+const { ResourceType, PrincipalType, PermissionBits } = require('librechat-data-provider');
+const { hasSupportContact, resolveAgentOwnerContact } = require('@librechat/api');
+const db = require('~/models');
+
+const OWNER_PERMISSION_BITS =
+ PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE;
+
+const getFirstOwnerIdsByResource = async (agents) => {
+ const resourceIds = agents
+ .filter((agent) => !hasSupportContact(agent))
+ .map((agent) => agent?._id)
+ .filter(Boolean);
+
+ if (resourceIds.length === 0) {
+ return new Map();
+ }
+
+ try {
+ const entries = await db.aggregateAclEntries([
+ {
+ $match: {
+ resourceType: ResourceType.AGENT,
+ resourceId: { $in: resourceIds },
+ principalType: PrincipalType.USER,
+ permBits: OWNER_PERMISSION_BITS,
+ },
+ },
+ { $sort: { grantedAt: 1, createdAt: 1, _id: 1 } },
+ { $group: { _id: '$resourceId', principalId: { $first: '$principalId' } } },
+ ]);
+
+ return new Map(
+ entries
+ .map((entry) => [entry?._id?.toString(), entry?.principalId?.toString()])
+ .filter(([resourceId, ownerId]) => resourceId && ownerId),
+ );
+ } catch (error) {
+ logger.warn('[/Agents] Failed to resolve agent owner ACL entries', error);
+ return new Map();
+ }
+};
+
+const attachOwnerContacts = async (agents) => {
+ if (!Array.isArray(agents) || agents.length === 0) {
+ return agents;
+ }
+
+ const ownerIdsByResource = await getFirstOwnerIdsByResource(agents);
+ const ownerIds = [
+ ...new Set(
+ agents
+ .filter((agent) => !hasSupportContact(agent))
+ .map((agent) => ownerIdsByResource.get(agent?._id?.toString()) ?? agent?.author?.toString())
+ .filter(Boolean),
+ ),
+ ];
+
+ let ownersById = new Map();
+ if (ownerIds.length > 0) {
+ try {
+ const users = await db.findUsers({ _id: { $in: ownerIds } }, 'name username email');
+ ownersById = new Map(users.map((user) => [user?._id?.toString(), user]));
+ } catch (error) {
+ logger.warn('[/Agents] Failed to resolve agent owner users', error);
+ }
+ }
+
+ return agents.map((agent) => {
+ if (hasSupportContact(agent)) {
+ delete agent.owner_contact;
+ return agent;
+ }
+ const ownerId = ownerIdsByResource.get(agent?._id?.toString()) ?? agent?.author?.toString();
+ const ownerContact = resolveAgentOwnerContact(agent, ownersById.get(ownerId) ?? null);
+ if (ownerContact) {
+ agent.owner_contact = ownerContact;
+ } else {
+ delete agent.owner_contact;
+ }
+ return agent;
+ });
+};
+
+module.exports = {
+ attachOwnerContacts,
+};
diff --git a/api/server/services/Artifacts/update.js b/api/server/services/Artifacts/update.js
new file mode 100644
index 0000000..11831c9
--- /dev/null
+++ b/api/server/services/Artifacts/update.js
@@ -0,0 +1,13 @@
+const {
+ ARTIFACT_START,
+ ARTIFACT_END,
+ findAllArtifacts,
+ replaceArtifactContent,
+} = require('@librechat/api');
+
+module.exports = {
+ ARTIFACT_START,
+ ARTIFACT_END,
+ findAllArtifacts,
+ replaceArtifactContent,
+};
diff --git a/api/server/services/Artifacts/update.spec.js b/api/server/services/Artifacts/update.spec.js
new file mode 100644
index 0000000..407ad2d
--- /dev/null
+++ b/api/server/services/Artifacts/update.spec.js
@@ -0,0 +1,970 @@
+const {
+ ARTIFACT_START,
+ ARTIFACT_END,
+ findAllArtifacts,
+ replaceArtifactContent,
+} = require('./update');
+
+const createArtifactText = (options = {}) => {
+ const { content = '', wrapCode = true, isClosed = true, prefix = '', suffix = '' } = options;
+
+ const codeBlock = wrapCode ? '```\n' + content + '\n```' : content;
+ const end = isClosed ? `\n${ARTIFACT_END}` : '';
+
+ return `${ARTIFACT_START}${prefix}\n${codeBlock}${end}${suffix}`;
+};
+
+describe('findAllArtifacts', () => {
+ test('should return empty array for message with no artifacts', () => {
+ const message = {
+ content: [
+ {
+ type: 'text',
+ text: 'No artifacts here',
+ },
+ ],
+ };
+ expect(findAllArtifacts(message)).toEqual([]);
+ });
+
+ test('should find artifacts in content parts', () => {
+ const message = {
+ content: [
+ { type: 'text', text: createArtifactText({ content: 'content1' }) },
+ { type: 'text', text: createArtifactText({ content: 'content2' }) },
+ ],
+ };
+
+ const result = findAllArtifacts(message);
+ expect(result).toHaveLength(2);
+ expect(result[0].source).toBe('content');
+ expect(result[1].partIndex).toBe(1);
+ });
+
+ test('should find artifacts in message.text when content is empty', () => {
+ const artifact1 = createArtifactText({ content: 'text1' });
+ const artifact2 = createArtifactText({ content: 'text2' });
+ const message = { text: [artifact1, artifact2].join('\n') };
+
+ const result = findAllArtifacts(message);
+ expect(result).toHaveLength(2);
+ expect(result[0].source).toBe('text');
+ });
+
+ test('should handle unclosed artifacts', () => {
+ const message = {
+ text: createArtifactText({ content: 'unclosed', isClosed: false }),
+ };
+ const result = findAllArtifacts(message);
+ expect(result[0].end).toBe(message.text.length);
+ });
+
+ test('should handle multiple artifacts in single part', () => {
+ const artifact1 = createArtifactText({ content: 'first' });
+ const artifact2 = createArtifactText({ content: 'second' });
+ const message = {
+ content: [
+ {
+ type: 'text',
+ text: [artifact1, artifact2].join('\n'),
+ },
+ ],
+ };
+
+ const result = findAllArtifacts(message);
+ expect(result).toHaveLength(2);
+ expect(result[1].start).toBeGreaterThan(result[0].end);
+ });
+
+ test('should ignore artifact close markers inside fenced content', () => {
+ const content = 'before\n:::\nafter';
+ const artifactText = `${ARTIFACT_START}{identifier="markdown" type="text/markdown" title="Markdown"}
+\`\`\`markdown
+${content}
+\`\`\`
+${ARTIFACT_END}`;
+ const message = { text: `${artifactText}\ntrailer` };
+
+ const result = findAllArtifacts(message);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].end).toBe(artifactText.length);
+ });
+
+ test('should allow trailing text after an artifact close marker', () => {
+ const artifactText = `${ARTIFACT_START}{identifier="plain" type="text/plain" title="Plain"}
+content
+${ARTIFACT_END}Thanks for reading`;
+ const message = { text: `${artifactText}\n${createArtifactText({ content: 'next' })}` };
+
+ const result = findAllArtifacts(message);
+
+ expect(result).toHaveLength(2);
+ expect(message.text.slice(result[0].end, result[0].end + 18)).toBe('Thanks for reading');
+ });
+
+ test('should not end an unclosed artifact at an internal marker in a closed fence', () => {
+ const artifactText = `${ARTIFACT_START}{identifier="markdown" type="text/markdown" title="Markdown"}
+\`\`\`markdown
+before
+:::
+after
+\`\`\`
+trailer`;
+ const message = { text: artifactText };
+
+ const result = findAllArtifacts(message);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].end).toBe(artifactText.length);
+ });
+
+ test('should keep artifact start markers inside fenced content', () => {
+ const artifactText = `${ARTIFACT_START}{identifier="markdown" type="text/markdown" title="Markdown"}
+\`\`\`markdown
+before
+:::
+:::artifact{identifier="sample" type="text/plain" title="Sample"}
+after
+\`\`\`
+${ARTIFACT_END}`;
+ const message = { text: artifactText };
+
+ const result = findAllArtifacts(message);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].end).toBe(artifactText.length);
+ });
+
+ test('should preserve markdown artifacts wrapped in longer fences with internal code blocks', () => {
+ const content = `# Title
+
+\`\`\`bash
+echo one
+\`\`\`
+Text between sections.
+## Section B
+\`\`\`bash
+echo two
+\`\`\``;
+ const artifactText = `${ARTIFACT_START}{identifier="git-cheatsheet" type="text/markdown" title="Git Cheatsheet"}
+\`\`\`\`markdown
+${content}
+\`\`\`\`
+${ARTIFACT_END}`;
+ const message = { text: `${artifactText}\ntrailer` };
+
+ const result = findAllArtifacts(message);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].end).toBe(artifactText.length);
+ });
+
+ test('should preserve the first fallback close in an unclosed fence', () => {
+ const firstArtifact = `${ARTIFACT_START}{identifier="first" type="text/html" title="First"}
+\`\`\`html
+first
+${ARTIFACT_END}`;
+ const secondArtifact = createArtifactText({
+ content: 'second
',
+ wrapCode: false,
+ prefix: '{identifier="second" type="text/html" title="Second"}',
+ });
+ const message = { text: `${firstArtifact}\n${secondArtifact}` };
+
+ const result = findAllArtifacts(message);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].end).toBe(firstArtifact.length);
+ expect(result[1].start).toBe(firstArtifact.length + 1);
+ });
+});
+
+describe('replaceArtifactContent', () => {
+ const createTestArtifact = (content, options) => {
+ const text = createArtifactText({ content, ...options });
+ return {
+ start: 0,
+ end: text.length,
+ text,
+ source: 'text',
+ };
+ };
+
+ test('should replace content within artifact boundaries', () => {
+ const original = "console.log('hello')";
+ const artifact = createTestArtifact(original);
+ const updated = "console.log('updated')";
+
+ const result = replaceArtifactContent(artifact.text, artifact, original, updated);
+ expect(result).toContain(updated);
+ expect(result).toMatch(ARTIFACT_START);
+ expect(result).toMatch(ARTIFACT_END);
+ });
+
+ test('should return null when original not found', () => {
+ const artifact = createTestArtifact('function test() {}');
+ const result = replaceArtifactContent(artifact.text, artifact, 'missing', 'updated');
+ expect(result).toBeNull();
+ });
+
+ test('should handle dedented content', () => {
+ const original = 'function test() {';
+ const artifact = createTestArtifact(original);
+ const updated = 'function updated() {';
+
+ const result = replaceArtifactContent(artifact.text, artifact, original, updated);
+ expect(result).toContain(updated);
+ });
+
+ test('should preserve text outside artifact', () => {
+ const artifactContent = createArtifactText({ content: 'original' });
+ const fullText = `prefix\n${artifactContent}\nsuffix`;
+ const artifact = createTestArtifact('original', {
+ prefix: 'prefix\n',
+ suffix: '\nsuffix',
+ });
+
+ const result = replaceArtifactContent(fullText, artifact, 'original', 'updated');
+ expect(result).toMatch(/^prefix/);
+ expect(result).toMatch(/suffix$/);
+ });
+
+ test('should handle replacement at artifact boundaries', () => {
+ const original = 'console.log("hello")';
+ const updated = 'console.log("updated")';
+
+ const artifactText = `${ARTIFACT_START}\n${original}\n${ARTIFACT_END}`;
+ const artifact = {
+ start: 0,
+ end: artifactText.length,
+ text: artifactText,
+ source: 'text',
+ };
+
+ const result = replaceArtifactContent(artifactText, artifact, original, updated);
+
+ expect(result).toBe(`${ARTIFACT_START}\n${updated}\n${ARTIFACT_END}`);
+ });
+
+ test('should replace markdown artifacts with internal code fences', () => {
+ const original = `# Notes
+
+\`\`\`js
+console.log('inside');
+\`\`\`
+
+Done`;
+ const updated = original.replace('Done', 'Updated');
+ const artifactText = `${ARTIFACT_START}{identifier="notes" type="text/markdown" title="Notes"}
+${original}
+${ARTIFACT_END}`;
+ const message = { text: artifactText };
+ const artifacts = findAllArtifacts(message);
+
+ const result = replaceArtifactContent(artifactText, artifacts[0], original, updated);
+
+ expect(result).not.toBeNull();
+ expect(result).toContain('Updated');
+ expect(result).toContain("console.log('inside');");
+ });
+
+ test('should replace markdown artifacts wrapped in longer fences with internal code blocks', () => {
+ const original = `# Notes
+
+\`\`\`bash
+echo one
+\`\`\`
+
+## More
+\`\`\`bash
+echo two
+\`\`\``;
+ const updated = original.replace('## More', '## Updated');
+ const artifactText = `${ARTIFACT_START}{identifier="notes" type="text/markdown" title="Notes"}
+\`\`\`\`markdown
+${original}
+\`\`\`\`
+${ARTIFACT_END}`;
+ const message = { text: artifactText };
+ const artifacts = findAllArtifacts(message);
+
+ const result = replaceArtifactContent(artifactText, artifacts[0], original, updated);
+
+ expect(result).not.toBeNull();
+ expect(result).toContain('## Updated');
+ expect(result).toContain('```bash');
+ expect(result).toContain('````markdown');
+ });
+
+ test('should normalize editor trailing newlines before longer closing fences', () => {
+ const original = '# Notes';
+ const artifactText = `${ARTIFACT_START}{identifier="notes" type="text/markdown" title="Notes"}
+\`\`\`\`markdown
+${original}
+\`\`\`\`
+${ARTIFACT_END}`;
+ const message = { text: artifactText };
+ const artifacts = findAllArtifacts(message);
+
+ const result = replaceArtifactContent(artifactText, artifacts[0], original, `${original}\n`);
+
+ expect(result).toBe(artifactText);
+ });
+
+ test('should replace unclosed artifacts with internal markers in fenced content', () => {
+ const original = `before
+\`\`\`markdown
+inside
+:::
+still inside
+\`\`\`
+after`;
+ const artifactText = `${ARTIFACT_START}{identifier="notes" type="text/markdown" title="Notes"}
+${original}`;
+ const message = { text: artifactText };
+ const artifacts = findAllArtifacts(message);
+ const updated = original.replace('after', 'updated');
+
+ const result = replaceArtifactContent(artifactText, artifacts[0], original, updated);
+
+ expect(result).not.toBeNull();
+ expect(result).toContain('updated');
+ expect(result).toContain(':::');
+ });
+});
+
+describe('replaceArtifactContent with shared text', () => {
+ test('should replace correct artifact when text is shared', () => {
+ const artifactContent = ' hi '; // Preserve exact spacing
+ const sharedText = `LOREM IPSUM
+
+:::artifact{identifier="calculator" type="application/vnd.react" title="Calculator"}
+\`\`\`
+${artifactContent}
+\`\`\`
+:::
+
+LOREM IPSUM
+
+:::artifact{identifier="calculator2" type="application/vnd.react" title="Calculator"}
+\`\`\`
+${artifactContent}
+\`\`\`
+:::`;
+
+ const message = { text: sharedText };
+ const artifacts = findAllArtifacts(message);
+ expect(artifacts).toHaveLength(2);
+
+ const targetArtifact = artifacts[1];
+ const updatedContent = ' updated content ';
+ const result = replaceArtifactContent(
+ sharedText,
+ targetArtifact,
+ artifactContent,
+ updatedContent,
+ );
+
+ // Verify exact matches with preserved formatting
+ expect(result).toContain(artifactContent); // First artifact unchanged
+ expect(result).toContain(updatedContent); // Second artifact updated
+ expect(result.indexOf(updatedContent)).toBeGreaterThan(result.indexOf(artifactContent));
+ });
+
+ const codeExample = `
+function greetPerson(name) {
+ return \`Hello, \${name}! Welcome to JavaScript programming.\`;
+}
+
+const personName = "Alice";
+const greeting = greetPerson(personName);
+console.log(greeting);`;
+
+ test('should handle random number of artifacts in content array', () => {
+ const numArtifacts = 5; // Fixed number for predictability
+ const targetIndex = 2; // Fixed target for predictability
+
+ // Create content array with multiple parts
+ const contentParts = Array.from({ length: numArtifacts }, (_, i) => ({
+ type: 'text',
+ text: createArtifactText({
+ content: `content-${i}`,
+ wrapCode: true,
+ prefix: i > 0 ? '\n' : '',
+ }),
+ }));
+
+ const message = { content: contentParts };
+ const artifacts = findAllArtifacts(message);
+ expect(artifacts).toHaveLength(numArtifacts);
+
+ const targetArtifact = artifacts[targetIndex];
+ const originalContent = `content-${targetIndex}`;
+ const updatedContent = 'updated-content';
+
+ const result = replaceArtifactContent(
+ contentParts[targetIndex].text,
+ targetArtifact,
+ originalContent,
+ updatedContent,
+ );
+
+ // Verify the specific content was updated
+ expect(result).toContain(updatedContent);
+ expect(result).not.toContain(originalContent);
+ expect(result).toMatch(
+ new RegExp(`${ARTIFACT_START}.*${updatedContent}.*${ARTIFACT_END}`, 's'),
+ );
+ });
+
+ test('should handle artifacts with identical content but different metadata in content array', () => {
+ const contentParts = [
+ {
+ type: 'text',
+ text: createArtifactText({
+ wrapCode: true,
+ content: codeExample,
+ prefix: '{id="1", title="First"}',
+ }),
+ },
+ {
+ type: 'text',
+ text: createArtifactText({
+ wrapCode: true,
+ content: codeExample,
+ prefix: '{id="2", title="Second"}',
+ }),
+ },
+ ];
+
+ const message = { content: contentParts };
+ const artifacts = findAllArtifacts(message);
+
+ // Target second artifact
+ const targetArtifact = artifacts[1];
+ const result = replaceArtifactContent(
+ contentParts[1].text,
+ targetArtifact,
+ codeExample,
+ 'updated content',
+ );
+ expect(result).toMatch(/id="2".*updated content/s);
+ expect(result).toMatch(new RegExp(`${ARTIFACT_START}.*updated content.*${ARTIFACT_END}`, 's'));
+ });
+
+ test('should handle empty content in artifact without code blocks', () => {
+ const artifactText = `${ARTIFACT_START}\n\n${ARTIFACT_END}`;
+ const artifact = {
+ start: 0,
+ end: artifactText.length,
+ text: artifactText,
+ source: 'text',
+ };
+
+ const result = replaceArtifactContent(artifactText, artifact, '', 'new content');
+ expect(result).toBe(`${ARTIFACT_START}\nnew content\n${ARTIFACT_END}`);
+ });
+
+ test('should handle empty content in artifact with code blocks', () => {
+ const artifactText = createArtifactText({ content: '' });
+ const artifact = {
+ start: 0,
+ end: artifactText.length,
+ text: artifactText,
+ source: 'text',
+ };
+
+ const result = replaceArtifactContent(artifactText, artifact, '', 'new content');
+ expect(result).toMatch(/```\nnew content\n```/);
+ });
+
+ test('should handle content with trailing newline in code blocks', () => {
+ const contentWithNewline = 'console.log("test")\n';
+ const message = {
+ text: `Some prefix text\n${createArtifactText({
+ content: contentWithNewline,
+ })}\nSome suffix text`,
+ };
+
+ const artifacts = findAllArtifacts(message);
+ expect(artifacts).toHaveLength(1);
+
+ const result = replaceArtifactContent(
+ message.text,
+ artifacts[0],
+ contentWithNewline,
+ 'updated content',
+ );
+
+ // Should update the content and preserve artifact structure
+ expect(result).toContain('```\nupdated content\n```');
+ // Should preserve surrounding text
+ expect(result).toMatch(/^Some prefix text\n/);
+ expect(result).toMatch(/\nSome suffix text$/);
+ // Should not have extra newlines
+ expect(result).not.toContain('\n\n```');
+ expect(result).not.toContain('```\n\n');
+ });
+
+ describe('incomplete artifacts', () => {
+ test('should handle incomplete artifacts (missing closing ::: and ```)', () => {
+ const original = `
+
+
+
+
+Pomodoro
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
You have been invited to join {{appName}}!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
You have been invited to join {{appName}}. Click the
+ button below to create your account and get started.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Hurry up, the invite will expiry in 7 days
+
+
+
+
+
+
+
+
+
+
+
+
+
Best regards,
+
The {{appName}} Team
+
+
+
+
+
+
+
+
+
+
+
+
©
+ {{year}}
+ {{appName}}. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/api/server/utils/emails/passwordReset.handlebars b/api/server/utils/emails/passwordReset.handlebars
new file mode 100644
index 0000000..6b735f5
--- /dev/null
+++ b/api/server/utils/emails/passwordReset.handlebars
@@ -0,0 +1,250 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Your password has been updated successfully!
+
+
+
+
+
+
+
+
+
+
+
+
Best regards,
+
The {{appName}} Team
+
+
+
+
+
+
+
+
+
+
+
+
©
+ {{year}}
+ {{appName}}. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/api/server/utils/emails/requestPasswordReset.handlebars b/api/server/utils/emails/requestPasswordReset.handlebars
new file mode 100644
index 0000000..b700525
--- /dev/null
+++ b/api/server/utils/emails/requestPasswordReset.handlebars
@@ -0,0 +1,344 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
You have requested to reset your password.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Please click the button below to
+ reset your password.
+
+
+
+
+
+
+
+
+
+
+
+
+
If you did not request a password reset, please ignore this
+ email.
+
+
+
+
+
+
+
+
+
+
+
+
Best regards,
+
The {{appName}} Team
+
+
+
+
+
+
+
+
+
+
+
+
©
+ {{year}}
+ {{appName}}. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/api/server/utils/emails/verifyEmail.handlebars b/api/server/utils/emails/verifyEmail.handlebars
new file mode 100644
index 0000000..a6cc79c
--- /dev/null
+++ b/api/server/utils/emails/verifyEmail.handlebars
@@ -0,0 +1,347 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Welcome to {{appName}}!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Thank you for registering with
+ {{appName}}. To complete your registration and verify your
+ email address, please click the button below:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
If you did not create an account with
+ {{appName}}, please ignore this email.
+
+
+
+
+
+
+
+
+
+
+
+
Best regards,
+
The {{appName}} Team
+
+
+
+
+
+
+
+
+
+
+
+
©
+ {{year}}
+ {{appName}}. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/api/server/utils/fallback.js b/api/server/utils/fallback.js
new file mode 100644
index 0000000..3e067ab
--- /dev/null
+++ b/api/server/utils/fallback.js
@@ -0,0 +1,20 @@
+/** Static asset extensions that must 404 when missing — serving the SPA's
+ * index.html for them breaks strict MIME checks and poisons SW/browser caches. */
+const STATIC_ASSET_EXT =
+ /\.(?:js|mjs|css|map|json|wasm|webmanifest|png|jpe?g|gif|svg|ico|webp|avif|woff2?|ttf|otf|eot)$/i;
+
+/**
+ * Creates the SPA fallback middleware: serves index.html for unmatched
+ * routes while returning 404 for missing static assets.
+ * @param {(req: import('express').Request, res: import('express').Response) => void} sendIndexHtml
+ */
+function createSpaFallback(sendIndexHtml) {
+ return (req, res) => {
+ if (STATIC_ASSET_EXT.test(req.path)) {
+ return res.status(404).end();
+ }
+ return sendIndexHtml(req, res);
+ };
+}
+
+module.exports = createSpaFallback;
diff --git a/api/server/utils/files.js b/api/server/utils/files.js
new file mode 100644
index 0000000..3129c8e
--- /dev/null
+++ b/api/server/utils/files.js
@@ -0,0 +1,95 @@
+const sharp = require('sharp');
+
+/**
+ * Determines the file type of a buffer
+ * @param {Buffer} dataBuffer
+ * @param {boolean} [returnFileType=false] - Optional. If true, returns the file type instead of the file extension.
+ * @returns {Promise} - Returns the file extension if found, else null
+ * */
+const determineFileType = async (dataBuffer, returnFileType) => {
+ const fileType = await import('file-type');
+ const type = await fileType.fileTypeFromBuffer(dataBuffer);
+ if (returnFileType) {
+ return type;
+ }
+ return type ? type.ext : null; // Returns extension if found, else null
+};
+
+/**
+ * Get buffer metadata
+ * @param {Buffer} buffer
+ * @returns {Promise<{ bytes: number, type: string, dimensions: Record, extension: string}>}
+ */
+const getBufferMetadata = async (buffer) => {
+ const fileType = await determineFileType(buffer, true);
+ const bytes = buffer.length;
+ let extension = fileType ? fileType.ext : 'unknown';
+
+ /** @type {Record} */
+ let dimensions = {};
+
+ if (fileType && fileType.mime.startsWith('image/') && extension !== 'unknown') {
+ const imageMetadata = await sharp(buffer).metadata();
+ dimensions = {
+ width: imageMetadata.width,
+ height: imageMetadata.height,
+ };
+ }
+
+ return {
+ bytes,
+ type: fileType?.mime ?? 'unknown',
+ dimensions,
+ extension,
+ };
+};
+
+/**
+ * Removes UUID prefix from filename for clean display
+ * Pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx__filename.ext
+ * @param {string} fileName - The filename to clean
+ * @returns {string} - The cleaned filename without UUID prefix
+ */
+const cleanFileName = (fileName) => {
+ if (!fileName) {
+ return fileName;
+ }
+
+ // Remove UUID pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx__
+ const cleaned = fileName.replace(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}__/i,
+ '',
+ );
+
+ return cleaned;
+};
+
+const encodeRFC5987ValueChars = (value) =>
+ encodeURIComponent(value).replace(
+ /['()*]/g,
+ (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
+ );
+
+const getAsciiFilenameFallback = (fileName) => {
+ const fallback = fileName
+ .normalize('NFKD')
+ .replace(/[^\x20-\x7e]/g, '_')
+ .replace(/["\\\r\n]/g, '_');
+
+ return fallback || 'download';
+};
+
+const getContentDisposition = (fileName, disposition = 'attachment') => {
+ const cleanedFilename = cleanFileName(fileName) || 'download';
+ const asciiFallback = getAsciiFilenameFallback(cleanedFilename);
+ const encodedFilename = encodeRFC5987ValueChars(cleanedFilename);
+
+ return `${disposition}; filename="${asciiFallback}"; filename*=UTF-8''${encodedFilename}`;
+};
+
+module.exports = {
+ determineFileType,
+ getBufferMetadata,
+ cleanFileName,
+ getContentDisposition,
+};
diff --git a/api/server/utils/files.spec.js b/api/server/utils/files.spec.js
new file mode 100644
index 0000000..41332c6
--- /dev/null
+++ b/api/server/utils/files.spec.js
@@ -0,0 +1,31 @@
+jest.mock('sharp', () => jest.fn(), { virtual: true });
+
+const { cleanFileName, getContentDisposition } = require('./files');
+
+describe('file utilities', () => {
+ describe('cleanFileName', () => {
+ it('removes storage UUID prefixes', () => {
+ expect(cleanFileName('123e4567-e89b-12d3-a456-426614174000__report.txt')).toBe('report.txt');
+ });
+ });
+
+ describe('getContentDisposition', () => {
+ it('adds RFC 8187 encoding for Unicode filenames', () => {
+ const filename = '日本語レポート.xlsx';
+ const header = getContentDisposition(`123e4567-e89b-12d3-a456-426614174000__${filename}`);
+
+ expect(header).toMatch(/^attachment; filename=".*"; filename\*=UTF-8''/);
+ expect(header).not.toContain('123e4567-e89b-12d3-a456-426614174000__');
+ expect(header).toContain(`filename*=UTF-8''${encodeURIComponent(filename)}`);
+ });
+
+ it('escapes the ASCII fallback without dropping the encoded filename', () => {
+ const filename = 'bad"name\r\n.txt';
+ const header = getContentDisposition(filename);
+
+ expect(header).toContain('filename="bad_name__.txt"');
+ expect(header).toContain("filename*=UTF-8''bad%22name%0D%0A.txt");
+ expect(header).not.toMatch(/[\r\n]/);
+ });
+ });
+});
diff --git a/api/server/utils/getFileStrategy.js b/api/server/utils/getFileStrategy.js
new file mode 100644
index 0000000..1bab7f2
--- /dev/null
+++ b/api/server/utils/getFileStrategy.js
@@ -0,0 +1,60 @@
+const { FileSources, FileContext } = require('librechat-data-provider');
+
+/**
+ * Determines the appropriate file storage strategy based on file type and configuration.
+ *
+ * @param {AppConfig} appConfig - App configuration object containing fileStrategy and fileStrategies
+ * @param {Object} options - File context options
+ * @param {boolean} options.isAvatar - Whether this is an avatar upload
+ * @param {boolean} options.isImage - Whether this is an image upload
+ * @param {string} options.context - File context from FileContext enum
+ * @returns {string} Storage strategy to use (e.g., FileSources.local, 's3', 'azure')
+ *
+ * @example
+ * // Legacy single strategy
+ * getFileStrategy({ fileStrategy: 's3' }) // Returns 's3'
+ *
+ * @example
+ * // Granular strategies
+ * getFileStrategy(
+ * {
+ * fileStrategy: 's3',
+ * fileStrategies: { avatar: FileSources.local, document: 's3' }
+ * },
+ * { isAvatar: true }
+ * ) // Returns FileSources.local
+ */
+function getFileStrategy(appConfig, { isAvatar = false, isImage = false, context = null } = {}) {
+ // Fallback to legacy single strategy if no granular config
+ if (!appConfig?.fileStrategies) {
+ return appConfig.fileStrategy || FileSources.local; // Default to FileSources.local if undefined
+ }
+
+ const strategies = appConfig.fileStrategies;
+ const defaultStrategy = strategies.default || appConfig.fileStrategy || FileSources.local;
+
+ // Priority order for strategy selection:
+ // 1. Specific file type strategy
+ // 2. Default strategy from fileStrategies
+ // 3. Legacy fileStrategy
+ // 4. FileSources.local as final fallback
+
+ let selectedStrategy;
+
+ if (isAvatar || context === FileContext.avatar) {
+ selectedStrategy = strategies.avatar || defaultStrategy;
+ } else if (context === FileContext.skill_file) {
+ // Skill files: explicit skills strategy → fall back by type → default
+ selectedStrategy =
+ strategies.skills || (isImage ? strategies.image : strategies.document) || defaultStrategy;
+ } else if (isImage || context === FileContext.image_generation) {
+ selectedStrategy = strategies.image || defaultStrategy;
+ } else {
+ // All other files (documents, attachments, etc.)
+ selectedStrategy = strategies.document || defaultStrategy;
+ }
+
+ return selectedStrategy || FileSources.local; // Final fallback to FileSources.local
+}
+
+module.exports = { getFileStrategy };
diff --git a/api/server/utils/handleText.js b/api/server/utils/handleText.js
new file mode 100644
index 0000000..cce96fe
--- /dev/null
+++ b/api/server/utils/handleText.js
@@ -0,0 +1,172 @@
+const partialRight = require('lodash/partialRight');
+const {
+ Capabilities,
+ EModelEndpoint,
+ isAgentsEndpoint,
+ isAssistantsEndpoint,
+ defaultRetrievalModels,
+ defaultAssistantsVersion,
+ defaultAgentCapabilities,
+} = require('librechat-data-provider');
+const { sendEvent, isUserProvided } = require('@librechat/api');
+
+const addSpaceIfNeeded = (text) => (text.length > 0 && !text.endsWith(' ') ? text + ' ' : text);
+
+const base = { message: true, initial: true };
+const createOnProgress = (
+ { generation = '', onProgress: _onProgress } = {
+ generation: '',
+ onProgress: null,
+ },
+) => {
+ let i = 0;
+ let tokens = addSpaceIfNeeded(generation);
+
+ const basePayload = Object.assign({}, base, { text: tokens || '' });
+
+ const progressCallback = (chunk, { res, ...rest }) => {
+ basePayload.text = basePayload.text + chunk;
+
+ const payload = Object.assign({}, basePayload, rest);
+ sendEvent(res, payload);
+ if (_onProgress) {
+ _onProgress(payload);
+ }
+ if (i === 0) {
+ basePayload.initial = false;
+ }
+ i++;
+ };
+
+ const sendIntermediateMessage = (res, payload, extraTokens = '') => {
+ basePayload.text = basePayload.text + extraTokens;
+ const message = Object.assign({}, basePayload, payload);
+ sendEvent(res, message);
+ if (i === 0) {
+ basePayload.initial = false;
+ }
+ i++;
+ };
+
+ const onProgress = (opts) => {
+ return partialRight(progressCallback, opts);
+ };
+
+ const getPartialText = () => {
+ return basePayload.text;
+ };
+
+ return { onProgress, getPartialText, sendIntermediateMessage };
+};
+
+const handleText = async (response) => {
+ let { text } = response;
+ response.text = text;
+ return text;
+};
+
+const isObject = (item) => item && typeof item === 'object' && !Array.isArray(item);
+const getString = (input) => (isObject(input) ? JSON.stringify(input) : input);
+
+function formatSteps(steps) {
+ let output = '';
+
+ for (let i = 0; i < steps.length; i++) {
+ const step = steps[i];
+ const actionInput = getString(step.action.toolInput);
+ const observation = step.observation;
+
+ if (actionInput === 'N/A' || observation?.trim()?.length === 0) {
+ continue;
+ }
+
+ output += `Input: ${actionInput}\nOutput: ${getString(observation)}`;
+
+ if (steps.length > 1 && i !== steps.length - 1) {
+ output += '\n---\n';
+ }
+ }
+
+ return output;
+}
+
+function formatAction(action) {
+ const formattedAction = {
+ plugin: action.tool,
+ input: getString(action.toolInput),
+ thought: action.log.includes('Thought: ')
+ ? action.log.split('\n')[0].replace('Thought: ', '')
+ : action.log.split('\n')[0],
+ };
+
+ formattedAction.thought = getString(formattedAction.thought);
+
+ if (action.tool.toLowerCase() === 'self-reflection' || formattedAction.plugin === 'N/A') {
+ formattedAction.inputStr = `{\n\tthought: ${formattedAction.input}${
+ !formattedAction.thought.includes(formattedAction.input)
+ ? ' - ' + formattedAction.thought
+ : ''
+ }\n}`;
+ formattedAction.inputStr = formattedAction.inputStr.replace('N/A - ', '');
+ } else {
+ const hasThought = formattedAction.thought.length > 0;
+ const thought = hasThought ? `\n\tthought: ${formattedAction.thought}` : '';
+ formattedAction.inputStr = `{\n\tplugin: ${formattedAction.plugin}\n\tinput: ${formattedAction.input}\n${thought}}`;
+ }
+
+ return formattedAction;
+}
+
+/**
+ * Generate the configuration for a given key and base URL.
+ * @param {string} key
+ * @param {string} [baseURL]
+ * @param {string} [endpoint]
+ * @returns {boolean | { userProvide: boolean, userProvideURL?: boolean }}
+ */
+function generateConfig(key, baseURL, endpoint) {
+ if (!key) {
+ return false;
+ }
+
+ /** @type {{ userProvide: boolean, userProvideURL?: boolean }} */
+ const config = { userProvide: isUserProvided(key) };
+
+ if (baseURL) {
+ config.userProvideURL = isUserProvided(baseURL);
+ }
+
+ const assistants = isAssistantsEndpoint(endpoint);
+ const agents = isAgentsEndpoint(endpoint);
+ if (assistants) {
+ config.retrievalModels = defaultRetrievalModels;
+ config.capabilities = [
+ Capabilities.code_interpreter,
+ Capabilities.image_vision,
+ Capabilities.retrieval,
+ Capabilities.actions,
+ Capabilities.tools,
+ ];
+ }
+
+ if (agents) {
+ config.capabilities = defaultAgentCapabilities;
+ }
+
+ if (assistants && endpoint === EModelEndpoint.azureAssistants) {
+ config.version = defaultAssistantsVersion.azureAssistants;
+ } else if (assistants) {
+ config.version = defaultAssistantsVersion.assistants;
+ }
+
+ return config;
+}
+
+module.exports = {
+ handleText,
+ formatSteps,
+ formatAction,
+ generateConfig,
+ addSpaceIfNeeded,
+ createOnProgress,
+};
diff --git a/api/server/utils/import/__data__/chatbotui-export.json b/api/server/utils/import/__data__/chatbotui-export.json
new file mode 100644
index 0000000..b1b5e2a
--- /dev/null
+++ b/api/server/utils/import/__data__/chatbotui-export.json
@@ -0,0 +1,98 @@
+{
+ "version": 4,
+ "history": [
+ {
+ "id": "493cb218-9ebb-4d35-af8b-5aded3f351be",
+ "name": "Hello what are you able to do?",
+ "messages": [
+ {
+ "role": "user",
+ "type": "text",
+ "content": "Hello what are you able to do?"
+ },
+ {
+ "role": "assistant",
+ "type": "text",
+ "content": "Hello! As an AI developed by OpenAI, I'm designed to assist with a wide range of tasks, including but not limited to:\n\n1. **Answering Questions**: I can provide explanations on various topics from science to history, technology, and more.\n2. **Providing Information**: Whether you need the latest knowledge (up to my last training data), tips, statistics, or how-to guides, I can help.\n3. **Learning and Education**: I can assist with learning new skills, languages, or subjects.\n4. **Writing and Editing**: From composing texts to editing them, I can assist in creating content such as essays, emails, and creative writing.\n5. **Programming Help**: Guidance in understanding programming concepts, debugging code, or learning about algorithms.\n6. **Entertainment**: I can tell jokes, stories, or even generate ideas for games and activities.\n7. **Brainstorming**: Whether it's ideas for a birthday party or strategies for a marketing plan, I can help brainstorm.\n8. **Conversations**: I can have in-depth or casual discussions on a broad range of topics.\n9. **Mental Health Support**: While I'm not a substitute for professional help, I can lend an ear and offer some general advice and support.\n10. **Travel Information**: Advice on destinations, tips for travel, and information about different cultures.\n\nPlease note that my responses are based on patterns and knowledge from text up to my last update, so I might not be aware of the very latest data or events."
+ },
+ {
+ "role": "user",
+ "type": "text",
+ "content": "I like Entertainment part. Go ahead. Entertain me."
+ },
+ {
+ "role": "assistant",
+ "type": "text",
+ "content": "Certainly! Let's begin with a light-hearted joke:\n\nWhy don't skeletons fight each other?\n\nThey don't have the guts.\n\nIf you're in the mood for a story instead, let's start with a short tale:\n\n---\n\n**Title: The Lost Key**\n\nIn a quiet town nestled between rolling green hills and a silver-sheened lake, there lived a locksmith named Eli. Eli had a peculiar ability; he could craft keys that didn't just unlock doors but also opened pathways to hidden places and secret realms. However, Eli never ventured through these magical doors; he was content knowing that he had the power to unveil them.\n\nOne day, Eli crafted his masterpiece—a key forged from an unknown, shimmering metal that seemed to hum with an inner light. The locksmith felt a deep connection to this key, as if it were calling to him to seek the door it was destined to open. Despite his usual reservations, curiosity got the better of him, and he embarked on a journey to find the matching lock.\n\nHe traveled through the town, greeting familiar faces and searching every nook and cranny, but to no avail. Eli was about to give up when he noticed a peculiar cat with a coat as dark as the night sky, its eyes glinting with a hint of stars. The cat seemed to beckon him to follow.\n\nLed by the cosmic feline through alleys and gardens, Eli arrived at a forgotten part of town where an old, vine-covered wall stood. To his astonishment, in the wall was a door that rippled like the surface of the lake when touched by a soft breeze. It was a door he had never seen before. Trembling with excitement, Eli took out the key. It fit perfectly into the lock, which turned with a satisfying click.\n\nAs the door creaked open, a brilliant light spilled forth. Eli shielded his eyes, and when he finally could see, he found himself not in another realm, but in the heart of his own town. However, everything appeared different—colors were more vibrant, sounds were clearer, and the air carried the sweet scent of blooming flowers.\n\nThe town's people approached him, smiling. \"We've been waiting for you,\" they said. \"The key wasn't meant to lead you to another world, but to allow you to see your own world differently. To show you that magic exists here, with all of us.\"\n\nEli's life transformed that day. He realized that every smile, every act of kindness, and every moment of connection was a door to a world just as magical as those he could unlock with his keys.\n\nAnd as for the mysterious cosmic cat, it would appear now and then, always at the edge of his vision, reminding him that the real magic was here all along, hidden in plain sight.\n\n---\n\nI hope you enjoyed the joke and the short story. If you'd like to continue the entertainment, feel free to guide me on what you'd like to hear or discuss next!"
+ }
+ ],
+ "model": {
+ "id": "gpt-4-1106-preview",
+ "name": "GPT-4 Turbo Preview",
+ "maxLength": 384000,
+ "tokenLimit": 128000
+ },
+ "prompt": "You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.",
+ "temperature": 1,
+ "folderId": null
+ },
+ {
+ "id": "f883fb40-6e5c-45fd-92d4-883a5595560f",
+ "name": "Give me the code that inverts ...",
+ "messages": [
+ {
+ "role": "user",
+ "type": "text",
+ "content": "Give me the code that inverts binary tree in COBOL"
+ },
+ {
+ "role": "assistant",
+ "type": "text",
+ "content": "COBOL is not a language that is commonly used for tasks like inverting a binary tree. It is more suited for business applications and mainframe programming. However, if you are looking for a general idea of how you might approach this in COBOL, here is a simplified example:\n\n```cobol\nIDENTIFICATION DIVISION.\nPROGRAM-ID. InvertBinaryTree.\n\nDATA DIVISION.\nWORKING-STORAGE SECTION.\n01 Node.\n 05 Left-Child PIC 9.\n 05 Right-Child PIC 9.\n\nPROCEDURE DIVISION.\nInvert-Binary-Tree.\n MOVE 1 TO Left-Child\n MOVE 2 TO Right-Child\n\n DISPLAY \"Original Binary Tree:\"\n DISPLAY \"Left Child: \" Left-Child\n DISPLAY \"Right Child: \" Right-Child\n\n PERFORM Swap-Children\n\n DISPLAY \"Inverted Binary Tree:\"\n DISPLAY \"Left Child: \" Left-Child\n DISPLAY \"Right Child: \" Right-Child\n\n STOP RUN.\n\nSwap-Children.\n MOVE Left-Child TO Node\n MOVE Right-Child TO Left-Child\n MOVE Node TO Right-Child\n```\n\nThis is a very basic example and may not cover all the complexities of inverting a binary tree. You would need to adapt and expand this code significantly for a more complex binary tree structure."
+ }
+ ],
+ "model": {
+ "id": "gpt-3.5-turbo",
+ "name": "GPT-3.5"
+ },
+ "prompt": "You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.",
+ "temperature": 0.5,
+ "folderId": null
+ }
+ ],
+ "folders": [
+ {
+ "id": "cdc857de-e669-498d-8fac-edc4995c9d7a",
+ "name": "New folder",
+ "type": "prompt"
+ }
+ ],
+ "prompts": [
+ {
+ "id": "a61573d8-6686-487c-9c5d-cd79c6d201ee",
+ "name": "Prompt 1",
+ "description": "",
+ "content": "",
+ "model": {
+ "id": "gpt-4",
+ "name": "GPT-4",
+ "maxLength": 24000,
+ "tokenLimit": 8000
+ },
+ "folderId": null
+ },
+ {
+ "id": "9bf456e3-61fc-494d-b940-55ec934e7a04",
+ "name": "Prompt 2",
+ "description": "afgdfsg",
+ "content": "adfdsfsadf",
+ "model": {
+ "id": "gpt-4",
+ "name": "GPT-4",
+ "maxLength": 24000,
+ "tokenLimit": 8000
+ },
+ "folderId": null
+ }
+ ]
+}
diff --git a/api/server/utils/import/__data__/chatgpt-citations.json b/api/server/utils/import/__data__/chatgpt-citations.json
new file mode 100644
index 0000000..5a08631
--- /dev/null
+++ b/api/server/utils/import/__data__/chatgpt-citations.json
@@ -0,0 +1,345 @@
+[
+ {
+ "title": "Citation Convo",
+ "create_time": 1704629915.775304,
+ "update_time": 1704717442.442031,
+ "mapping": {
+ "9e874379-5008-4a2d-aa2e-628d1d705a04": {
+ "id": "9e874379-5008-4a2d-aa2e-628d1d705a04",
+ "message": {
+ "id": "9e874379-5008-4a2d-aa2e-628d1d705a04",
+ "author": {
+ "role": "tool",
+ "name": "browser",
+ "metadata": {}
+ },
+ "create_time": 1708580164.172007,
+ "update_time": null,
+ "content": {
+ "content_type": "tether_browsing_display",
+ "result": "# 【0†Signal Sciences - Crunchbase Company Profile & Funding†www.crunchbase.com】\nFounded Date Mar 10, 2014. Founders Andrew Peterson, Nick Galbreath, Zane Lackey. Operating Status Active. Last Funding Type Series C. Legal Name Signal Sciences Corp. Company Type For Profit. Contact Email info@signalsciences.com. Phone Number +1 424-404-1139. Signal Sciences is the fastest growing web application security company in the world.\n# 【1†Demand More from Your WAF - Signal Sciences now part of Fastly†www.signalsciences.com】\nModernize Your Application/API Protection While Lowering Your TCO. Legacy WAFs weren't designed for today's web apps that are distributed across cloud, on-premise, edge or hybrid environments. Our next-gen web application firewall (NGWAF) and runtime application self protection (RASP) increase security and maintain reliability without ...\n# 【2†Security at Scale: Fastly Announces Intent to Acquire Signal Science†www.fastly.com】\nSignal Sciences' technology combined with Fastly's current solutions will form Fastly's upcoming new security offering, called Secure@Edge. Secure@Edge will be a modern, unified web application and API security solution that will integrate with our Compute platform, and power and protect companies looking to further or begin their digital ...\n# 【3†Web Application and API Protection - Signal Sciences now part of Fastly†www.signalsciences.com】\nOur customers have spoken: Signal Sciences is the highest rated Web Application Firewalls (WAF) vendor. We were named a 2021 Gartner Peer Insights Customers' Choice for WAF based on detailed end-user testimonials. In addition, we are the only vendor to be named a Customers' Choice for three consecutive times and have an overall rating of 5 ...\n# 【4†Fastly Completes Acquisition of Signal Sciences | Fastly†www.fastly.com】\nSAN FRANCISCO, October 1, 2020 – Fastly, Inc. (NYSE: FSLY), provider of an edge cloud platform, today announced it has successfully completed the acquisition of Signal Sciences (“Signal Sciences”). The transaction combines Signal Sciences' powerful web application and API security solutions with Fastly's edge cloud platform and existing security offerings to form a unified suite of ...\n# 【5†Signal Sciences: Enterprises still overlooking web app security†www.techtarget.com】\nRansomware threats are everywhere these days, but one startup is focusing on the most common cause behind data breaches. Signal Sciences is a Web Application Firewall (WAF) and Runtime Application Self-Protection (RASP) company that protects customers' web applications and APIs, regardless of the software architecture or deployment model used, such as on-premises, cloud, containers or hybrid ...\n# 【6†Signal Sciences Launches Cloud WAF for Easy Web Application Security†www.signalsciences.com】\nToday we are proud to announce the launch of the new Signal Sciences Cloud Web Application Firewall (WAF) deployment option for our award-winning WAF and RASP solution.As the fastest growing web application security company in the world, we know organizations must quickly detect and stop web application layer attacks wherever their apps, APIs, or microservices operate—whether that be in the ...\n# 【7†Fastly Agrees to Acquire Signal Sciences for $775 Million - Cooley†www.cooley.com】\nSan Francisco – August 27, 2020 – Cooley advised edge cloud platform provider Fastly on its agreement to acquire Signal Sciences, one of the fastest growing web application security companies in the world, for approximately $775 million in cash and stock.The acquisition, which is expected to close this year, will expand Fastly's robust security portfolio at a time when security at the ...\n# 【8†Signal Sciences†info.signalsciences.com】\nSignal Sciences next-gen WAF and RASP technology is designed to work quickly and effectively, enabling application developers and operations teams to deliver modern, business-critical web applications and APIs while ensuring they're well protected and running performantly.. There are many vendors claiming to provide effective and scalable offerings to protect applications and APIs, so we ...\n# 【9†Gartner names Signal Sciences a Visionary in the 2020 Magic ... - Fastly†www.fastly.com】\nWe believe Signal Sciences' innovation earned them recognition in the 2019 Gartner Magic Quadrant for WAF, ... Gartner research publications consist of the opinions of Gartner's research organization and should not be construed as statements of fact. Gartner disclaims all warranties, express or implied, with respect to this research ...\n# 【10†Fastly acquires Signal Sciences - 2020-08-27 - Crunchbase†www.crunchbase.com】\nSignal Sciences Signal Sciences is a software as a service platform providing security monitoring and defense for your web applications. Acquiring Organization: Fastly Fastly helps digital businesses keep pace with their customer expectations by delivering secure and online experiences. Announced Date Aug 27, 2020;\n# 【11†Signal Sciences - Funding, Financials, Valuation & Investors - Crunchbase†www.crunchbase.com】\nFunding. Signal Sciences has raised a total of. $61.7M. in funding over 4 rounds. Their latest funding was raised on Feb 5, 2019 from a Series C round. Signal Sciences is funded by 8 investors. CRV and Harrison Metal are the most recent investors. Signal Sciences has invested in Open Raven on Feb 11, 2020. This investment - Seed Round - Open ...\n# 【12†Signal Sciences WAF Review and Alternatives - Comparitech†www.comparitech.com】\nSignal Sciences WAF is completely agentless as it works in the cloud to apply both managed and custom rulesets to your application traffic. Protections extend beyond the OWASP Top 10 and include defense from botnet attacks, account takeovers, credential stuffing, API abuse, and DDoS mitigation. Automatic blocking and scanning are applied to ...\n# 【13†Integrations - Web Application Security - Signal Sciences now part of ...†www.signalsciences.com】\nThe Signal Sciences site extension protects any IIS web application hosted on AAS and provides production visibility necessary to detect and block malicious web requests before they can compromise your business-critical apps, APIs and microservices. ... and efficiently into an organization's existing hybrid, or cloud architecture ...\n# 【14†Fastly Next-Gen WAF professional services | Fastly Products†docs.fastly.com】\nFastly Next-Gen WAF (powered by Signal Sciences) (Next-Gen WAF) professional services provide your organization with training, implementation, and maintenance services for the Next-Gen WAF. Depending on the service offerings you select, our team will provide training and work with you to plan, test, deploy, and maintain a solution to protect ...\n# 【15†Andrew Peterson - Founder & CEO @ Signal Sciences - Crunchbase†www.crunchbase.com】\nAndrew Peterson is the founder and CEO of Signal Sciences, a Software-as-a-Service platform that provides security monitoring and defense solutions for web applications. He was previously the group product manager of international growth at Etsy. Prior to Etsy, Peterson was a health information management officer at the Clinton Foundation\n# 【16†Signal Sciences†info.signalsciences.com】\nThe webinar will cover: The most common attacks targeting distributed web apps. How a web application firewall (WAF) can inspect and decision on both north-south (client-to-app origin) and east-west (service-to-service) web requests. How to detect and prevent malicious client-side activity originating from website supply chain vendors. A live ...\n# 【17†Next-Generation Cloud Web Application Firewall (WAF) - Signal Sciences ...†www.signalsciences.com】\nWith a single change to a DNS record, web requests are routed to the Signal Sciences Cloud WAF to inspect and decision on those requests and block bad requests. Our unique solution provides robust detection and protection capabilities no matter where your organization's applications operate or how rapidly they scale. No complex software ...\n# 【18†How to Configure SAML 2.0 for Signal Sciences - UserDocs†saml-doc.okta.com】\nConfiguration Steps. Log in to Signal Sciences as an Organization Administrator. Navigate to organization > Settings.. Click the Switch to SAML button:. Click the I understand, configure SSO button:. On the Configure SSO with SAML page, enter the following:. Copy and paste the following IDP Login page into the SAML endpoint field:. Sign into the Okta Admin Dashboard to generate this variable.\n# 【19†Powering the best of the internet | Fastly†www.fastly.com】\nEverybody needs speed, reliability, security, savings, and scale – but different industries have different needs. Fastly's powerful network and smarter solutions can be tailored to your organization. We partner with you to guarantee a smooth migration, so you can deliver the best possible user experiences.\nVisible: 0% - 100%",
+ "summary": null,
+ "assets": [],
+ "tether_id": null
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 0,
+ "metadata": {
+ "_cite_metadata": {
+ "citation_format": {
+ "name": "tether_og",
+ "regex": "【(\\d+)(?::(\\d+))?†([^†【】]*)[^【】]*?】"
+ },
+ "metadata_list": [
+ {
+ "type": "webpage",
+ "title": "Signal Sciences - Crunchbase Company Profile & Funding",
+ "url": "https://www.crunchbase.com/organization/signal-sciences",
+ "text": "\nFounded Date Mar 10, 2014. Founders Andrew Peterson, Nick Galbreath, Zane Lackey. Operating Status Active. Last Funding Type Series C. Legal Name Signal Sciences Corp. Company Type For Profit. Contact Email info@signalsciences.com. Phone Number +1 424-404-1139. Signal Sciences is the fastest growing web application security company in the world.\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Demand More from Your WAF - Signal Sciences now part of Fastly",
+ "url": "https://www.signalsciences.com/",
+ "text": "\nModernize Your Application/API Protection While Lowering Your TCO. Legacy WAFs weren't designed for today's web apps that are distributed across cloud, on-premise, edge or hybrid environments. Our next-gen web application firewall (NGWAF) and runtime application self protection (RASP) increase security and maintain reliability without ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Security at Scale: Fastly Announces Intent to Acquire Signal Science",
+ "url": "https://www.fastly.com/blog/fastly-intent-to-acquire-signal-sciences",
+ "text": "\nSignal Sciences' technology combined with Fastly's current solutions will form Fastly's upcoming new security offering, called Secure@Edge. Secure@Edge will be a modern, unified web application and API security solution that will integrate with our Compute platform, and power and protect companies looking to further or begin their digital ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Web Application and API Protection - Signal Sciences now part of Fastly",
+ "url": "https://www.signalsciences.com/products/",
+ "text": "\nOur customers have spoken: Signal Sciences is the highest rated Web Application Firewalls (WAF) vendor. We were named a 2021 Gartner Peer Insights Customers' Choice for WAF based on detailed end-user testimonials. In addition, we are the only vendor to be named a Customers' Choice for three consecutive times and have an overall rating of 5 ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Fastly Completes Acquisition of Signal Sciences | Fastly",
+ "url": "https://www.fastly.com/press/press-releases/fastly-completes-acquisition-signal-sciences",
+ "text": "\nSAN FRANCISCO, October 1, 2020 – Fastly, Inc. (NYSE: FSLY), provider of an edge cloud platform, today announced it has successfully completed the acquisition of Signal Sciences (“Signal Sciences”). The transaction combines Signal Sciences' powerful web application and API security solutions with Fastly's edge cloud platform and existing security offerings to form a unified suite of ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Signal Sciences: Enterprises still overlooking web app security",
+ "url": "https://www.techtarget.com/searchsecurity/news/252476659/Signal-Sciences-Enterprises-still-overlooking-web-app-security",
+ "text": "\nRansomware threats are everywhere these days, but one startup is focusing on the most common cause behind data breaches. Signal Sciences is a Web Application Firewall (WAF) and Runtime Application Self-Protection (RASP) company that protects customers' web applications and APIs, regardless of the software architecture or deployment model used, such as on-premises, cloud, containers or hybrid ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Signal Sciences Launches Cloud WAF for Easy Web Application Security",
+ "url": "https://www.signalsciences.com/blog/cloud-waf-web-applications/",
+ "text": "\nToday we are proud to announce the launch of the new Signal Sciences Cloud Web Application Firewall (WAF) deployment option for our award-winning WAF and RASP solution.As the fastest growing web application security company in the world, we know organizations must quickly detect and stop web application layer attacks wherever their apps, APIs, or microservices operate—whether that be in the ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Fastly Agrees to Acquire Signal Sciences for $775 Million - Cooley",
+ "url": "https://www.cooley.com/news/coverage/2020/2020-08-27-fastly-agrees-to-acquire-signal-sciences-for-775-million",
+ "text": "\nSan Francisco – August 27, 2020 – Cooley advised edge cloud platform provider Fastly on its agreement to acquire Signal Sciences, one of the fastest growing web application security companies in the world, for approximately $775 million in cash and stock.The acquisition, which is expected to close this year, will expand Fastly's robust security portfolio at a time when security at the ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Signal Sciences",
+ "url": "https://info.signalsciences.com/10-key-capabilities-of-signal-sciences",
+ "text": "\nSignal Sciences next-gen WAF and RASP technology is designed to work quickly and effectively, enabling application developers and operations teams to deliver modern, business-critical web applications and APIs while ensuring they're well protected and running performantly.. There are many vendors claiming to provide effective and scalable offerings to protect applications and APIs, so we ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Gartner names Signal Sciences a Visionary in the 2020 Magic ... - Fastly",
+ "url": "https://www.fastly.com/blog/gartner-names-signal-sciences-a-visionary-in-the-2020-magic-quadrant-for-waf",
+ "text": "\nWe believe Signal Sciences' innovation earned them recognition in the 2019 Gartner Magic Quadrant for WAF, ... Gartner research publications consist of the opinions of Gartner's research organization and should not be construed as statements of fact. Gartner disclaims all warranties, express or implied, with respect to this research ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Fastly acquires Signal Sciences - 2020-08-27 - Crunchbase",
+ "url": "https://www.crunchbase.com/acquisition/fastly-acquires-signal-sciences--2b836efb",
+ "text": "\nSignal Sciences Signal Sciences is a software as a service platform providing security monitoring and defense for your web applications. Acquiring Organization: Fastly Fastly helps digital businesses keep pace with their customer expectations by delivering secure and online experiences. Announced Date Aug 27, 2020;\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Signal Sciences - Funding, Financials, Valuation & Investors - Crunchbase",
+ "url": "https://www.crunchbase.com/organization/signal-sciences/company_financials",
+ "text": "\nFunding. Signal Sciences has raised a total of. $61.7M. in funding over 4 rounds. Their latest funding was raised on Feb 5, 2019 from a Series C round. Signal Sciences is funded by 8 investors. CRV and Harrison Metal are the most recent investors. Signal Sciences has invested in Open Raven on Feb 11, 2020. This investment - Seed Round - Open ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Signal Sciences WAF Review and Alternatives - Comparitech",
+ "url": "https://www.comparitech.com/net-admin/signal-sciences-waf-review/",
+ "text": "\nSignal Sciences WAF is completely agentless as it works in the cloud to apply both managed and custom rulesets to your application traffic. Protections extend beyond the OWASP Top 10 and include defense from botnet attacks, account takeovers, credential stuffing, API abuse, and DDoS mitigation. Automatic blocking and scanning are applied to ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Integrations - Web Application Security - Signal Sciences now part of ...",
+ "url": "https://www.signalsciences.com/integrations/",
+ "text": "\nThe Signal Sciences site extension protects any IIS web application hosted on AAS and provides production visibility necessary to detect and block malicious web requests before they can compromise your business-critical apps, APIs and microservices. ... and efficiently into an organization's existing hybrid, or cloud architecture ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Fastly Next-Gen WAF professional services | Fastly Products",
+ "url": "https://docs.fastly.com/products/fastly-next-gen-waf-professional-services",
+ "text": "\nFastly Next-Gen WAF (powered by Signal Sciences) (Next-Gen WAF) professional services provide your organization with training, implementation, and maintenance services for the Next-Gen WAF. Depending on the service offerings you select, our team will provide training and work with you to plan, test, deploy, and maintain a solution to protect ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Andrew Peterson - Founder & CEO @ Signal Sciences - Crunchbase",
+ "url": "https://www.crunchbase.com/person/andrew-peterson",
+ "text": "\nAndrew Peterson is the founder and CEO of Signal Sciences, a Software-as-a-Service platform that provides security monitoring and defense solutions for web applications. He was previously the group product manager of international growth at Etsy. Prior to Etsy, Peterson was a health information management officer at the Clinton Foundation\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Signal Sciences",
+ "url": "https://info.signalsciences.com/preventing-modern-web-app-attacks-source-defense",
+ "text": "\nThe webinar will cover: The most common attacks targeting distributed web apps. How a web application firewall (WAF) can inspect and decision on both north-south (client-to-app origin) and east-west (service-to-service) web requests. How to detect and prevent malicious client-side activity originating from website supply chain vendors. A live ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Next-Generation Cloud Web Application Firewall (WAF) - Signal Sciences ...",
+ "url": "https://www.signalsciences.com/products/cloud-waf/",
+ "text": "\nWith a single change to a DNS record, web requests are routed to the Signal Sciences Cloud WAF to inspect and decision on those requests and block bad requests. Our unique solution provides robust detection and protection capabilities no matter where your organization's applications operate or how rapidly they scale. No complex software ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "How to Configure SAML 2.0 for Signal Sciences - UserDocs",
+ "url": "https://saml-doc.okta.com/SAML_Docs/How-to-Configure-SAML-2.0-for-Signal-Sciences.html",
+ "text": "\nConfiguration Steps. Log in to Signal Sciences as an Organization Administrator. Navigate to organization > Settings.. Click the Switch to SAML button:. Click the I understand, configure SSO button:. On the Configure SSO with SAML page, enter the following:. Copy and paste the following IDP Login page into the SAML endpoint field:. Sign into the Okta Admin Dashboard to generate this variable.\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Powering the best of the internet | Fastly",
+ "url": "https://www.fastly.com/",
+ "text": "\nEverybody needs speed, reliability, security, savings, and scale – but different industries have different needs. Fastly's powerful network and smarter solutions can be tailored to your organization. We partner with you to guarantee a smooth migration, so you can deliver the best possible user experiences.\nVisible: 0% - 100%",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Search results for query: 'Signal Sciences organization'",
+ "url": "",
+ "text": "# 【0†Signal Sciences - Crunchbase Company Profile & Funding†www.crunchbase.com】\nFounded Date Mar 10, 2014. Founders Andrew Peterson, Nick Galbreath, Zane Lackey. Operating Status Active. Last Funding Type Series C. Legal Name Signal Sciences Corp. Company Type For Profit. Contact Email info@signalsciences.com. Phone Number +1 424-404-1139. Signal Sciences is the fastest growing web application security company in the world.\n# 【1†Demand More from Your WAF - Signal Sciences now part of Fastly†www.signalsciences.com】\nModernize Your Application/API Protection While Lowering Your TCO. Legacy WAFs weren't designed for today's web apps that are distributed across cloud, on-premise, edge or hybrid environments. Our next-gen web application firewall (NGWAF) and runtime application self protection (RASP) increase security and maintain reliability without ...\n# 【2†Security at Scale: Fastly Announces Intent to Acquire Signal Science†www.fastly.com】\nSignal Sciences' technology combined with Fastly's current solutions will form Fastly's upcoming new security offering, called Secure@Edge. Secure@Edge will be a modern, unified web application and API security solution that will integrate with our Compute platform, and power and protect companies looking to further or begin their digital ...\n# 【3†Web Application and API Protection - Signal Sciences now part of Fastly†www.signalsciences.com】\nOur customers have spoken: Signal Sciences is the highest rated Web Application Firewalls (WAF) vendor. We were named a 2021 Gartner Peer Insights Customers' Choice for WAF based on detailed end-user testimonials. In addition, we are the only vendor to be named a Customers' Choice for three consecutive times and have an overall rating of 5 ...\n# 【4†Fastly Completes Acquisition of Signal Sciences | Fastly†www.fastly.com】\nSAN FRANCISCO, October 1, 2020 – Fastly, Inc. (NYSE: FSLY), provider of an edge cloud platform, today announced it has successfully completed the acquisition of Signal Sciences (“Signal Sciences”). The transaction combines Signal Sciences' powerful web application and API security solutions with Fastly's edge cloud platform and existing security offerings to form a unified suite of ...\n# 【5†Signal Sciences: Enterprises still overlooking web app security†www.techtarget.com】\nRansomware threats are everywhere these days, but one startup is focusing on the most common cause behind data breaches. Signal Sciences is a Web Application Firewall (WAF) and Runtime Application Self-Protection (RASP) company that protects customers' web applications and APIs, regardless of the software architecture or deployment model used, such as on-premises, cloud, containers or hybrid ...\n# 【6†Signal Sciences Launches Cloud WAF for Easy Web Application Security†www.signalsciences.com】\nToday we are proud to announce the launch of the new Signal Sciences Cloud Web Application Firewall (WAF) deployment option for our award-winning WAF and RASP solution.As the fastest growing web application security company in the world, we know organizations must quickly detect and stop web application layer attacks wherever their apps, APIs, or microservices operate—whether that be in the ...\n# 【7†Fastly Agrees to Acquire Signal Sciences for $775 Million - Cooley†www.cooley.com】\nSan Francisco – August 27, 2020 – Cooley advised edge cloud platform provider Fastly on its agreement to acquire Signal Sciences, one of the fastest growing web application security companies in the world, for approximately $775 million in cash and stock.The acquisition, which is expected to close this year, will expand Fastly's robust security portfolio at a time when security at the ...\n# 【8†Signal Sciences†info.signalsciences.com】\nSignal Sciences next-gen WAF and RASP technology is designed to work quickly and effectively, enabling application developers and operations teams to deliver modern, business-critical web applications and APIs while ensuring they're well protected and running performantly.. There are many vendors claiming to provide effective and scalable offerings to protect applications and APIs, so we ...\n# 【9†Gartner names Signal Sciences a Visionary in the 2020 Magic ... - Fastly†www.fastly.com】\nWe believe Signal Sciences' innovation earned them recognition in the 2019 Gartner Magic Quadrant for WAF, ... Gartner research publications consist of the opinions of Gartner's research organization and should not be construed as statements of fact. Gartner disclaims all warranties, express or implied, with respect to this research ...\n# 【10†Fastly acquires Signal Sciences - 2020-08-27 - Crunchbase†www.crunchbase.com】\nSignal Sciences Signal Sciences is a software as a service platform providing security monitoring and defense for your web applications. Acquiring Organization: Fastly Fastly helps digital businesses keep pace with their customer expectations by delivering secure and online experiences. Announced Date Aug 27, 2020;\n# 【11†Signal Sciences - Funding, Financials, Valuation & Investors - Crunchbase†www.crunchbase.com】\nFunding. Signal Sciences has raised a total of. $61.7M. in funding over 4 rounds. Their latest funding was raised on Feb 5, 2019 from a Series C round. Signal Sciences is funded by 8 investors. CRV and Harrison Metal are the most recent investors. Signal Sciences has invested in Open Raven on Feb 11, 2020. This investment - Seed Round - Open ...\n# 【12†Signal Sciences WAF Review and Alternatives - Comparitech†www.comparitech.com】\nSignal Sciences WAF is completely agentless as it works in the cloud to apply both managed and custom rulesets to your application traffic. Protections extend beyond the OWASP Top 10 and include defense from botnet attacks, account takeovers, credential stuffing, API abuse, and DDoS mitigation. Automatic blocking and scanning are applied to ...\n# 【13†Integrations - Web Application Security - Signal Sciences now part of ...†www.signalsciences.com】\nThe Signal Sciences site extension protects any IIS web application hosted on AAS and provides production visibility necessary to detect and block malicious web requests before they can compromise your business-critical apps, APIs and microservices. ... and efficiently into an organization's existing hybrid, or cloud architecture ...\n# 【14†Fastly Next-Gen WAF professional services | Fastly Products†docs.fastly.com】\nFastly Next-Gen WAF (powered by Signal Sciences) (Next-Gen WAF) professional services provide your organization with training, implementation, and maintenance services for the Next-Gen WAF. Depending on the service offerings you select, our team will provide training and work with you to plan, test, deploy, and maintain a solution to protect ...\n# 【15†Andrew Peterson - Founder & CEO @ Signal Sciences - Crunchbase†www.crunchbase.com】\nAndrew Peterson is the founder and CEO of Signal Sciences, a Software-as-a-Service platform that provides security monitoring and defense solutions for web applications. He was previously the group product manager of international growth at Etsy. Prior to Etsy, Peterson was a health information management officer at the Clinton Foundation\n# 【16†Signal Sciences†info.signalsciences.com】\nThe webinar will cover: The most common attacks targeting distributed web apps. How a web application firewall (WAF) can inspect and decision on both north-south (client-to-app origin) and east-west (service-to-service) web requests. How to detect and prevent malicious client-side activity originating from website supply chain vendors. A live ...\n# 【17†Next-Generation Cloud Web Application Firewall (WAF) - Signal Sciences ...†www.signalsciences.com】\nWith a single change to a DNS record, web requests are routed to the Signal Sciences Cloud WAF to inspect and decision on those requests and block bad requests. Our unique solution provides robust detection and protection capabilities no matter where your organization's applications operate or how rapidly they scale. No complex software ...\n# 【18†How to Configure SAML 2.0 for Signal Sciences - UserDocs†saml-doc.okta.com】\nConfiguration Steps. Log in to Signal Sciences as an Organization Administrator. Navigate to organization > Settings.. Click the Switch to SAML button:. Click the I understand, configure SSO button:. On the Configure SSO with SAML page, enter the following:. Copy and paste the following IDP Login page into the SAML endpoint field:. Sign into the Okta Admin Dashboard to generate this variable.\n# 【19†Powering the best of the internet | Fastly†www.fastly.com】\nEverybody needs speed, reliability, security, savings, and scale – but different industries have different needs. Fastly's powerful network and smarter solutions can be tailored to your organization. We partner with you to guarantee a smooth migration, so you can deliver the best possible user experiences.\nVisible: 0% - 100%",
+ "pub_date": null,
+ "extra": null
+ }
+ ],
+ "original_query": null
+ },
+ "command": "search",
+ "args": ["Signal Sciences organization"],
+ "status": "finished",
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "gpt-4",
+ "parent_id": "5cc5e87b-2b74-485a-b3a7-23cb25f202db",
+ "request_id": "8594dde3b85705f4-IAD",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "5cc5e87b-2b74-485a-b3a7-23cb25f202db",
+ "children": ["4b3aec6b-5146-4bad-ae8e-204fdb6accda"]
+ },
+ "4b3aec6b-5146-4bad-ae8e-204fdb6accda": {
+ "id": "4b3aec6b-5146-4bad-ae8e-204fdb6accda",
+ "message": {
+ "id": "4b3aec6b-5146-4bad-ae8e-204fdb6accda",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1708580164.175371,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ "Signal Sciences is a web application security company that was founded on March 10, 2014, by Andrew Peterson, Nick Galbreath, and Zane Lackey. It operates as a for-profit company with its legal name being Signal Sciences Corp. The company has achieved significant growth and is recognized as the fastest-growing web application security company in the world. Signal Sciences developed a next-gen web application firewall (NGWAF) and runtime application self-protection (RASP) technologies designed to increase security and maintain reliability without compromising the performance of modern web applications distributed across cloud, on-premise, edge, or hybrid environments【3:0†source】【3:1†source】.\n\nIn a major development, Fastly, Inc., a provider of an edge cloud platform, announced the completion of its acquisition of Signal Sciences on October 1, 2020. This acquisition was valued at approximately $775 million in cash and stock. By integrating Signal Sciences' powerful web application and API security solutions with Fastly's edge cloud platform and existing security offerings, they aimed to form a unified suite of security solutions. The merger was aimed at expanding Fastly's security portfolio, particularly at a time when digital security has become paramount for businesses operating online【3:4†source】【3:7†source】."
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": true,
+ "weight": 1,
+ "metadata": {
+ "finish_details": {
+ "type": "stop",
+ "stop_tokens": [100260]
+ },
+ "citations": [
+ {
+ "start_ix": 674,
+ "end_ix": 686,
+ "citation_format_type": "tether_og",
+ "metadata": {
+ "type": "webpage",
+ "title": "Signal Sciences - Crunchbase Company Profile & Funding",
+ "url": "https://www.crunchbase.com/organization/signal-sciences",
+ "text": "",
+ "pub_date": null,
+ "extra": {
+ "evidence_text": "source",
+ "cited_message_idx": 3,
+ "search_result_idx": 0
+ }
+ }
+ },
+ {
+ "start_ix": 686,
+ "end_ix": 698,
+ "citation_format_type": "tether_og",
+ "metadata": {
+ "type": "webpage",
+ "title": "Demand More from Your WAF - Signal Sciences now part of Fastly",
+ "url": "https://www.signalsciences.com/",
+ "text": "",
+ "pub_date": null,
+ "extra": {
+ "evidence_text": "source",
+ "cited_message_idx": 3,
+ "search_result_idx": 1
+ }
+ }
+ },
+ {
+ "start_ix": 1306,
+ "end_ix": 1318,
+ "citation_format_type": "tether_og",
+ "metadata": {
+ "type": "webpage",
+ "title": "Fastly Completes Acquisition of Signal Sciences | Fastly",
+ "url": "https://www.fastly.com/press/press-releases/fastly-completes-acquisition-signal-sciences",
+ "text": "",
+ "pub_date": null,
+ "extra": {
+ "evidence_text": "source",
+ "cited_message_idx": 3,
+ "search_result_idx": 4
+ }
+ }
+ },
+ {
+ "start_ix": 1318,
+ "end_ix": 1330,
+ "citation_format_type": "tether_og",
+ "metadata": {
+ "type": "webpage",
+ "title": "Fastly Agrees to Acquire Signal Sciences for $775 Million - Cooley",
+ "url": "https://www.cooley.com/news/coverage/2020/2020-08-27-fastly-agrees-to-acquire-signal-sciences-for-775-million",
+ "text": "",
+ "pub_date": null,
+ "extra": {
+ "evidence_text": "source",
+ "cited_message_idx": 3,
+ "search_result_idx": 7
+ }
+ }
+ }
+ ],
+ "gizmo_id": null,
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "gpt-4",
+ "parent_id": "9e874379-5008-4a2d-aa2e-628d1d705a04",
+ "request_id": "8594dde3b85705f4-IAD",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "9e874379-5008-4a2d-aa2e-628d1d705a04",
+ "children": ["aaa27ed5-1e1c-4189-ac6c-737608404ec1"]
+ }
+ },
+ "moderation_results": [],
+ "current_node": "88a0cf9f-e860-4b34-8e7e-65f8346f4862",
+ "plugin_ids": null,
+ "conversation_id": "d6523d1e-7ec3-474f-a363-0e9dffdb3d93",
+ "conversation_template_id": null,
+ "gizmo_id": null,
+ "is_archived": false,
+ "safe_urls": [
+ "https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/",
+ "https://www.drive.com.au/reviews/volkswagen-transporter-review/",
+ "https://www.ultimatespecs.com/car-specs/volkswagen/118424/2021-volkswagen-transporter-t61-l2h1-20-tdi-110hp.html"
+ ],
+ "default_model_slug": null,
+ "id": "d6523d1e-7ec3-474f-a363-0e9dffdb3d93"
+ }
+]
diff --git a/api/server/utils/import/__data__/chatgpt-export.json b/api/server/utils/import/__data__/chatgpt-export.json
new file mode 100644
index 0000000..a8ee0f3
--- /dev/null
+++ b/api/server/utils/import/__data__/chatgpt-export.json
@@ -0,0 +1,1224 @@
+[
+ {
+ "title": "Conversation 1. Web Search",
+ "create_time": 1704629915.775304,
+ "update_time": 1704717442.442031,
+ "mapping": {
+ "6d251922-28a1-48a5-af9f-687fab4184a8": {
+ "id": "6d251922-28a1-48a5-af9f-687fab4184a8",
+ "message": {
+ "id": "6d251922-28a1-48a5-af9f-687fab4184a8",
+ "author": {
+ "role": "system",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": null,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": [""]
+ },
+ "status": "finished_successfully",
+ "end_turn": true,
+ "weight": 0.0,
+ "metadata": {
+ "is_visually_hidden_from_conversation": true
+ },
+ "recipient": "all"
+ },
+ "parent": "7a4306a5-8df1-4e69-9469-98e7619801db",
+ "children": ["bbb277e8-11d0-44f4-86c9-01dc3027228a"]
+ },
+ "7a4306a5-8df1-4e69-9469-98e7619801db": {
+ "id": "7a4306a5-8df1-4e69-9469-98e7619801db",
+ "message": null,
+ "parent": null,
+ "children": ["6d251922-28a1-48a5-af9f-687fab4184a8"]
+ },
+ "bbb277e8-11d0-44f4-86c9-01dc3027228a": {
+ "id": "bbb277e8-11d0-44f4-86c9-01dc3027228a",
+ "message": {
+ "id": "bbb277e8-11d0-44f4-86c9-01dc3027228a",
+ "author": {
+ "role": "user",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1704629915.776371,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": ["What is the fuel consumption of vw transporter with 8 people in l/km"]
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 1.0,
+ "metadata": {
+ "finish_details": {
+ "type": "unknown"
+ },
+ "citations": [],
+ "voice_mode_message": false,
+ "timestamp_": "absolute",
+ "message_type": null
+ },
+ "recipient": "all"
+ },
+ "parent": "6d251922-28a1-48a5-af9f-687fab4184a8",
+ "children": ["412dd50f-40c9-4f21-9102-fe148eb41a0b"]
+ },
+ "412dd50f-40c9-4f21-9102-fe148eb41a0b": {
+ "id": "412dd50f-40c9-4f21-9102-fe148eb41a0b",
+ "message": {
+ "id": "412dd50f-40c9-4f21-9102-fe148eb41a0b",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1704629939.839052,
+ "update_time": null,
+ "content": {
+ "content_type": "code",
+ "language": "unknown",
+ "text": "search(\"Volkswagen Transporter fuel consumption with 8 people l/km\")"
+ },
+ "status": "finished_successfully",
+ "end_turn": false,
+ "weight": 1.0,
+ "metadata": {
+ "finish_details": {
+ "type": "stop",
+ "stop_tokens": [100265]
+ },
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "gpt-4",
+ "parent_id": "bbb277e8-11d0-44f4-86c9-01dc3027228a",
+ "timestamp_": "absolute"
+ },
+ "recipient": "browser"
+ },
+ "parent": "bbb277e8-11d0-44f4-86c9-01dc3027228a",
+ "children": ["374bbcc8-2013-4387-8cd8-3e64abbd60ca"]
+ },
+ "374bbcc8-2013-4387-8cd8-3e64abbd60ca": {
+ "id": "374bbcc8-2013-4387-8cd8-3e64abbd60ca",
+ "message": {
+ "id": "374bbcc8-2013-4387-8cd8-3e64abbd60ca",
+ "author": {
+ "role": "tool",
+ "name": "browser",
+ "metadata": {}
+ },
+ "create_time": 1704629939.840484,
+ "update_time": null,
+ "content": {
+ "content_type": "tether_browsing_display",
+ "result": "# \u30100\u2020Volkswagen Transporter MPG - Actual MPG from 528 Volkswagen ... - Fuelly\u2020www.fuelly.com\u3011\n528 Volkswagen Transporters have provided 7.3 million miles of real world fuel economy & MPG data. Click here to view all the Volkswagen Transporters currently participating in our fuel tracking program. 2020. 22.0 Avg MPG. 3 Vehicles.\n# \u30101\u20202020 Volkswagen Transporter MPG - Actual MPG from 3 2020 ... - Fuelly\u2020www.fuelly.com\u3011\n2020 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Dec 2020 \u2022 60 Fuel-ups. Property of oleg_r_vitvitskiy . 21.8 Avg MPG. ... A simple & effecive way to track fuel consumption Easy to understand the real cost of your vehicle. Benefits.\n# \u30102\u20202022 Volkswagen Multivan (T7) specs, Fuel consumption, Dimensions\u2020www.auto-data.net\u3011\nVolkswagen Multivan (T7) | Technical Specs, Fuel consumption, Space, Volume and weights, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size ... 9.4 sec, 0-60 mph: 8.9 sec Fuel consumption: 7.6-7.7 l/100 km | 31 - 31 US mpg | 37 - 37 UK mpg | 13 - 13 km/l: 2.0 TDI (150 Hp) DSG 2022 - Maximum ...\n# \u30103\u2020Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Specs\u2020www.ultimatespecs.com\u3011\n41 MPG 5.7 L/100 km 50 MPG UK: Fuel Consumption - Economy - City: 31 MPG 7.5 L/100 km 38 MPG UK: Range : 679 miles / 1093 km: Fuel Tank Capacity : ... The 2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP top speed is 155 Km/h / 96 mph. Is 2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP All Wheel Drive (AWD)? No, the 2021 Volkswagen ...\n# \u30104\u2020Gas Mileage of 2021 Vehicles by Volkswagen - FuelEconomy.gov\u2020www.fueleconomy.gov\u3011\nHighway MPG: 23. highway. 5.3 gals/ 100 miles. 2021 Volkswagen Atlas 4 cyl, 2.0 L, Automatic (S8) Regular Gasoline. Not Available.\n# \u30105\u20202019 Volkswagen Transporter MPG - Actual MPG from 6 2019 ... - Fuelly\u2020www.fuelly.com\u3011\n2019 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Oct 2020 \u2022 12 Fuel-ups. Property of Amarokian . 27.0 Avg MPG. Recent Activity. from other Volkswagen Transporter vehicles . ... Get an accurate view of your vehicles fuel economy;\n# \u30106\u2020Volkswagen Transporter van review (2023) - Parkers\u2020www.parkers.co.uk\u3011\nThis 2023 VW Transporter review covers the T6 and T6.1 versions of this popular medium van, originally launched in 2015 then updated with a major facelift (known as the Transporter 6.1) in 2019.. And while this sixth-generation model is closely related to the previous VW Transporter T5 under the skin, significant gains have been made in the areas of running costs, driver comfort and safety.\n# \u30107\u2020Volkswagen Transporter Review - Drive\u2020www.drive.com.au\u3011\nHelping the Volkswagen Transporter claim a combined cycle fuel consumption figure of 8.2 litres per 100km, the seven-speed auto is also up by between one and three ratios on its main rivals ...\n# \u30108\u2020VW Van Fuel Consumption and Residual Values | VW Vans - Volkswagen Vans\u2020www.volkswagen-vans.co.uk\u3011\nResidual value after 3 year/ 60,000 miles \u20602. Volkswagen Crafter CR30 Startline MWB FWD 2.0 TDI 102 PS. \u00a37,975 / 31.44 % \u2060. 2. Mercedes-Benz Sprinter Light Commercial 316 L1 3.5t 2.1CDi 163. \u00a39,750 / 29.53% \u2060. 2. Ford Transit Light Commercial 290 L2 2.0EcoBlue 130 Trend Medium Roof. \u00a38,350 / 29.35% \u2060.\n# \u30109\u2020Volkswagen | Technical Specs, Fuel consumption, Dimensions\u2020www.auto-data.net\u3011\nThe 1.8 T has 5 valves per cylinder - 3 intake and 2 exhaust. Dacia Ford GMC Haval Honda Hummer Jaguar Jeep Koenigsegg Lada Lamborghini Lancia Lexus Maserati. Volkswagen | Technical Specs, Fuel consumption, Dimensions, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size, Body type, Doors, Seats.\n# \u301010\u20202018 Volkswagen Transporter MPG - Actual MPG from 15 2018 ... - Fuelly\u2020www.fuelly.com\u3011\n2018 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Jul 2018 \u2022 101 Fuel-ups. Property of hwarang73 . 23.1 Avg MPG. Pinky. ... A simple & effecive way to track fuel consumption Easy to understand the real cost of your vehicle. Benefits.\n# \u301011\u2020Gas Mileage of 2022 Vehicles by Volkswagen - FuelEconomy.gov\u2020www.fueleconomy.gov\u3011\nWe can help you calculate and track your fuel economy. MPG Estimates from Others; MPG estimates from drivers like you! Advanced Cars & Fuels. ... 2022 Volkswagen Taos 4 cyl, 1.5 L, Automatic 8-spd Regular Gasoline: View Estimates How can I share my MPG? Combined MPG: 31. combined. city/highway. MPG. City MPG: 28. city. Highway MPG: 36.\n# \u301012\u20202021 Volkswagen Multivan (T7) 1.4 eHybrid (218 Hp) DSG\u2020www.auto-data.net\u3011\nWhat is the fuel economy, Volkswagen Multivan (T7) 1.4 eHybrid (218 Hp) DSG? 1.5 l/100 km 156.81 US mpg 188.32 UK mpg 66.67 km/l: How ECO is the car, Volkswagen Multivan 1.4 eHybrid (218 Hp) DSG? 34 g/km CO 2 Euro 6d-ISC-FCM: What is the range of pure electric driving, 1.4 eHybrid (218 Hp) DSG?\n# \u301013\u2020Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Specs\u2020www.ultimatespecs.com\u3011\n35 MPG 6.7 L/100 km 42 MPG UK: Fuel Consumption - Economy - Open road: 40 MPG 5.9 L/100 km 48 MPG UK: Fuel Consumption - Economy - City: 30 ... The Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP top speed is 182 Km/h / 113 mph. Is Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP All Wheel Drive (AWD)? No, the Volkswagen Transporter T6.1 L2H1 2.0 ...\n# \u301014\u2020Volkswagen Transporter Review (2024) | Autocar\u2020www.autocar.co.uk\u3011\nStill, a van\u2019s a van and, ergonomically at least, this one doesn\u2019t do much differently from any other. Auxiliary heating system for the second-row seats is a \u00a3330 option, controlled from this ...\n# \u301015\u20202019 Volkswagen Multivan (T6) 2.0 TDI (150 Hp)\u2020www.auto-data.net\u3011\n14.49 - 13.89 km/l: How ECO is the car, Volkswagen Multivan 2.0 TDI (150 Hp)? 181-189 g/km CO 2 Euro 6d-Temp: How fast is the car, 2019 Multivan (T6) 2.0 TDI (150 Hp)? ... 16.39 - 15.87 km/l: Fuel consumption (economy) - combined: 6.9-7.2 l/100 km 34.09 - 32.67 US mpg 40.94 - 39.23 UK mpg 14.49 - 13.89 km/l: CO 2 emissions: 181-189 g/km : Fuel ...\n# \u301016\u2020Fuel Capacity & Consumption Of A Volkswagen (All Models) - Vehicle Help\u2020vehiclehelp.com\u3011\nThe average fuel capacity of all Volkswagen vehicles is 17.2 gallons, and the average consumption is 38 MPG. The most fuel-efficient model is the Volkswagen ID.4, and the Volkswagen Atlas has the worst fuel economy. Depending on your requirements, you can buy a smaller, lighter-on-fuel Volkswagen or a bigger, heavier-on-fuel model.\n# \u301017\u2020Volkswagen Type 2 - Wikipedia\u2020en.wikipedia.org\u3011\nThe Volkswagen Type 2 is a forward control light commercial vehicle introduced in 1950 by the German automaker Volkswagen as its second car model.Known officially (depending on body type) as the Transporter, Kombi or Microbus, or, informally, as the Volkswagen Station Wagon (US), Bus (also US), Camper (UK) or Bulli (Germany), it was given the factory designation Type 2 as it followed \u2013 and ...\n# \u301018\u20202025 Volkswagen ID.Buzz: What We Know So Far - Car and Driver\u2020www.caranddriver.com\u3011\nPricing and Which One to Buy. The price of the 2025 Volkswagen ID.Buzz is expected to start around $40,000. ID.Buzz. 0 $10k $20k $30k $40k $50k $60k. VW hasn't said how much the ID.Buzz will cost ...\n# \u301019\u2020Boom and Crisis in the One-Product Business - Volkswagen Group\u2020www.volkswagen-group.com\u3011\nFitted out as a 1-tonne delivery van, an estate with a 5 cubic metre loading volume or with seating for up to 9 people, the Transporter is still a much in-demand master of versatility. Its rear-mounted 47 horsepower boxer engine achieves a top speed of 105 km/h, while fuel consumption of 10.4 litres per 100 kilometres keeps running costs down.\nVisible: 0% - 100%",
+ "summary": null,
+ "assets": []
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 0.0,
+ "metadata": {
+ "_cite_metadata": {
+ "citation_format": {
+ "name": "tether_og"
+ },
+ "metadata_list": [
+ {
+ "type": "webpage",
+ "title": "Volkswagen Transporter MPG - Actual MPG from 528 Volkswagen ... - Fuelly",
+ "url": "https://www.fuelly.com/car/volkswagen/transporter",
+ "text": "\n528 Volkswagen Transporters have provided 7.3 million miles of real world fuel economy & MPG data. Click here to view all the Volkswagen Transporters currently participating in our fuel tracking program. 2020. 22.0 Avg MPG. 3 Vehicles.\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "2020 Volkswagen Transporter MPG - Actual MPG from 3 2020 ... - Fuelly",
+ "url": "https://www.fuelly.com/car/volkswagen/transporter/2020",
+ "text": "\n2020 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Dec 2020 \u2022 60 Fuel-ups. Property of oleg_r_vitvitskiy . 21.8 Avg MPG. ... A simple & effecive way to track fuel consumption Easy to understand the real cost of your vehicle. Benefits.\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "2022 Volkswagen Multivan (T7) specs, Fuel consumption, Dimensions",
+ "url": "https://www.auto-data.net/en/volkswagen-multivan-t7-generation-8578",
+ "text": "\nVolkswagen Multivan (T7) | Technical Specs, Fuel consumption, Space, Volume and weights, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size ... 9.4 sec, 0-60 mph: 8.9 sec Fuel consumption: 7.6-7.7 l/100 km | 31 - 31 US mpg | 37 - 37 UK mpg | 13 - 13 km/l: 2.0 TDI (150 Hp) DSG 2022 - Maximum ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Specs",
+ "url": "https://www.ultimatespecs.com/car-specs/Volkswagen/118424/2021-Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html",
+ "text": "\n41 MPG 5.7 L/100 km 50 MPG UK: Fuel Consumption - Economy - City: 31 MPG 7.5 L/100 km 38 MPG UK: Range : 679 miles / 1093 km: Fuel Tank Capacity : ... The 2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP top speed is 155 Km/h / 96 mph. Is 2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP All Wheel Drive (AWD)? No, the 2021 Volkswagen ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Gas Mileage of 2021 Vehicles by Volkswagen - FuelEconomy.gov",
+ "url": "https://www.fueleconomy.gov/feg/bymake/Volkswagen2021.shtml",
+ "text": "\nHighway MPG: 23. highway. 5.3 gals/ 100 miles. 2021 Volkswagen Atlas 4 cyl, 2.0 L, Automatic (S8) Regular Gasoline. Not Available.\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "2019 Volkswagen Transporter MPG - Actual MPG from 6 2019 ... - Fuelly",
+ "url": "https://www.fuelly.com/car/volkswagen/transporter/2019",
+ "text": "\n2019 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Oct 2020 \u2022 12 Fuel-ups. Property of Amarokian . 27.0 Avg MPG. Recent Activity. from other Volkswagen Transporter vehicles . ... Get an accurate view of your vehicles fuel economy;\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen Transporter van review (2023) - Parkers",
+ "url": "https://www.parkers.co.uk/vans-pickups/volkswagen/transporter/2015-review/",
+ "text": "\nThis 2023 VW Transporter review covers the T6 and T6.1 versions of this popular medium van, originally launched in 2015 then updated with a major facelift (known as the Transporter 6.1) in 2019.. And while this sixth-generation model is closely related to the previous VW Transporter T5 under the skin, significant gains have been made in the areas of running costs, driver comfort and safety.\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen Transporter Review - Drive",
+ "url": "https://www.drive.com.au/reviews/volkswagen-transporter-review/",
+ "text": "\nHelping the Volkswagen Transporter claim a combined cycle fuel consumption figure of 8.2 litres per 100km, the seven-speed auto is also up by between one and three ratios on its main rivals ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "VW Van Fuel Consumption and Residual Values | VW Vans - Volkswagen Vans",
+ "url": "https://www.volkswagen-vans.co.uk/en/finance-offers-and-fleet/fleet/van-life-costs.html",
+ "text": "\nResidual value after 3 year/ 60,000 miles \u20602. Volkswagen Crafter CR30 Startline MWB FWD 2.0 TDI 102 PS. \u00a37,975 / 31.44 % \u2060. 2. Mercedes-Benz Sprinter Light Commercial 316 L1 3.5t 2.1CDi 163. \u00a39,750 / 29.53% \u2060. 2. Ford Transit Light Commercial 290 L2 2.0EcoBlue 130 Trend Medium Roof. \u00a38,350 / 29.35% \u2060.\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen | Technical Specs, Fuel consumption, Dimensions",
+ "url": "https://www.auto-data.net/en/volkswagen-brand-80",
+ "text": "\nThe 1.8 T has 5 valves per cylinder - 3 intake and 2 exhaust. Dacia Ford GMC Haval Honda Hummer Jaguar Jeep Koenigsegg Lada Lamborghini Lancia Lexus Maserati. Volkswagen | Technical Specs, Fuel consumption, Dimensions, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size, Body type, Doors, Seats.\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "2018 Volkswagen Transporter MPG - Actual MPG from 15 2018 ... - Fuelly",
+ "url": "https://www.fuelly.com/car/volkswagen/transporter/2018",
+ "text": "\n2018 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Jul 2018 \u2022 101 Fuel-ups. Property of hwarang73 . 23.1 Avg MPG. Pinky. ... A simple & effecive way to track fuel consumption Easy to understand the real cost of your vehicle. Benefits.\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Gas Mileage of 2022 Vehicles by Volkswagen - FuelEconomy.gov",
+ "url": "https://www.fueleconomy.gov/feg/bymake/Volkswagen2022.shtml",
+ "text": "\nWe can help you calculate and track your fuel economy. MPG Estimates from Others; MPG estimates from drivers like you! Advanced Cars & Fuels. ... 2022 Volkswagen Taos 4 cyl, 1.5 L, Automatic 8-spd Regular Gasoline: View Estimates How can I share my MPG? Combined MPG: 31. combined. city/highway. MPG. City MPG: 28. city. Highway MPG: 36.\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "2021 Volkswagen Multivan (T7) 1.4 eHybrid (218 Hp) DSG",
+ "url": "https://www.auto-data.net/en/volkswagen-multivan-t7-1.4-ehybrid-218hp-dsg-45272",
+ "text": "\nWhat is the fuel economy, Volkswagen Multivan (T7) 1.4 eHybrid (218 Hp) DSG? 1.5 l/100 km 156.81 US mpg 188.32 UK mpg 66.67 km/l: How ECO is the car, Volkswagen Multivan 1.4 eHybrid (218 Hp) DSG? 34 g/km CO 2 Euro 6d-ISC-FCM: What is the range of pure electric driving, 1.4 eHybrid (218 Hp) DSG?\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Specs",
+ "url": "https://www.ultimatespecs.com/car-specs/Volkswagen/118425/Volkswagen-Transporter-T61-L2H1-20-TDI-150HP.html",
+ "text": "\n35 MPG 6.7 L/100 km 42 MPG UK: Fuel Consumption - Economy - Open road: 40 MPG 5.9 L/100 km 48 MPG UK: Fuel Consumption - Economy - City: 30 ... The Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP top speed is 182 Km/h / 113 mph. Is Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP All Wheel Drive (AWD)? No, the Volkswagen Transporter T6.1 L2H1 2.0 ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen Transporter Review (2024) | Autocar",
+ "url": "https://www.autocar.co.uk/car-review/volkswagen/transporter",
+ "text": "\nStill, a van\u2019s a van and, ergonomically at least, this one doesn\u2019t do much differently from any other. Auxiliary heating system for the second-row seats is a \u00a3330 option, controlled from this ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "2019 Volkswagen Multivan (T6) 2.0 TDI (150 Hp)",
+ "url": "https://www.auto-data.net/en/volkswagen-multivan-t6-2.0-tdi-150hp-36051",
+ "text": "\n14.49 - 13.89 km/l: How ECO is the car, Volkswagen Multivan 2.0 TDI (150 Hp)? 181-189 g/km CO 2 Euro 6d-Temp: How fast is the car, 2019 Multivan (T6) 2.0 TDI (150 Hp)? ... 16.39 - 15.87 km/l: Fuel consumption (economy) - combined: 6.9-7.2 l/100 km 34.09 - 32.67 US mpg 40.94 - 39.23 UK mpg 14.49 - 13.89 km/l: CO 2 emissions: 181-189 g/km : Fuel ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Fuel Capacity & Consumption Of A Volkswagen (All Models) - Vehicle Help",
+ "url": "https://vehiclehelp.com/fuel-capacity-consumption-of-a-volkswagen/",
+ "text": "\nThe average fuel capacity of all Volkswagen vehicles is 17.2 gallons, and the average consumption is 38 MPG. The most fuel-efficient model is the Volkswagen ID.4, and the Volkswagen Atlas has the worst fuel economy. Depending on your requirements, you can buy a smaller, lighter-on-fuel Volkswagen or a bigger, heavier-on-fuel model.\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen Type 2 - Wikipedia",
+ "url": "https://en.wikipedia.org/wiki/Volkswagen_Type_2",
+ "text": "\nThe Volkswagen Type 2 is a forward control light commercial vehicle introduced in 1950 by the German automaker Volkswagen as its second car model.Known officially (depending on body type) as the Transporter, Kombi or Microbus, or, informally, as the Volkswagen Station Wagon (US), Bus (also US), Camper (UK) or Bulli (Germany), it was given the factory designation Type 2 as it followed \u2013 and ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "2025 Volkswagen ID.Buzz: What We Know So Far - Car and Driver",
+ "url": "https://www.caranddriver.com/volkswagen/id-buzz-microbus",
+ "text": "\nPricing and Which One to Buy. The price of the 2025 Volkswagen ID.Buzz is expected to start around $40,000. ID.Buzz. 0 $10k $20k $30k $40k $50k $60k. VW hasn't said how much the ID.Buzz will cost ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Boom and Crisis in the One-Product Business - Volkswagen Group",
+ "url": "https://www.volkswagen-group.com/en/volkswagen-chronicle-17351/1961-to-1972-boom-and-crisis-in-the-one-product-business-17357",
+ "text": "\nFitted out as a 1-tonne delivery van, an estate with a 5 cubic metre loading volume or with seating for up to 9 people, the Transporter is still a much in-demand master of versatility. Its rear-mounted 47 horsepower boxer engine achieves a top speed of 105 km/h, while fuel consumption of 10.4 litres per 100 kilometres keeps running costs down.\nVisible: 0% - 100%",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Search results for query: 'Volkswagen Transporter fuel consumption with 8 people l/km'",
+ "url": "",
+ "text": "# \u30100\u2020Volkswagen Transporter MPG - Actual MPG from 528 Volkswagen ... - Fuelly\u2020www.fuelly.com\u3011\n528 Volkswagen Transporters have provided 7.3 million miles of real world fuel economy & MPG data. Click here to view all the Volkswagen Transporters currently participating in our fuel tracking program. 2020. 22.0 Avg MPG. 3 Vehicles.\n# \u30101\u20202020 Volkswagen Transporter MPG - Actual MPG from 3 2020 ... - Fuelly\u2020www.fuelly.com\u3011\n2020 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Dec 2020 \u2022 60 Fuel-ups. Property of oleg_r_vitvitskiy . 21.8 Avg MPG. ... A simple & effecive way to track fuel consumption Easy to understand the real cost of your vehicle. Benefits.\n# \u30102\u20202022 Volkswagen Multivan (T7) specs, Fuel consumption, Dimensions\u2020www.auto-data.net\u3011\nVolkswagen Multivan (T7) | Technical Specs, Fuel consumption, Space, Volume and weights, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size ... 9.4 sec, 0-60 mph: 8.9 sec Fuel consumption: 7.6-7.7 l/100 km | 31 - 31 US mpg | 37 - 37 UK mpg | 13 - 13 km/l: 2.0 TDI (150 Hp) DSG 2022 - Maximum ...\n# \u30103\u2020Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Specs\u2020www.ultimatespecs.com\u3011\n41 MPG 5.7 L/100 km 50 MPG UK: Fuel Consumption - Economy - City: 31 MPG 7.5 L/100 km 38 MPG UK: Range : 679 miles / 1093 km: Fuel Tank Capacity : ... The 2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP top speed is 155 Km/h / 96 mph. Is 2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP All Wheel Drive (AWD)? No, the 2021 Volkswagen ...\n# \u30104\u2020Gas Mileage of 2021 Vehicles by Volkswagen - FuelEconomy.gov\u2020www.fueleconomy.gov\u3011\nHighway MPG: 23. highway. 5.3 gals/ 100 miles. 2021 Volkswagen Atlas 4 cyl, 2.0 L, Automatic (S8) Regular Gasoline. Not Available.\n# \u30105\u20202019 Volkswagen Transporter MPG - Actual MPG from 6 2019 ... - Fuelly\u2020www.fuelly.com\u3011\n2019 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Oct 2020 \u2022 12 Fuel-ups. Property of Amarokian . 27.0 Avg MPG. Recent Activity. from other Volkswagen Transporter vehicles . ... Get an accurate view of your vehicles fuel economy;\n# \u30106\u2020Volkswagen Transporter van review (2023) - Parkers\u2020www.parkers.co.uk\u3011\nThis 2023 VW Transporter review covers the T6 and T6.1 versions of this popular medium van, originally launched in 2015 then updated with a major facelift (known as the Transporter 6.1) in 2019.. And while this sixth-generation model is closely related to the previous VW Transporter T5 under the skin, significant gains have been made in the areas of running costs, driver comfort and safety.\n# \u30107\u2020Volkswagen Transporter Review - Drive\u2020www.drive.com.au\u3011\nHelping the Volkswagen Transporter claim a combined cycle fuel consumption figure of 8.2 litres per 100km, the seven-speed auto is also up by between one and three ratios on its main rivals ...\n# \u30108\u2020VW Van Fuel Consumption and Residual Values | VW Vans - Volkswagen Vans\u2020www.volkswagen-vans.co.uk\u3011\nResidual value after 3 year/ 60,000 miles \u20602. Volkswagen Crafter CR30 Startline MWB FWD 2.0 TDI 102 PS. \u00a37,975 / 31.44 % \u2060. 2. Mercedes-Benz Sprinter Light Commercial 316 L1 3.5t 2.1CDi 163. \u00a39,750 / 29.53% \u2060. 2. Ford Transit Light Commercial 290 L2 2.0EcoBlue 130 Trend Medium Roof. \u00a38,350 / 29.35% \u2060.\n# \u30109\u2020Volkswagen | Technical Specs, Fuel consumption, Dimensions\u2020www.auto-data.net\u3011\nThe 1.8 T has 5 valves per cylinder - 3 intake and 2 exhaust. Dacia Ford GMC Haval Honda Hummer Jaguar Jeep Koenigsegg Lada Lamborghini Lancia Lexus Maserati. Volkswagen | Technical Specs, Fuel consumption, Dimensions, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size, Body type, Doors, Seats.\n# \u301010\u20202018 Volkswagen Transporter MPG - Actual MPG from 15 2018 ... - Fuelly\u2020www.fuelly.com\u3011\n2018 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Jul 2018 \u2022 101 Fuel-ups. Property of hwarang73 . 23.1 Avg MPG. Pinky. ... A simple & effecive way to track fuel consumption Easy to understand the real cost of your vehicle. Benefits.\n# \u301011\u2020Gas Mileage of 2022 Vehicles by Volkswagen - FuelEconomy.gov\u2020www.fueleconomy.gov\u3011\nWe can help you calculate and track your fuel economy. MPG Estimates from Others; MPG estimates from drivers like you! Advanced Cars & Fuels. ... 2022 Volkswagen Taos 4 cyl, 1.5 L, Automatic 8-spd Regular Gasoline: View Estimates How can I share my MPG? Combined MPG: 31. combined. city/highway. MPG. City MPG: 28. city. Highway MPG: 36.\n# \u301012\u20202021 Volkswagen Multivan (T7) 1.4 eHybrid (218 Hp) DSG\u2020www.auto-data.net\u3011\nWhat is the fuel economy, Volkswagen Multivan (T7) 1.4 eHybrid (218 Hp) DSG? 1.5 l/100 km 156.81 US mpg 188.32 UK mpg 66.67 km/l: How ECO is the car, Volkswagen Multivan 1.4 eHybrid (218 Hp) DSG? 34 g/km CO 2 Euro 6d-ISC-FCM: What is the range of pure electric driving, 1.4 eHybrid (218 Hp) DSG?\n# \u301013\u2020Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Specs\u2020www.ultimatespecs.com\u3011\n35 MPG 6.7 L/100 km 42 MPG UK: Fuel Consumption - Economy - Open road: 40 MPG 5.9 L/100 km 48 MPG UK: Fuel Consumption - Economy - City: 30 ... The Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP top speed is 182 Km/h / 113 mph. Is Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP All Wheel Drive (AWD)? No, the Volkswagen Transporter T6.1 L2H1 2.0 ...\n# \u301014\u2020Volkswagen Transporter Review (2024) | Autocar\u2020www.autocar.co.uk\u3011\nStill, a van\u2019s a van and, ergonomically at least, this one doesn\u2019t do much differently from any other. Auxiliary heating system for the second-row seats is a \u00a3330 option, controlled from this ...\n# \u301015\u20202019 Volkswagen Multivan (T6) 2.0 TDI (150 Hp)\u2020www.auto-data.net\u3011\n14.49 - 13.89 km/l: How ECO is the car, Volkswagen Multivan 2.0 TDI (150 Hp)? 181-189 g/km CO 2 Euro 6d-Temp: How fast is the car, 2019 Multivan (T6) 2.0 TDI (150 Hp)? ... 16.39 - 15.87 km/l: Fuel consumption (economy) - combined: 6.9-7.2 l/100 km 34.09 - 32.67 US mpg 40.94 - 39.23 UK mpg 14.49 - 13.89 km/l: CO 2 emissions: 181-189 g/km : Fuel ...\n# \u301016\u2020Fuel Capacity & Consumption Of A Volkswagen (All Models) - Vehicle Help\u2020vehiclehelp.com\u3011\nThe average fuel capacity of all Volkswagen vehicles is 17.2 gallons, and the average consumption is 38 MPG. The most fuel-efficient model is the Volkswagen ID.4, and the Volkswagen Atlas has the worst fuel economy. Depending on your requirements, you can buy a smaller, lighter-on-fuel Volkswagen or a bigger, heavier-on-fuel model.\n# \u301017\u2020Volkswagen Type 2 - Wikipedia\u2020en.wikipedia.org\u3011\nThe Volkswagen Type 2 is a forward control light commercial vehicle introduced in 1950 by the German automaker Volkswagen as its second car model.Known officially (depending on body type) as the Transporter, Kombi or Microbus, or, informally, as the Volkswagen Station Wagon (US), Bus (also US), Camper (UK) or Bulli (Germany), it was given the factory designation Type 2 as it followed \u2013 and ...\n# \u301018\u20202025 Volkswagen ID.Buzz: What We Know So Far - Car and Driver\u2020www.caranddriver.com\u3011\nPricing and Which One to Buy. The price of the 2025 Volkswagen ID.Buzz is expected to start around $40,000. ID.Buzz. 0 $10k $20k $30k $40k $50k $60k. VW hasn't said how much the ID.Buzz will cost ...\n# \u301019\u2020Boom and Crisis in the One-Product Business - Volkswagen Group\u2020www.volkswagen-group.com\u3011\nFitted out as a 1-tonne delivery van, an estate with a 5 cubic metre loading volume or with seating for up to 9 people, the Transporter is still a much in-demand master of versatility. Its rear-mounted 47 horsepower boxer engine achieves a top speed of 105 km/h, while fuel consumption of 10.4 litres per 100 kilometres keeps running costs down.\nVisible: 0% - 100%",
+ "pub_date": null,
+ "extra": null
+ }
+ ],
+ "original_query": null
+ },
+ "command": "search",
+ "args": ["Volkswagen Transporter fuel consumption with 8 people l/km"],
+ "status": "finished",
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "gpt-4",
+ "parent_id": "412dd50f-40c9-4f21-9102-fe148eb41a0b",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "412dd50f-40c9-4f21-9102-fe148eb41a0b",
+ "children": ["36b62905-c942-414a-8ad2-d5d26739efa5"]
+ },
+ "36b62905-c942-414a-8ad2-d5d26739efa5": {
+ "id": "36b62905-c942-414a-8ad2-d5d26739efa5",
+ "message": {
+ "id": "36b62905-c942-414a-8ad2-d5d26739efa5",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1704629939.845544,
+ "update_time": null,
+ "content": {
+ "content_type": "code",
+ "language": "unknown",
+ "text": "mclick([0, 3, 7])"
+ },
+ "status": "finished_successfully",
+ "end_turn": false,
+ "weight": 1.0,
+ "metadata": {
+ "finish_details": {
+ "type": "stop",
+ "stop_tokens": [100265]
+ },
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "gpt-4",
+ "parent_id": "374bbcc8-2013-4387-8cd8-3e64abbd60ca",
+ "timestamp_": "absolute"
+ },
+ "recipient": "browser"
+ },
+ "parent": "374bbcc8-2013-4387-8cd8-3e64abbd60ca",
+ "children": ["b87c7f57-a6f4-4f4f-999f-38bd70981ae0"]
+ },
+ "b87c7f57-a6f4-4f4f-999f-38bd70981ae0": {
+ "id": "b87c7f57-a6f4-4f4f-999f-38bd70981ae0",
+ "message": {
+ "id": "b87c7f57-a6f4-4f4f-999f-38bd70981ae0",
+ "author": {
+ "role": "tool",
+ "name": "browser",
+ "metadata": {}
+ },
+ "create_time": 1704629939.849366,
+ "update_time": null,
+ "content": {
+ "content_type": "tether_quote",
+ "url": "https://www.ultimatespecs.com/car-specs/Volkswagen/118424/2021-Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html",
+ "domain": "www.ultimatespecs.com",
+ "text": "\nURL: https://www.ultimatespecs.com/car-specs/Volkswagen/118424/2021-Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html\n\n\n[Image 0: English] \n\n\u30100\u2020 English \u3011 \u30101\u2020 Fran\u00e7ais \u3011 \u30102\u2020 Deutsch \u3011 \u30103\u2020 Svensk \u3011 \u30104\u2020 Portugu\u00eas PT \u3011 \u30105\u2020 Espa\u00f1ol \u3011 \u30106\u2020 \u0440\u0443\u0441\u0441\u043a\u0438\u0439 \u3011 \u30107\u2020 Italiano \u3011 \u30108\u2020 \u0431\u044a\u043b\u0433\u0430\u0440\u0441\u043a\u0438 \u3011 \u30109\u2020 Nederlands \u3011 \u301010\u2020 Polski \u3011 \u301011\u2020 Portugu\u00eas BR \u3011 \u301012\u2020 T\u00fcrk\u00e7e \u3011 \n\n\u301013\u2020 \u3011 \n\n\u301013\u2020 Cars \u3011 \n\n\u301013\u2020 Cars \u3011 \n\n\u301014\u2020 Motos \u3011 \n\n\u301015\u2020 Tractors \u3011 \n\n \u301016\u2020 \u2020www.facebook.com\u3011 \u301017\u2020 \u2020twitter.com\u3011 \n\n< Back \n\n * * \u301018\u2020 Cars \u3011 \n * \u301019\u2020 Electric & Hybrid Cars \u3011 \n * \u301020\u2020 Compare cars \u3011 \n * \u301021\u2020 Car Images \u3011 \n * \u301022\u2020 Advanced Search \u3011 \n\n[Image 1: menu] \n\nIt seems that you have reached a high volume of page views. \nPlease confirm that you are a human by clicking the box below. \n\nThank you! \n\n Send \n\n## Latest Car Specs\n\n\u301023\u20202023 Cupra Formentor VZ5 2.5 TSI 4Drive\u3011\u301024\u20202023 BMW G21 3 Series Touring LCI 318i Auto\u3011\u301025\u20202024 Lexus LBX 1.5 Hybrid E-Four e-CVT\u3011\u301026\u20202024 Lexus LBX 1.5 Hybrid e-CVT\u3011\n\n\u301027\u20201983 Buick Electra Coupe 1980 Limited 5.0L V8 4-speed Auto\u3011\u301028\u20201981 Buick Electra Coupe 1980 Limited 4.1 V6 4-speed Auto\u3011\u301029\u20201980 Buick Electra Coupe 1980 Limited 4.1 V6 Overdrive 4-speed Auto\u3011\u301030\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 100HP Hybrid DCT\u3011\n\n\u301031\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 100HP Hybrid Auto\u3011\u301032\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 120HP Hybrid DCT\u3011\u301033\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 120HP Hybrid Auto\u3011\u301034\u20202023 Hyundai i20 (BC3) 2023 1.0 T-GDI 48V-Hybrid DCT\u3011\n\n\u301035\u20202023 Hyundai i20 (BC3) 2023 1.0 T-GDI\u3011\u301036\u20202023 Hyundai i20 (BC3) 2023 1.2 MPI\u3011\u301037\u20202023 BYD Seal 83 kWh 530HP AWD\u3011\u301038\u20202023 BYD Seal 83 kWh 313HP\u3011\n\n\u301039\u20202023 BYD Dolphin 60 kWh 204HP\u3011\u301040\u20201992 Alfa Romeo 164 Super V6 Turbo\u3011\u301041\u20202023 BMW G20 3 Series Sedan LCI M340i Mild Hybrid xDrive Auto\u3011\u301042\u20202023 BMW G20 3 Series Sedan LCI 330i xDrive Auto\u3011\n\n\u301043\u20202023 BMW G20 3 Series Sedan LCI 330i Auto\u3011\u301044\u20202023 BMW G20 3 Series Sedan LCI 320i xDrive Auto\u3011\u301045\u20202023 BMW G20 3 Series Sedan LCI 320i Auto\u3011\u301046\u20202023 BMW G20 3 Series Sedan LCI 318i Auto\u3011\n\n\u301047\u20201976 Ford Pinto 2-Door Sedan 1977 2.8 V6 Cruise-O-Matic\u3011\u301048\u20201976 Ford Pinto 2-Door Sedan 1977 2.3 Cruise-O-Matic\u3011\u301049\u20201976 Ford Pinto 2-Door Sedan 1977 2.3\u3011\u301050\u2020View more\u2020ultimatespecs.com\u3011\n\n## Latest Models\n\n\u301051\u2020BMW G21 3 Series Touring LCI\u3011\u301052\u2020Lexus LBX\u3011\u301053\u2020Hyundai i20 (BC3) 2023\u3011\u301054\u2020Hyundai i20 (BC3)\u3011\n\n\u301055\u2020BYD Seal\u3011\u301056\u2020BYD Dolphin\u3011\u301057\u2020BMW G20 3 Series Sedan LCI\u3011\u301058\u2020Ford Pinto 2-Door Sedan 1977\u3011\n\n\u301059\u2020Buick Electra Coupe 1980\u3011\u301060\u2020Ford Pinto 2-Door Sedan 1976\u3011\u301061\u2020Citroen C3 Phase IV\u3011\u301062\u2020Citroen C5 X\u3011\n\n\u301063\u2020Ford Pinto 2-Door Sedan 1975\u3011\u301064\u2020Lancia Delta\u3011\u301065\u2020Ford Pinto 2-Door Sedan 1974\u3011\u301066\u2020Ford Pinto 2-Door Sedan 1973\u3011\n\n\u301067\u2020Peugeot 408\u3011\u301068\u2020Ford Pinto 2-Door Sedan 1972\u3011\u301069\u2020Renault Sc\u00e9nic 5\u3011\u301070\u2020Renault Espace 6\u3011\n\n\u301071\u2020Ford Pinto 2-Door Sedan\u3011\u301072\u2020Renault Austral\u3011\u301073\u2020Mazda RX-8 2008\u3011\u301074\u2020Bentley Bentayga EWB\u3011\n\n\u301075\u2020Bentley Bentayga 2020 Facelift\u3011\u301076\u2020Toyota GR86 2021\u3011\u301077\u2020Fiat Cinquecento\u3011\u301078\u2020Audi A8 L 2022 (D5)\u3011\n\n\u00a92024 Ultimate Specs - The Most Comprehensive Car Specifications Database. Over 46.000 technical specs!! - \u301079\u2020Change consent\u2020javascript:;\u3011 \n\n- Do not share my Personal Information.\n\n- \u301080\u2020 About \u3011- \u301081\u2020 Privacy Policy \u3011- \u301082\u2020 Contact US \u3011",
+ "title": "2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP specs, dimensions"
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 0.0,
+ "metadata": {
+ "_cite_metadata": {
+ "citation_format": {
+ "name": "tether_og"
+ },
+ "metadata_list": [
+ {
+ "type": "webpage",
+ "title": "2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP specs, dimensions",
+ "url": "https://www.ultimatespecs.com/car-specs/Volkswagen/118424/2021-Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html",
+ "text": "\nURL: https://www.ultimatespecs.com/car-specs/Volkswagen/118424/2021-Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html\n\n\n[Image 0: English] \n\n\u30100\u2020 English \u3011 \u30101\u2020 Fran\u00e7ais \u3011 \u30102\u2020 Deutsch \u3011 \u30103\u2020 Svensk \u3011 \u30104\u2020 Portugu\u00eas PT \u3011 \u30105\u2020 Espa\u00f1ol \u3011 \u30106\u2020 \u0440\u0443\u0441\u0441\u043a\u0438\u0439 \u3011 \u30107\u2020 Italiano \u3011 \u30108\u2020 \u0431\u044a\u043b\u0433\u0430\u0440\u0441\u043a\u0438 \u3011 \u30109\u2020 Nederlands \u3011 \u301010\u2020 Polski \u3011 \u301011\u2020 Portugu\u00eas BR \u3011 \u301012\u2020 T\u00fcrk\u00e7e \u3011 \n\n\u301013\u2020 \u3011 \n\n\u301013\u2020 Cars \u3011 \n\n\u301013\u2020 Cars \u3011 \n\n\u301014\u2020 Motos \u3011 \n\n\u301015\u2020 Tractors \u3011 \n\n \u301016\u2020 \u2020www.facebook.com\u3011 \u301017\u2020 \u2020twitter.com\u3011 \n\n< Back \n\n * * \u301018\u2020 Cars \u3011 \n * \u301019\u2020 Electric & Hybrid Cars \u3011 \n * \u301020\u2020 Compare cars \u3011 \n * \u301021\u2020 Car Images \u3011 \n * \u301022\u2020 Advanced Search \u3011 \n\n[Image 1: menu] \n\nIt seems that you have reached a high volume of page views. \nPlease confirm that you are a human by clicking the box below. \n\nThank you! \n\n Send \n\n## Latest Car Specs\n\n\u301023\u20202023 Cupra Formentor VZ5 2.5 TSI 4Drive\u3011\u301024\u20202023 BMW G21 3 Series Touring LCI 318i Auto\u3011\u301025\u20202024 Lexus LBX 1.5 Hybrid E-Four e-CVT\u3011\u301026\u20202024 Lexus LBX 1.5 Hybrid e-CVT\u3011\n\n\u301027\u20201983 Buick Electra Coupe 1980 Limited 5.0L V8 4-speed Auto\u3011\u301028\u20201981 Buick Electra Coupe 1980 Limited 4.1 V6 4-speed Auto\u3011\u301029\u20201980 Buick Electra Coupe 1980 Limited 4.1 V6 Overdrive 4-speed Auto\u3011\u301030\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 100HP Hybrid DCT\u3011\n\n\u301031\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 100HP Hybrid Auto\u3011\u301032\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 120HP Hybrid DCT\u3011\u301033\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 120HP Hybrid Auto\u3011\u301034\u20202023 Hyundai i20 (BC3) 2023 1.0 T-GDI 48V-Hybrid DCT\u3011\n\n\u301035\u20202023 Hyundai i20 (BC3) 2023 1.0 T-GDI\u3011\u301036\u20202023 Hyundai i20 (BC3) 2023 1.2 MPI\u3011\u301037\u20202023 BYD Seal 83 kWh 530HP AWD\u3011\u301038\u20202023 BYD Seal 83 kWh 313HP\u3011\n\n\u301039\u20202023 BYD Dolphin 60 kWh 204HP\u3011\u301040\u20201992 Alfa Romeo 164 Super V6 Turbo\u3011\u301041\u20202023 BMW G20 3 Series Sedan LCI M340i Mild Hybrid xDrive Auto\u3011\u301042\u20202023 BMW G20 3 Series Sedan LCI 330i xDrive Auto\u3011\n\n\u301043\u20202023 BMW G20 3 Series Sedan LCI 330i Auto\u3011\u301044\u20202023 BMW G20 3 Series Sedan LCI 320i xDrive Auto\u3011\u301045\u20202023 BMW G20 3 Series Sedan LCI 320i Auto\u3011\u301046\u20202023 BMW G20 3 Series Sedan LCI 318i Auto\u3011\n\n\u301047\u20201976 Ford Pinto 2-Door Sedan 1977 2.8 V6 Cruise-O-Matic\u3011\u301048\u20201976 Ford Pinto 2-Door Sedan 1977 2.3 Cruise-O-Matic\u3011\u301049\u20201976 Ford Pinto 2-Door Sedan 1977 2.3\u3011\u301050\u2020View more\u2020ultimatespecs.com\u3011\n\n## Latest Models\n\n\u301051\u2020BMW G21 3 Series Touring LCI\u3011\u301052\u2020Lexus LBX\u3011\u301053\u2020Hyundai i20 (BC3) 2023\u3011\u301054\u2020Hyundai i20 (BC3)\u3011\n\n\u301055\u2020BYD Seal\u3011\u301056\u2020BYD Dolphin\u3011\u301057\u2020BMW G20 3 Series Sedan LCI\u3011\u301058\u2020Ford Pinto 2-Door Sedan 1977\u3011\n\n\u301059\u2020Buick Electra Coupe 1980\u3011\u301060\u2020Ford Pinto 2-Door Sedan 1976\u3011\u301061\u2020Citroen C3 Phase IV\u3011\u301062\u2020Citroen C5 X\u3011\n\n\u301063\u2020Ford Pinto 2-Door Sedan 1975\u3011\u301064\u2020Lancia Delta\u3011\u301065\u2020Ford Pinto 2-Door Sedan 1974\u3011\u301066\u2020Ford Pinto 2-Door Sedan 1973\u3011\n\n\u301067\u2020Peugeot 408\u3011\u301068\u2020Ford Pinto 2-Door Sedan 1972\u3011\u301069\u2020Renault Sc\u00e9nic 5\u3011\u301070\u2020Renault Espace 6\u3011\n\n\u301071\u2020Ford Pinto 2-Door Sedan\u3011\u301072\u2020Renault Austral\u3011\u301073\u2020Mazda RX-8 2008\u3011\u301074\u2020Bentley Bentayga EWB\u3011\n\n\u301075\u2020Bentley Bentayga 2020 Facelift\u3011\u301076\u2020Toyota GR86 2021\u3011\u301077\u2020Fiat Cinquecento\u3011\u301078\u2020Audi A8 L 2022 (D5)\u3011\n\n\u00a92024 Ultimate Specs - The Most Comprehensive Car Specifications Database. Over 46.000 technical specs!! - \u301079\u2020Change consent\u2020javascript:;\u3011 \n\n- Do not share my Personal Information.\n\n- \u301080\u2020 About \u3011- \u301081\u2020 Privacy Policy \u3011- \u301082\u2020 Contact US \u3011",
+ "pub_date": null,
+ "extra": null
+ }
+ ],
+ "original_query": null
+ },
+ "command": "mclick",
+ "args": ["[0, 3, 7]"],
+ "status": "finished",
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "gpt-4",
+ "parent_id": "36b62905-c942-414a-8ad2-d5d26739efa5",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "36b62905-c942-414a-8ad2-d5d26739efa5",
+ "children": ["2d717cdc-b597-4850-878b-ac13e97a6696"]
+ },
+ "2d717cdc-b597-4850-878b-ac13e97a6696": {
+ "id": "2d717cdc-b597-4850-878b-ac13e97a6696",
+ "message": {
+ "id": "2d717cdc-b597-4850-878b-ac13e97a6696",
+ "author": {
+ "role": "tool",
+ "name": "browser",
+ "metadata": {}
+ },
+ "create_time": 1704629939.856997,
+ "update_time": null,
+ "content": {
+ "content_type": "tether_quote",
+ "url": "https://www.drive.com.au/reviews/volkswagen-transporter-review/",
+ "domain": "www.drive.com.au",
+ "text": "\n###### What we don't\n\n * Seven-speed dual-clutch DSG automatic can get grumbly in traffic \n * no Bluetooth connectivity \n * no cruise control \n * single sliding door only \n * expensive options and servicing\n\n[Image 41: Volkswagen Transporter 2014 30]View 31 images[Image 42: PhotoIcon]\n\nA staple of the German car maker's commercial line-up since 2003, the T5 Volkswagen Transporter remains one of the most popular options available in today's competitive Australian van segment.\n\nPriced from $36,490, the short-wheelbase \u3010178\u2020Volkswagen Transporter\u3011 comes in below its \u3010183\u2020Ford Transit Custom\u3011 ($37,490) and \u3010184\u2020Mercedes-Benz Vito\u3011 ($38,990) equivalents.\n\nThe third highest-selling van in its class, the Transporter is still north of the likes of the Chinese-built \u3010185\u2020LDV V80\u3011 ($30,800), \u3010186\u2020Hyundai iLoad\u3011 ($30,990), soon-to-be-replaced \u3010187\u2020Renault Traffic\u3011 ($32,990 driveaway) and reigning king of moving \u2018stuff\u2019 from one place to an other, the \u3010188\u2020Toyota HiAce\u3011 ($32,990) \u2013 the latter only available in long- and super-long-wheelbase configurations.\n\nPacked with a 2.0-litre four-cylinder turbo diesel and a seven-speed dual-clutch automatic transmission, our $40,990 front-wheel-drive TDI340 test car delivers 103kW at 3500rpm and 340Nm between 1750-2500rpm.\n\nPipping both the HiAce\u2019s 3.0-litre and Vito\u2019s 2.1-litre turbo diesels by 3kW and at least 30Nm, the \u301060\u2020Volkswagen\u3011 Transporter TDI340 also trumps the 85kW/290Nm 2.0-litre in the \u301051\u2020Renault\u3011 Trafic and the 100kW/330Nm 2.5-litre in the manual-only \u301033\u2020LDV\u3011 V80.\n\nAnd while the Volkswagen does fall 10Nm shy of the 92kW/350Nm 2.2-litre turbo diesel unit in the all-new sixth-generation \u301021\u2020Ford\u3011 Transit Custom, the Blue Oval\u2019s challenger is again exclusively offered with a six-speed manual transmission.\n\nHelping the Volkswagen Transporter claim a combined cycle fuel consumption figure of 8.2 litres per 100km, the seven-speed auto is also up by between one and three ratios on its main rivals \u2013 bookended by the single-clutch-equipped Trafic and four-speed \u301059\u2020Toyota\u3011 HiAce respectively. As a result, only the Trafic (8.0L/100km) and the Transit Custom (7.1L/100km) claim sharper economy figures.\n\nComing standard with air conditioning, a two-speaker stereo, daytime running lights and 16-inch steel wheels, the entry-level automatic Transporter TDI340 misses out on the Bluetooth connectivity included on the Toyota, \u301026\u2020Hyundai\u3011, \u301044\u2020Mercedes-Benz\u3011, Ford and Renault offerings.\n\nObtaining cruise control \u2013 standard on Vito, Transit Custom, Trafic and V80 \u2013 also requires a further $490.\n\nOnce settled into the narrow and flat but still comfortable vinyl-winged cloth seat base and gripping the soft-rimmed button-free steering wheel, the Transporter isn\u2019t a bad place to be.\n\nA little utilitarian among a sea of hard-wearing dash and door trims, manual climate controls and a basic audio unit, the scratchy yet durable centre console, air vents, grab handles and plastic floor liner are all offset by one-touch driver and passenger power windows (up and down), damped indicator and wipers stalks, and Volkswagen\u2019s standard clear and simple instrument layout.\n\n### Get a great deal today \n\nInterested in this car? Provide your details and we'll connect you to a member of the Drive team.\n\nVolkswagen Transporter \n\nVolkswagen Transporter\n\nI'd like to hear about finance deals\n\nSubscribe to the newsletter \n\nBy clicking the Send Enquiry button you acknowledge that you have read and agree to the Drive \u3010189\u2020Terms and Conditions \u3011 and \u3010190\u2020Privacy Policy.\u3011\n\nSend Enquiry \n\nLimited to only Trip A and B kilometre readings \u2013 with no average fuel or average speed figures given \u2013 the gauges are joined in the cabin by a heavy-lidded but amply sized lockable glovebox and a netted storage pocket below it.\n\nAiding practicality are two flip-out cupholders, four in-dash cubby holes, a single overhead cut-out, a dash-top storage space for sunglasses and business cards and large split-level door pockets for both driver and passenger.\n\nLess easy to learn to live with are the super-low floor-mounted hard plastic handbrake lever and high NVH (noise, vibration, harshness) levels highlighted by plenty of road, tyre and engine noise.\n\nComfortable and compliant riding on tall 65-profile Continental tyres, the Volkswagen Transporter bobs along in a controlled fashion over undulations, with acceptable \u2013 and expected for a commercial van \u2013 amounts of body roll present through bends.\n\nMowing flatly through ruts, potholes and road joins with an audible thump, the 1752kg TDI340 stays on track with little fuss, and calmly hums over tramlines.\n\nConsistently light but responsive steering works together with an 11.9m turning circle and genuine handling agility to ensure punting through tight inner-city streets is a legitimately enjoyable experience.\n\nConsistent, too, are the brakes \u2013 despite being attached to a mildly slack-feeling brake pedal, heavily contrasted by a tightly sprung throttle pedal.\n\nThe engine is also a gem. Happy to complete most tasks asked of it below 2000rpm \u2013 including freeway stretches at 100km/h \u2013 the grunty turbo diesel delivers sound cruising pace from as low as 1600rpm until things noticeably drop off around 4400rpm. It\u2019ll even contently coast along at 60km/h doing 1400rpm in fifth gear.\n\nProne to some hesitation and jerkiness when responding to sporadic prods of the throttle in stop-start traffic situations, the DSG gearbox and its dash-mounted gear selector work well overall, delivering smooth ratio swaps once moving, with little to no interruption to drive.\n\nAnnoyingly, though, the transmission\u2019s own gear indicator \u2013 located on the left-hand side of the selector\u2019s base \u2013 is obstructed from the driver\u2019s view by the DSG-stamped gear lever itself. A slight ergonomic oversight, the issue can be easily circumvented by relying on the gear display in the instrument cluster, which sits next to the time and above outside temperature, trip and fuel information.\n\nShifting goods is what the Transporter\u2019s all about, though, and despite being shorter in length than the iLoad, Transit Custom and V80, the 4892mm-long Volkswagen\u2019s 5800L load volume is only bettered by the HiAce (6000L) and V80 (6400L).\n\nThe Volkswagen Transporter\u2019s 1268kg payload rating is also the pick of the bunch, while its 2000kg braked towing capacity can only be matched by the Vito and Trafic and topped by the Ford at 2500kg.\n\nSliding back the heavy passenger side door presents an area 2353mm long and 1692mm wide at its maximum. Slightly reduced due to our test van being fitted with a $690 mesh cargo barrier, the Transporter\u2019s rear end space still offers plenty of stacking room thanks to its 1410mm floor-to-roof height and 1244mm minimum width (between rear wheel guards).\n\nProviding excellent head clearance at 1305mm tall, the weighty 1486mm-wide tailgate creates a large aperture for loading items through, although some may find the six floor-mounted tie-down hooks more of a nuisance than convenient.\n\nOddly too, the Volkswagen Transporter is free of any side or roof strapping/tie-down points.\n\nAnd while the tailgate gives drivers an uninterrupted view out the back \u2013 rather than a thick join line common to barn door-style rear doors \u2013 lower rear vision is made much more difficult, particularly when reverse parking.\n\nBut where a rear-view camera is standard on the HiAce, Transporter buyers looking to match the Toyota need to fit not only a $2490 sat-nav unit but additionally a $1890 package that includes front and rear parking sensors ($890) and a rear-view camera, for a total of $4380.\n\nFortunately, the standard heated power side mirrors provide excellent assistance and rear parking sensors on their own will only set you back $490.\n\nStandard on the Mercedes-Benz Vito and Hyundai iLoad, a driver\u2019s side sliding door is also an option on the Volkswagen Transporter, priced at $1190.\n\nA driver and front passenger airbag, stability control and hill-start assist are all standard fair, and the German van is also covered under Volkswagen\u2019s three-year/unlimited kilometre warranty with three years 24hr roadside assist.\n\nWith capped-price services ranging from $435 to $508 over the first three 15,000km annual service intervals, the first three years of ownership in the Volkswagen Transporter TDI340 DSG will cost you $1577.\n\nBetween $530 and $377 dearer than servicing costs over the same time period for the iLoad, Trafic and Transit Custom \u2013 the latter including brake fluid replacement \u2013 only Volkswagen declares costs for replacing both the pollen filter and brake fluid (both required every two years).\n\nWith service intervals every 10,000km, however, the Toyota HiAce \u2013 with two services annually \u2013 still takes the crown, requiring $1020 over three years or 60,000km.\n\n",
+ "title": "Volkswagen Transporter Review - Drive"
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 0.0,
+ "metadata": {
+ "_cite_metadata": {
+ "citation_format": {
+ "name": "tether_og"
+ },
+ "metadata_list": [
+ {
+ "type": "webpage",
+ "title": "Volkswagen Transporter Review - Drive",
+ "url": "https://www.drive.com.au/reviews/volkswagen-transporter-review/",
+ "text": "\n###### What we don't\n\n * Seven-speed dual-clutch DSG automatic can get grumbly in traffic \n * no Bluetooth connectivity \n * no cruise control \n * single sliding door only \n * expensive options and servicing\n\n[Image 41: Volkswagen Transporter 2014 30]View 31 images[Image 42: PhotoIcon]\n\nA staple of the German car maker's commercial line-up since 2003, the T5 Volkswagen Transporter remains one of the most popular options available in today's competitive Australian van segment.\n\nPriced from $36,490, the short-wheelbase \u3010178\u2020Volkswagen Transporter\u3011 comes in below its \u3010183\u2020Ford Transit Custom\u3011 ($37,490) and \u3010184\u2020Mercedes-Benz Vito\u3011 ($38,990) equivalents.\n\nThe third highest-selling van in its class, the Transporter is still north of the likes of the Chinese-built \u3010185\u2020LDV V80\u3011 ($30,800), \u3010186\u2020Hyundai iLoad\u3011 ($30,990), soon-to-be-replaced \u3010187\u2020Renault Traffic\u3011 ($32,990 driveaway) and reigning king of moving \u2018stuff\u2019 from one place to an other, the \u3010188\u2020Toyota HiAce\u3011 ($32,990) \u2013 the latter only available in long- and super-long-wheelbase configurations.\n\nPacked with a 2.0-litre four-cylinder turbo diesel and a seven-speed dual-clutch automatic transmission, our $40,990 front-wheel-drive TDI340 test car delivers 103kW at 3500rpm and 340Nm between 1750-2500rpm.\n\nPipping both the HiAce\u2019s 3.0-litre and Vito\u2019s 2.1-litre turbo diesels by 3kW and at least 30Nm, the \u301060\u2020Volkswagen\u3011 Transporter TDI340 also trumps the 85kW/290Nm 2.0-litre in the \u301051\u2020Renault\u3011 Trafic and the 100kW/330Nm 2.5-litre in the manual-only \u301033\u2020LDV\u3011 V80.\n\nAnd while the Volkswagen does fall 10Nm shy of the 92kW/350Nm 2.2-litre turbo diesel unit in the all-new sixth-generation \u301021\u2020Ford\u3011 Transit Custom, the Blue Oval\u2019s challenger is again exclusively offered with a six-speed manual transmission.\n\nHelping the Volkswagen Transporter claim a combined cycle fuel consumption figure of 8.2 litres per 100km, the seven-speed auto is also up by between one and three ratios on its main rivals \u2013 bookended by the single-clutch-equipped Trafic and four-speed \u301059\u2020Toyota\u3011 HiAce respectively. As a result, only the Trafic (8.0L/100km) and the Transit Custom (7.1L/100km) claim sharper economy figures.\n\nComing standard with air conditioning, a two-speaker stereo, daytime running lights and 16-inch steel wheels, the entry-level automatic Transporter TDI340 misses out on the Bluetooth connectivity included on the Toyota, \u301026\u2020Hyundai\u3011, \u301044\u2020Mercedes-Benz\u3011, Ford and Renault offerings.\n\nObtaining cruise control \u2013 standard on Vito, Transit Custom, Trafic and V80 \u2013 also requires a further $490.\n\nOnce settled into the narrow and flat but still comfortable vinyl-winged cloth seat base and gripping the soft-rimmed button-free steering wheel, the Transporter isn\u2019t a bad place to be.\n\nA little utilitarian among a sea of hard-wearing dash and door trims, manual climate controls and a basic audio unit, the scratchy yet durable centre console, air vents, grab handles and plastic floor liner are all offset by one-touch driver and passenger power windows (up and down), damped indicator and wipers stalks, and Volkswagen\u2019s standard clear and simple instrument layout.\n\n### Get a great deal today \n\nInterested in this car? Provide your details and we'll connect you to a member of the Drive team.\n\nVolkswagen Transporter \n\nVolkswagen Transporter\n\nI'd like to hear about finance deals\n\nSubscribe to the newsletter \n\nBy clicking the Send Enquiry button you acknowledge that you have read and agree to the Drive \u3010189\u2020Terms and Conditions \u3011 and \u3010190\u2020Privacy Policy.\u3011\n\nSend Enquiry \n\nLimited to only Trip A and B kilometre readings \u2013 with no average fuel or average speed figures given \u2013 the gauges are joined in the cabin by a heavy-lidded but amply sized lockable glovebox and a netted storage pocket below it.\n\nAiding practicality are two flip-out cupholders, four in-dash cubby holes, a single overhead cut-out, a dash-top storage space for sunglasses and business cards and large split-level door pockets for both driver and passenger.\n\nLess easy to learn to live with are the super-low floor-mounted hard plastic handbrake lever and high NVH (noise, vibration, harshness) levels highlighted by plenty of road, tyre and engine noise.\n\nComfortable and compliant riding on tall 65-profile Continental tyres, the Volkswagen Transporter bobs along in a controlled fashion over undulations, with acceptable \u2013 and expected for a commercial van \u2013 amounts of body roll present through bends.\n\nMowing flatly through ruts, potholes and road joins with an audible thump, the 1752kg TDI340 stays on track with little fuss, and calmly hums over tramlines.\n\nConsistently light but responsive steering works together with an 11.9m turning circle and genuine handling agility to ensure punting through tight inner-city streets is a legitimately enjoyable experience.\n\nConsistent, too, are the brakes \u2013 despite being attached to a mildly slack-feeling brake pedal, heavily contrasted by a tightly sprung throttle pedal.\n\nThe engine is also a gem. Happy to complete most tasks asked of it below 2000rpm \u2013 including freeway stretches at 100km/h \u2013 the grunty turbo diesel delivers sound cruising pace from as low as 1600rpm until things noticeably drop off around 4400rpm. It\u2019ll even contently coast along at 60km/h doing 1400rpm in fifth gear.\n\nProne to some hesitation and jerkiness when responding to sporadic prods of the throttle in stop-start traffic situations, the DSG gearbox and its dash-mounted gear selector work well overall, delivering smooth ratio swaps once moving, with little to no interruption to drive.\n\nAnnoyingly, though, the transmission\u2019s own gear indicator \u2013 located on the left-hand side of the selector\u2019s base \u2013 is obstructed from the driver\u2019s view by the DSG-stamped gear lever itself. A slight ergonomic oversight, the issue can be easily circumvented by relying on the gear display in the instrument cluster, which sits next to the time and above outside temperature, trip and fuel information.\n\nShifting goods is what the Transporter\u2019s all about, though, and despite being shorter in length than the iLoad, Transit Custom and V80, the 4892mm-long Volkswagen\u2019s 5800L load volume is only bettered by the HiAce (6000L) and V80 (6400L).\n\nThe Volkswagen Transporter\u2019s 1268kg payload rating is also the pick of the bunch, while its 2000kg braked towing capacity can only be matched by the Vito and Trafic and topped by the Ford at 2500kg.\n\nSliding back the heavy passenger side door presents an area 2353mm long and 1692mm wide at its maximum. Slightly reduced due to our test van being fitted with a $690 mesh cargo barrier, the Transporter\u2019s rear end space still offers plenty of stacking room thanks to its 1410mm floor-to-roof height and 1244mm minimum width (between rear wheel guards).\n\nProviding excellent head clearance at 1305mm tall, the weighty 1486mm-wide tailgate creates a large aperture for loading items through, although some may find the six floor-mounted tie-down hooks more of a nuisance than convenient.\n\nOddly too, the Volkswagen Transporter is free of any side or roof strapping/tie-down points.\n\nAnd while the tailgate gives drivers an uninterrupted view out the back \u2013 rather than a thick join line common to barn door-style rear doors \u2013 lower rear vision is made much more difficult, particularly when reverse parking.\n\nBut where a rear-view camera is standard on the HiAce, Transporter buyers looking to match the Toyota need to fit not only a $2490 sat-nav unit but additionally a $1890 package that includes front and rear parking sensors ($890) and a rear-view camera, for a total of $4380.\n\nFortunately, the standard heated power side mirrors provide excellent assistance and rear parking sensors on their own will only set you back $490.\n\nStandard on the Mercedes-Benz Vito and Hyundai iLoad, a driver\u2019s side sliding door is also an option on the Volkswagen Transporter, priced at $1190.\n\nA driver and front passenger airbag, stability control and hill-start assist are all standard fair, and the German van is also covered under Volkswagen\u2019s three-year/unlimited kilometre warranty with three years 24hr roadside assist.\n\nWith capped-price services ranging from $435 to $508 over the first three 15,000km annual service intervals, the first three years of ownership in the Volkswagen Transporter TDI340 DSG will cost you $1577.\n\nBetween $530 and $377 dearer than servicing costs over the same time period for the iLoad, Trafic and Transit Custom \u2013 the latter including brake fluid replacement \u2013 only Volkswagen declares costs for replacing both the pollen filter and brake fluid (both required every two years).\n\nWith service intervals every 10,000km, however, the Toyota HiAce \u2013 with two services annually \u2013 still takes the crown, requiring $1020 over three years or 60,000km.\n\n",
+ "pub_date": null,
+ "extra": null
+ }
+ ],
+ "original_query": null
+ },
+ "command": "mclick",
+ "args": ["[0, 3, 7]"],
+ "status": "finished",
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "gpt-4",
+ "parent_id": "b87c7f57-a6f4-4f4f-999f-38bd70981ae0",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "b87c7f57-a6f4-4f4f-999f-38bd70981ae0",
+ "children": ["5c57c3b5-35df-4b1c-ab2d-8ca76cc63629"]
+ },
+ "5c57c3b5-35df-4b1c-ab2d-8ca76cc63629": {
+ "id": "5c57c3b5-35df-4b1c-ab2d-8ca76cc63629",
+ "message": {
+ "id": "5c57c3b5-35df-4b1c-ab2d-8ca76cc63629",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1704629939.865778,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ "The fuel consumption of a Volkswagen Transporter can vary based on the specific model and driving conditions. For example, the 2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP, which is one of the recent models, doesn't have its fuel consumption data explicitly mentioned in the sources I found. However, a review of a Volkswagen Transporter model, which is equipped with a 2.0-litre four-cylinder turbo diesel engine and a seven-speed dual-clutch automatic transmission, mentions a combined cycle fuel consumption figure of 8.2 litres per 100km. It's important to note that this figure might vary, especially when the vehicle is carrying eight people, as more weight can lead to increased fuel consumption.\n\nFor more detailed information, you might need to refer to the specific model's manual or contact a Volkswagen dealer. Also, keep in mind that real-world fuel consumption can be influenced by factors such as driving style, road conditions, and maintenance of the vehicle\u30106\u2020source\u3011\u30107\u2020source\u3011."
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": true,
+ "weight": 1.0,
+ "metadata": {
+ "finish_details": {
+ "type": "stop",
+ "stop_tokens": [100260]
+ },
+ "citations": [
+ {
+ "start_ix": 981,
+ "end_ix": 991,
+ "citation_format_type": "tether_og",
+ "metadata": {
+ "type": "webpage",
+ "title": "2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP specs, dimensions",
+ "url": "https://www.ultimatespecs.com/car-specs/Volkswagen/118424/2021-Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html",
+ "text": "\nURL: https://www.ultimatespecs.com/car-specs/Volkswagen/118424/2021-Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html\n\n\n[Image 0: English] \n\n\u30100\u2020 English \u3011 \u30101\u2020 Fran\u00e7ais \u3011 \u30102\u2020 Deutsch \u3011 \u30103\u2020 Svensk \u3011 \u30104\u2020 Portugu\u00eas PT \u3011 \u30105\u2020 Espa\u00f1ol \u3011 \u30106\u2020 \u0440\u0443\u0441\u0441\u043a\u0438\u0439 \u3011 \u30107\u2020 Italiano \u3011 \u30108\u2020 \u0431\u044a\u043b\u0433\u0430\u0440\u0441\u043a\u0438 \u3011 \u30109\u2020 Nederlands \u3011 \u301010\u2020 Polski \u3011 \u301011\u2020 Portugu\u00eas BR \u3011 \u301012\u2020 T\u00fcrk\u00e7e \u3011 \n\n\u301013\u2020 \u3011 \n\n\u301013\u2020 Cars \u3011 \n\n\u301013\u2020 Cars \u3011 \n\n\u301014\u2020 Motos \u3011 \n\n\u301015\u2020 Tractors \u3011 \n\n \u301016\u2020 \u2020www.facebook.com\u3011 \u301017\u2020 \u2020twitter.com\u3011 \n\n< Back \n\n * * \u301018\u2020 Cars \u3011 \n * \u301019\u2020 Electric & Hybrid Cars \u3011 \n * \u301020\u2020 Compare cars \u3011 \n * \u301021\u2020 Car Images \u3011 \n * \u301022\u2020 Advanced Search \u3011 \n\n[Image 1: menu] \n\nIt seems that you have reached a high volume of page views. \nPlease confirm that you are a human by clicking the box below. \n\nThank you! \n\n Send \n\n## Latest Car Specs\n\n\u301023\u20202023 Cupra Formentor VZ5 2.5 TSI 4Drive\u3011\u301024\u20202023 BMW G21 3 Series Touring LCI 318i Auto\u3011\u301025\u20202024 Lexus LBX 1.5 Hybrid E-Four e-CVT\u3011\u301026\u20202024 Lexus LBX 1.5 Hybrid e-CVT\u3011\n\n\u301027\u20201983 Buick Electra Coupe 1980 Limited 5.0L V8 4-speed Auto\u3011\u301028\u20201981 Buick Electra Coupe 1980 Limited 4.1 V6 4-speed Auto\u3011\u301029\u20201980 Buick Electra Coupe 1980 Limited 4.1 V6 Overdrive 4-speed Auto\u3011\u301030\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 100HP Hybrid DCT\u3011\n\n\u301031\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 100HP Hybrid Auto\u3011\u301032\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 120HP Hybrid DCT\u3011\u301033\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 120HP Hybrid Auto\u3011\u301034\u20202023 Hyundai i20 (BC3) 2023 1.0 T-GDI 48V-Hybrid DCT\u3011\n\n\u301035\u20202023 Hyundai i20 (BC3) 2023 1.0 T-GDI\u3011\u301036\u20202023 Hyundai i20 (BC3) 2023 1.2 MPI\u3011\u301037\u20202023 BYD Seal 83 kWh 530HP AWD\u3011\u301038\u20202023 BYD Seal 83 kWh 313HP\u3011\n\n\u301039\u20202023 BYD Dolphin 60 kWh 204HP\u3011\u301040\u20201992 Alfa Romeo 164 Super V6 Turbo\u3011\u301041\u20202023 BMW G20 3 Series Sedan LCI M340i Mild Hybrid xDrive Auto\u3011\u301042\u20202023 BMW G20 3 Series Sedan LCI 330i xDrive Auto\u3011\n\n\u301043\u20202023 BMW G20 3 Series Sedan LCI 330i Auto\u3011\u301044\u20202023 BMW G20 3 Series Sedan LCI 320i xDrive Auto\u3011\u301045\u20202023 BMW G20 3 Series Sedan LCI 320i Auto\u3011\u301046\u20202023 BMW G20 3 Series Sedan LCI 318i Auto\u3011\n\n\u301047\u20201976 Ford Pinto 2-Door Sedan 1977 2.8 V6 Cruise-O-Matic\u3011\u301048\u20201976 Ford Pinto 2-Door Sedan 1977 2.3 Cruise-O-Matic\u3011\u301049\u20201976 Ford Pinto 2-Door Sedan 1977 2.3\u3011\u301050\u2020View more\u2020ultimatespecs.com\u3011\n\n## Latest Models\n\n\u301051\u2020BMW G21 3 Series Touring LCI\u3011\u301052\u2020Lexus LBX\u3011\u301053\u2020Hyundai i20 (BC3) 2023\u3011\u301054\u2020Hyundai i20 (BC3)\u3011\n\n\u301055\u2020BYD Seal\u3011\u301056\u2020BYD Dolphin\u3011\u301057\u2020BMW G20 3 Series Sedan LCI\u3011\u301058\u2020Ford Pinto 2-Door Sedan 1977\u3011\n\n\u301059\u2020Buick Electra Coupe 1980\u3011\u301060\u2020Ford Pinto 2-Door Sedan 1976\u3011\u301061\u2020Citroen C3 Phase IV\u3011\u301062\u2020Citroen C5 X\u3011\n\n\u301063\u2020Ford Pinto 2-Door Sedan 1975\u3011\u301064\u2020Lancia Delta\u3011\u301065\u2020Ford Pinto 2-Door Sedan 1974\u3011\u301066\u2020Ford Pinto 2-Door Sedan 1973\u3011\n\n\u301067\u2020Peugeot 408\u3011\u301068\u2020Ford Pinto 2-Door Sedan 1972\u3011\u301069\u2020Renault Sc\u00e9nic 5\u3011\u301070\u2020Renault Espace 6\u3011\n\n\u301071\u2020Ford Pinto 2-Door Sedan\u3011\u301072\u2020Renault Austral\u3011\u301073\u2020Mazda RX-8 2008\u3011\u301074\u2020Bentley Bentayga EWB\u3011\n\n\u301075\u2020Bentley Bentayga 2020 Facelift\u3011\u301076\u2020Toyota GR86 2021\u3011\u301077\u2020Fiat Cinquecento\u3011\u301078\u2020Audi A8 L 2022 (D5)\u3011\n\n\u00a92024 Ultimate Specs - The Most Comprehensive Car Specifications Database. Over 46.000 technical specs!! - \u301079\u2020Change consent\u2020javascript:;\u3011 \n\n- Do not share my Personal Information.\n\n- \u301080\u2020 About \u3011- \u301081\u2020 Privacy Policy \u3011- \u301082\u2020 Contact US \u3011",
+ "pub_date": null,
+ "extra": {
+ "cited_message_idx": 6,
+ "evidence_text": "source"
+ }
+ }
+ },
+ {
+ "start_ix": 991,
+ "end_ix": 1001,
+ "citation_format_type": "tether_og",
+ "metadata": {
+ "type": "webpage",
+ "title": "Volkswagen Transporter Review - Drive",
+ "url": "https://www.drive.com.au/reviews/volkswagen-transporter-review/",
+ "text": "\n###### What we don't\n\n * Seven-speed dual-clutch DSG automatic can get grumbly in traffic \n * no Bluetooth connectivity \n * no cruise control \n * single sliding door only \n * expensive options and servicing\n\n[Image 41: Volkswagen Transporter 2014 30]View 31 images[Image 42: PhotoIcon]\n\nA staple of the German car maker's commercial line-up since 2003, the T5 Volkswagen Transporter remains one of the most popular options available in today's competitive Australian van segment.\n\nPriced from $36,490, the short-wheelbase \u3010178\u2020Volkswagen Transporter\u3011 comes in below its \u3010183\u2020Ford Transit Custom\u3011 ($37,490) and \u3010184\u2020Mercedes-Benz Vito\u3011 ($38,990) equivalents.\n\nThe third highest-selling van in its class, the Transporter is still north of the likes of the Chinese-built \u3010185\u2020LDV V80\u3011 ($30,800), \u3010186\u2020Hyundai iLoad\u3011 ($30,990), soon-to-be-replaced \u3010187\u2020Renault Traffic\u3011 ($32,990 driveaway) and reigning king of moving \u2018stuff\u2019 from one place to an other, the \u3010188\u2020Toyota HiAce\u3011 ($32,990) \u2013 the latter only available in long- and super-long-wheelbase configurations.\n\nPacked with a 2.0-litre four-cylinder turbo diesel and a seven-speed dual-clutch automatic transmission, our $40,990 front-wheel-drive TDI340 test car delivers 103kW at 3500rpm and 340Nm between 1750-2500rpm.\n\nPipping both the HiAce\u2019s 3.0-litre and Vito\u2019s 2.1-litre turbo diesels by 3kW and at least 30Nm, the \u301060\u2020Volkswagen\u3011 Transporter TDI340 also trumps the 85kW/290Nm 2.0-litre in the \u301051\u2020Renault\u3011 Trafic and the 100kW/330Nm 2.5-litre in the manual-only \u301033\u2020LDV\u3011 V80.\n\nAnd while the Volkswagen does fall 10Nm shy of the 92kW/350Nm 2.2-litre turbo diesel unit in the all-new sixth-generation \u301021\u2020Ford\u3011 Transit Custom, the Blue Oval\u2019s challenger is again exclusively offered with a six-speed manual transmission.\n\nHelping the Volkswagen Transporter claim a combined cycle fuel consumption figure of 8.2 litres per 100km, the seven-speed auto is also up by between one and three ratios on its main rivals \u2013 bookended by the single-clutch-equipped Trafic and four-speed \u301059\u2020Toyota\u3011 HiAce respectively. As a result, only the Trafic (8.0L/100km) and the Transit Custom (7.1L/100km) claim sharper economy figures.\n\nComing standard with air conditioning, a two-speaker stereo, daytime running lights and 16-inch steel wheels, the entry-level automatic Transporter TDI340 misses out on the Bluetooth connectivity included on the Toyota, \u301026\u2020Hyundai\u3011, \u301044\u2020Mercedes-Benz\u3011, Ford and Renault offerings.\n\nObtaining cruise control \u2013 standard on Vito, Transit Custom, Trafic and V80 \u2013 also requires a further $490.\n\nOnce settled into the narrow and flat but still comfortable vinyl-winged cloth seat base and gripping the soft-rimmed button-free steering wheel, the Transporter isn\u2019t a bad place to be.\n\nA little utilitarian among a sea of hard-wearing dash and door trims, manual climate controls and a basic audio unit, the scratchy yet durable centre console, air vents, grab handles and plastic floor liner are all offset by one-touch driver and passenger power windows (up and down), damped indicator and wipers stalks, and Volkswagen\u2019s standard clear and simple instrument layout.\n\n### Get a great deal today \n\nInterested in this car? Provide your details and we'll connect you to a member of the Drive team.\n\nVolkswagen Transporter \n\nVolkswagen Transporter\n\nI'd like to hear about finance deals\n\nSubscribe to the newsletter \n\nBy clicking the Send Enquiry button you acknowledge that you have read and agree to the Drive \u3010189\u2020Terms and Conditions \u3011 and \u3010190\u2020Privacy Policy.\u3011\n\nSend Enquiry \n\nLimited to only Trip A and B kilometre readings \u2013 with no average fuel or average speed figures given \u2013 the gauges are joined in the cabin by a heavy-lidded but amply sized lockable glovebox and a netted storage pocket below it.\n\nAiding practicality are two flip-out cupholders, four in-dash cubby holes, a single overhead cut-out, a dash-top storage space for sunglasses and business cards and large split-level door pockets for both driver and passenger.\n\nLess easy to learn to live with are the super-low floor-mounted hard plastic handbrake lever and high NVH (noise, vibration, harshness) levels highlighted by plenty of road, tyre and engine noise.\n\nComfortable and compliant riding on tall 65-profile Continental tyres, the Volkswagen Transporter bobs along in a controlled fashion over undulations, with acceptable \u2013 and expected for a commercial van \u2013 amounts of body roll present through bends.\n\nMowing flatly through ruts, potholes and road joins with an audible thump, the 1752kg TDI340 stays on track with little fuss, and calmly hums over tramlines.\n\nConsistently light but responsive steering works together with an 11.9m turning circle and genuine handling agility to ensure punting through tight inner-city streets is a legitimately enjoyable experience.\n\nConsistent, too, are the brakes \u2013 despite being attached to a mildly slack-feeling brake pedal, heavily contrasted by a tightly sprung throttle pedal.\n\nThe engine is also a gem. Happy to complete most tasks asked of it below 2000rpm \u2013 including freeway stretches at 100km/h \u2013 the grunty turbo diesel delivers sound cruising pace from as low as 1600rpm until things noticeably drop off around 4400rpm. It\u2019ll even contently coast along at 60km/h doing 1400rpm in fifth gear.\n\nProne to some hesitation and jerkiness when responding to sporadic prods of the throttle in stop-start traffic situations, the DSG gearbox and its dash-mounted gear selector work well overall, delivering smooth ratio swaps once moving, with little to no interruption to drive.\n\nAnnoyingly, though, the transmission\u2019s own gear indicator \u2013 located on the left-hand side of the selector\u2019s base \u2013 is obstructed from the driver\u2019s view by the DSG-stamped gear lever itself. A slight ergonomic oversight, the issue can be easily circumvented by relying on the gear display in the instrument cluster, which sits next to the time and above outside temperature, trip and fuel information.\n\nShifting goods is what the Transporter\u2019s all about, though, and despite being shorter in length than the iLoad, Transit Custom and V80, the 4892mm-long Volkswagen\u2019s 5800L load volume is only bettered by the HiAce (6000L) and V80 (6400L).\n\nThe Volkswagen Transporter\u2019s 1268kg payload rating is also the pick of the bunch, while its 2000kg braked towing capacity can only be matched by the Vito and Trafic and topped by the Ford at 2500kg.\n\nSliding back the heavy passenger side door presents an area 2353mm long and 1692mm wide at its maximum. Slightly reduced due to our test van being fitted with a $690 mesh cargo barrier, the Transporter\u2019s rear end space still offers plenty of stacking room thanks to its 1410mm floor-to-roof height and 1244mm minimum width (between rear wheel guards).\n\nProviding excellent head clearance at 1305mm tall, the weighty 1486mm-wide tailgate creates a large aperture for loading items through, although some may find the six floor-mounted tie-down hooks more of a nuisance than convenient.\n\nOddly too, the Volkswagen Transporter is free of any side or roof strapping/tie-down points.\n\nAnd while the tailgate gives drivers an uninterrupted view out the back \u2013 rather than a thick join line common to barn door-style rear doors \u2013 lower rear vision is made much more difficult, particularly when reverse parking.\n\nBut where a rear-view camera is standard on the HiAce, Transporter buyers looking to match the Toyota need to fit not only a $2490 sat-nav unit but additionally a $1890 package that includes front and rear parking sensors ($890) and a rear-view camera, for a total of $4380.\n\nFortunately, the standard heated power side mirrors provide excellent assistance and rear parking sensors on their own will only set you back $490.\n\nStandard on the Mercedes-Benz Vito and Hyundai iLoad, a driver\u2019s side sliding door is also an option on the Volkswagen Transporter, priced at $1190.\n\nA driver and front passenger airbag, stability control and hill-start assist are all standard fair, and the German van is also covered under Volkswagen\u2019s three-year/unlimited kilometre warranty with three years 24hr roadside assist.\n\nWith capped-price services ranging from $435 to $508 over the first three 15,000km annual service intervals, the first three years of ownership in the Volkswagen Transporter TDI340 DSG will cost you $1577.\n\nBetween $530 and $377 dearer than servicing costs over the same time period for the iLoad, Trafic and Transit Custom \u2013 the latter including brake fluid replacement \u2013 only Volkswagen declares costs for replacing both the pollen filter and brake fluid (both required every two years).\n\nWith service intervals every 10,000km, however, the Toyota HiAce \u2013 with two services annually \u2013 still takes the crown, requiring $1020 over three years or 60,000km.\n\n",
+ "pub_date": null,
+ "extra": {
+ "cited_message_idx": 7,
+ "evidence_text": "source"
+ }
+ }
+ }
+ ],
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "gpt-4",
+ "parent_id": "2d717cdc-b597-4850-878b-ac13e97a6696",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "2d717cdc-b597-4850-878b-ac13e97a6696",
+ "children": ["bbb2682f-b4f7-49f8-b842-5352f8b6e3c0"]
+ },
+ "bbb2682f-b4f7-49f8-b842-5352f8b6e3c0": {
+ "id": "bbb2682f-b4f7-49f8-b842-5352f8b6e3c0",
+ "message": {
+ "id": "bbb2682f-b4f7-49f8-b842-5352f8b6e3c0",
+ "author": {
+ "role": "user",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1704629961.277709,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": ["What about 10 year old model"]
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 1.0,
+ "metadata": {
+ "finish_details": {
+ "type": "unknown"
+ },
+ "citations": [],
+ "voice_mode_message": false,
+ "timestamp_": "absolute",
+ "message_type": null
+ },
+ "recipient": "all"
+ },
+ "parent": "5c57c3b5-35df-4b1c-ab2d-8ca76cc63629",
+ "children": ["bea0c246-0b82-4df0-88e3-e28c9c28b909"]
+ },
+ "bea0c246-0b82-4df0-88e3-e28c9c28b909": {
+ "id": "bea0c246-0b82-4df0-88e3-e28c9c28b909",
+ "message": {
+ "id": "bea0c246-0b82-4df0-88e3-e28c9c28b909",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1704629977.563998,
+ "update_time": null,
+ "content": {
+ "content_type": "code",
+ "language": "unknown",
+ "text": "search(\"2014 Volkswagen Transporter fuel consumption\")"
+ },
+ "status": "finished_successfully",
+ "end_turn": false,
+ "weight": 1.0,
+ "metadata": {
+ "finish_details": {
+ "type": "stop",
+ "stop_tokens": [100265]
+ },
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "gpt-4",
+ "parent_id": "bbb2682f-b4f7-49f8-b842-5352f8b6e3c0",
+ "timestamp_": "absolute"
+ },
+ "recipient": "browser"
+ },
+ "parent": "bbb2682f-b4f7-49f8-b842-5352f8b6e3c0",
+ "children": ["3db71263-feb6-445a-b60c-dddc8302fa52"]
+ },
+ "3db71263-feb6-445a-b60c-dddc8302fa52": {
+ "id": "3db71263-feb6-445a-b60c-dddc8302fa52",
+ "message": {
+ "id": "3db71263-feb6-445a-b60c-dddc8302fa52",
+ "author": {
+ "role": "tool",
+ "name": "browser",
+ "metadata": {}
+ },
+ "create_time": 1704629977.577253,
+ "update_time": null,
+ "content": {
+ "content_type": "tether_browsing_display",
+ "result": "# \u30100\u20202014 Volkswagen Transporter MPG - Actual MPG from 14 2014 ... - Fuelly\u2020www.fuelly.com\u3011\nBased on data from 14 vehicles, 639 fuel-ups and 260,822 miles of driving, the 2014 Volkswagen Transporter gets a combined Avg MPG of 23.76 with a 0.37 MPG margin of error. Below you can see a distribution of the fuel-ups with 24 outliers (3.62%) removed. Following shows the average MPG of each of the 14 vehicles in the system. T5.1 Transporter\n# \u30101\u2020Gas Mileage of 2014 Vehicles by Volkswagen - FuelEconomy.gov\u2020www.fueleconomy.gov\u3011\nFuel Economy of 2014 Volkswagen Vehicles Search by Manufacturer. Search by make for fuel efficient new and used cars and trucks\n# \u30102\u20202014 Volkswagen Transporter MPG - Actual MPG from 18 2014 ... - Fuelly\u2020www.fuelly.com\u3011\nAdded Feb 2015 \u2022 171 Fuel-ups. ... 2014 Volkswagen Transporter 2.0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Apr 2018 \u2022 134 Fuel-ups. Property of Elec61 . 25.4 Avg MPG. Transporter. 2014 Volkswagen Transporter 2.0L L4 DIESEL Manual 6 Speed Van Camper Added Jun 2021 \u2022 26 Fuel-ups. Property of damian1471 .\n# \u30103\u2020Volkswagen Transporter MPG - Actual MPG from 528 Volkswagen ... - Fuelly\u2020www.fuelly.com\u3011\n2019 27.1 Avg MPG 6 Vehicles 416 Fuel-ups 178,106 Miles Tracked View All 2019 Volkswagen Transporters 2018 26.5 Avg MPG 15 Vehicles 622 Fuel-ups 235,245 Miles Tracked View All 2018 Volkswagen Transporters 2017 28.2 Avg MPG 13 Vehicles 1,287 Fuel-ups 554,573 Miles Tracked View All 2017 Volkswagen Transporters 2016 26.5 Avg MPG 22 Vehicles\n# \u30104\u2020Volkswagen Transporter 2014 | CarsGuide\u2020www.carsguide.com.au\u3011\nFind all of our 2014 Volkswagen Transporter Reviews, Videos, FAQs & News in one place. Learn how it drives and what features set the 2014 Volkswagen Transporter apart from its rivals. Our comprehensive reviews include detailed ratings on Price and Features, Design, Practicality, Engine, Fuel Consumption, Ownership, Driving & Safety.\n# \u30105\u20202014 Volkswagen Transporter Review, Price and Specification\u2020www.carexpert.com.au\u3011\nPrice $12,400 - $29,500 About the Transporter The 2014 Volkswagen Transporter was available in one hundred and twenty-four variants, is classed as a Van and was built in Germany. It uses Diesel fuel. The 2014 Volkswagen Transporter was sold with an engine size of 2.0L and with turbocharged four-cylinder. Official Links\n# \u30106\u2020Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Specs\u2020www.ultimatespecs.com\u3011\nVolkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Technical Specs 2019,2020,2021: 150 PS (148 hp), Diesel, Fuel consumption:6.7 l/100km (35 MPG), Dimensions: Length: 208.82 in (530.4 cm), Width: 74.96 in (190.4 cm), Height: 78.35 in (199.0 cm) ... Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Fuel Consumption (Economy), Emissions and Range. Fuel ...\n# \u30107\u2020Volkswagen T5 Transporter (2013-2015) van review | Auto Express\u2020www.autoexpress.co.uk\u3011\nOptional BlueMotion Technology delivers lower emissions and improved fuel consumption on 83bhp and 112bhp versions, while there\u2019s also a 112bhp Transporter BlueMotion promising nearly 45mpg ...\n# \u30108\u2020Volkswagen Transporter Review - Drive\u2020www.drive.com.au\u3011\nReview 4 doors, 2 seats 2.0DT, 4 cyl. 103kW, 340Nm Diesel 8.2L/100KM FWD Auto (DCT) 3 Yr, Unltd KMs NA See Pricing + Full Specs All Work Cars Best Vans All Volkswagen Volkswagen Transporter Review David Zalstein 12:44 12 May 2014 0 comments\n# \u30109\u2020Volkswagen Transporter van review (2010-2015) - Parkers\u2020www.parkers.co.uk\u3011\n4.5 out of 5 4.5 This Transporter was naturally powered by a new generation 2.0-litre turbodiesel engine, which is both smooth and refined. It was offered in four different power outputs. The 84 and 102hp versions come with a five-speed gearbox that is well-suited to the wide spread of power.\n# \u301010\u2020Fuel Economy Trip Calculator\u2020www.fueleconomy.gov\u3011\nYou will be able to modify the route by dragging the route line on the map. section above will be updated with the cost of fuel for the trip. This website is administered by Oak Ridge National Laboratory for the U.S. Department of Energy and the U.S. Environmental Protection Agency. My Trip Calculator. Plan your route, estimate fuel costs, and ...\n# \u301011\u2020Volkswagen | Technical Specs, Fuel consumption, Dimensions\u2020www.auto-data.net\u3011\nThe 1.8 T has 5 valves per cylinder - 3 intake and 2 exhaust. Dacia Ford GMC Haval Honda Hummer Jaguar Jeep Koenigsegg Lada Lamborghini Lancia Lexus Maserati. Volkswagen | Technical Specs, Fuel consumption, Dimensions, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size, Body type, Doors, Seats.\n# \u301012\u2020Volkswagen Transporter T4 MPG - Car Emissions\u2020www.car-emissions.com\u3011\nFuel Economy, Road Tax & Emissions. CO2 (carbon dioxide), mileage, fuel economy and other emissions data for Volkswagen Transporter T4 cars. average fuel consumption is 46.1 MPG or 6.6 litres/100km. and average CO2 output is 166.0 g/km. based on 6782 models.\n# \u301013\u20202014 Volkswagen Transporter TDI340 T5 LWB Auto MY14 - Carsales\u2020www.carsales.com.au\u3011\nFuel Consumption Combined. 8.2 L/100km . Fuel Consumption Extra Urban. 6.9 L/100km . Fuel Consumption Urban. 10.2 L/100km ... 2014 Volkswagen Transporter TDI340 T5 SWB Manual MY15 $18,600* Excl. Govt. Charges Volkswagen Transporter Car Reviews. View all. Review 21 ...\n# \u301014\u2020Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Specs\u2020www.ultimatespecs.com\u3011\nVolkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Technical Specs 2019,2020,2021: 110 PS (109 hp), Diesel, Fuel consumption:6.4 l/100km (37 MPG), Dimensions: Length: 208.82 in (530.4 cm), Width: 74.96 in (190.4 cm), Height: 78.35 in (199.0 cm) ... Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Fuel Consumption (Economy), Emissions and Range. Fuel ...\n# \u301015\u2020Volkswagen T5 Transporter Combi 2.0 TDI (140 hp) 2009-2015 MPG, WLTP ...\u2020motoreu.com\u3011\nConsumption in city is 10.2 lt/100km, consumption in highway is 6.9 lt/100km and combined fuel consumption is 8.1 lt/100km according to official factory data. Estimated T5 Transporter Combi 2.0 TDI Short 4MOTION real World MPG/consumption is 12,4 lt/100km. It is expected to consume 48 percent more fuel than factory data in real conditions and ...\n# \u301016\u20202015 Volkswagen Transporter (T6) Kombi 2.0 TSI (150 Hp) L1H2\u2020www.auto-data.net\u3011\nWhat is the body type, Volkswagen Transporter (T6) Kombi? Van, 4-5 Doors, 4-9 Seats What is the fuel economy, Volkswagen Transporter (T6) Kombi 2.0 TSI (150 Hp) L1H2?\n# \u301017\u2020Volkswagen Type 2 - Wikipedia\u2020en.wikipedia.org\u3011\nThe Volkswagen Type 2 is a forward control light commercial vehicle introduced in 1950 by the German automaker Volkswagen as its second car model.Known officially (depending on body type) as the Transporter, Kombi or Microbus, or, informally, as the Volkswagen Station Wagon (US), Bus (also US), Camper (UK) or Bulli (Germany), it was given the factory designation Type 2 as it followed \u2013 and ...\n# \u301018\u2020Volkswagen MPG (Fuel Consumption) - Car Emissions\u2020www.car-emissions.com\u3011\n166.0 g/km What is the average MPG (fuel economy) of a Volkswagen ? Volkswagen average fuel consumption is 46.1 MPG or 6.6 litres/100km and average CO2 output is 166.0 g/km based on 6782 models. CO2 (carbon dioxide), mileage, fuel economy and other emissions data for Volkswagen cars. Data table - click on a title to order by column:\n# \u301019\u2020Rental Truck Fuel Calculator | U-Pack\u2020www.upack.com\u3011\nMiles \u00f7 MPG x Gas Price = Minimum Fuel Cost. ( Miles = total distance of the trip) ( MPG = Moving Truck average mile per gallon) ( Gas Price = average cost of gas per gallon) For example, if you\u2019re planning to drive 850 miles in a U-Haul \u00ae truck that gets 10 miles to the gallon, and gas averages $2.50, you should budget around $212.50 for ...\nVisible: 0% - 100%",
+ "summary": null,
+ "assets": []
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 0.0,
+ "metadata": {
+ "_cite_metadata": {
+ "citation_format": {
+ "name": "tether_og"
+ },
+ "metadata_list": [
+ {
+ "type": "webpage",
+ "title": "2014 Volkswagen Transporter MPG - Actual MPG from 14 2014 ... - Fuelly",
+ "url": "https://www.fuelly.com/car/volkswagen/transporter/2014",
+ "text": "\nBased on data from 14 vehicles, 639 fuel-ups and 260,822 miles of driving, the 2014 Volkswagen Transporter gets a combined Avg MPG of 23.76 with a 0.37 MPG margin of error. Below you can see a distribution of the fuel-ups with 24 outliers (3.62%) removed. Following shows the average MPG of each of the 14 vehicles in the system. T5.1 Transporter\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Gas Mileage of 2014 Vehicles by Volkswagen - FuelEconomy.gov",
+ "url": "https://www.fueleconomy.gov/feg/bymake/Volkswagen2014.shtml",
+ "text": "\nFuel Economy of 2014 Volkswagen Vehicles Search by Manufacturer. Search by make for fuel efficient new and used cars and trucks\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "2014 Volkswagen Transporter MPG - Actual MPG from 18 2014 ... - Fuelly",
+ "url": "https://www.fuelly.com/car/Volkswagen/Transporter/2014/all",
+ "text": "\nAdded Feb 2015 \u2022 171 Fuel-ups. ... 2014 Volkswagen Transporter 2.0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Apr 2018 \u2022 134 Fuel-ups. Property of Elec61 . 25.4 Avg MPG. Transporter. 2014 Volkswagen Transporter 2.0L L4 DIESEL Manual 6 Speed Van Camper Added Jun 2021 \u2022 26 Fuel-ups. Property of damian1471 .\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen Transporter MPG - Actual MPG from 528 Volkswagen ... - Fuelly",
+ "url": "https://www.fuelly.com/car/volkswagen/transporter",
+ "text": "\n2019 27.1 Avg MPG 6 Vehicles 416 Fuel-ups 178,106 Miles Tracked View All 2019 Volkswagen Transporters 2018 26.5 Avg MPG 15 Vehicles 622 Fuel-ups 235,245 Miles Tracked View All 2018 Volkswagen Transporters 2017 28.2 Avg MPG 13 Vehicles 1,287 Fuel-ups 554,573 Miles Tracked View All 2017 Volkswagen Transporters 2016 26.5 Avg MPG 22 Vehicles\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen Transporter 2014 | CarsGuide",
+ "url": "https://www.carsguide.com.au/volkswagen/transporter/2014",
+ "text": "\nFind all of our 2014 Volkswagen Transporter Reviews, Videos, FAQs & News in one place. Learn how it drives and what features set the 2014 Volkswagen Transporter apart from its rivals. Our comprehensive reviews include detailed ratings on Price and Features, Design, Practicality, Engine, Fuel Consumption, Ownership, Driving & Safety.\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "2014 Volkswagen Transporter Review, Price and Specification",
+ "url": "https://www.carexpert.com.au/volkswagen/transporter/2014",
+ "text": "\nPrice $12,400 - $29,500 About the Transporter The 2014 Volkswagen Transporter was available in one hundred and twenty-four variants, is classed as a Van and was built in Germany. It uses Diesel fuel. The 2014 Volkswagen Transporter was sold with an engine size of 2.0L and with turbocharged four-cylinder. Official Links\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Specs",
+ "url": "https://www.ultimatespecs.com/car-specs/Volkswagen/118425/Volkswagen-Transporter-T61-L2H1-20-TDI-150HP.html",
+ "text": "\nVolkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Technical Specs 2019,2020,2021: 150 PS (148 hp), Diesel, Fuel consumption:6.7 l/100km (35 MPG), Dimensions: Length: 208.82 in (530.4 cm), Width: 74.96 in (190.4 cm), Height: 78.35 in (199.0 cm) ... Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Fuel Consumption (Economy), Emissions and Range. Fuel ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen T5 Transporter (2013-2015) van review | Auto Express",
+ "url": "https://www.autoexpress.co.uk/volkswagen/transporter/64655/2013-2015-van",
+ "text": "\nOptional BlueMotion Technology delivers lower emissions and improved fuel consumption on 83bhp and 112bhp versions, while there\u2019s also a 112bhp Transporter BlueMotion promising nearly 45mpg ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen Transporter Review - Drive",
+ "url": "https://www.drive.com.au/reviews/volkswagen-transporter-review/",
+ "text": "\nReview 4 doors, 2 seats 2.0DT, 4 cyl. 103kW, 340Nm Diesel 8.2L/100KM FWD Auto (DCT) 3 Yr, Unltd KMs NA See Pricing + Full Specs All Work Cars Best Vans All Volkswagen Volkswagen Transporter Review David Zalstein 12:44 12 May 2014 0 comments\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen Transporter van review (2010-2015) - Parkers",
+ "url": "https://www.parkers.co.uk/vans-pickups/volkswagen/transporter/2010-review/",
+ "text": "\n4.5 out of 5 4.5 This Transporter was naturally powered by a new generation 2.0-litre turbodiesel engine, which is both smooth and refined. It was offered in four different power outputs. The 84 and 102hp versions come with a five-speed gearbox that is well-suited to the wide spread of power.\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Fuel Economy Trip Calculator",
+ "url": "https://www.fueleconomy.gov/trip/",
+ "text": "\nYou will be able to modify the route by dragging the route line on the map. section above will be updated with the cost of fuel for the trip. This website is administered by Oak Ridge National Laboratory for the U.S. Department of Energy and the U.S. Environmental Protection Agency. My Trip Calculator. Plan your route, estimate fuel costs, and ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen | Technical Specs, Fuel consumption, Dimensions",
+ "url": "https://www.auto-data.net/en/volkswagen-brand-80",
+ "text": "\nThe 1.8 T has 5 valves per cylinder - 3 intake and 2 exhaust. Dacia Ford GMC Haval Honda Hummer Jaguar Jeep Koenigsegg Lada Lamborghini Lancia Lexus Maserati. Volkswagen | Technical Specs, Fuel consumption, Dimensions, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size, Body type, Doors, Seats.\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen Transporter T4 MPG - Car Emissions",
+ "url": "https://www.car-emissions.com/cars/index/volkswagen+transporter+t4",
+ "text": "\nFuel Economy, Road Tax & Emissions. CO2 (carbon dioxide), mileage, fuel economy and other emissions data for Volkswagen Transporter T4 cars. average fuel consumption is 46.1 MPG or 6.6 litres/100km. and average CO2 output is 166.0 g/km. based on 6782 models.\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "2014 Volkswagen Transporter TDI340 T5 LWB Auto MY14 - Carsales",
+ "url": "https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/",
+ "text": "\nFuel Consumption Combined. 8.2 L/100km . Fuel Consumption Extra Urban. 6.9 L/100km . Fuel Consumption Urban. 10.2 L/100km ... 2014 Volkswagen Transporter TDI340 T5 SWB Manual MY15 $18,600* Excl. Govt. Charges Volkswagen Transporter Car Reviews. View all. Review 21 ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Specs",
+ "url": "https://www.ultimatespecs.com/car-specs/Volkswagen/118424/Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html",
+ "text": "\nVolkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Technical Specs 2019,2020,2021: 110 PS (109 hp), Diesel, Fuel consumption:6.4 l/100km (37 MPG), Dimensions: Length: 208.82 in (530.4 cm), Width: 74.96 in (190.4 cm), Height: 78.35 in (199.0 cm) ... Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Fuel Consumption (Economy), Emissions and Range. Fuel ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen T5 Transporter Combi 2.0 TDI (140 hp) 2009-2015 MPG, WLTP ...",
+ "url": "https://motoreu.com/volkswagen-t5-transporter-combi-2.0-tdi-mpg-fuel-consumption-technical-specifications-171706",
+ "text": "\nConsumption in city is 10.2 lt/100km, consumption in highway is 6.9 lt/100km and combined fuel consumption is 8.1 lt/100km according to official factory data. Estimated T5 Transporter Combi 2.0 TDI Short 4MOTION real World MPG/consumption is 12,4 lt/100km. It is expected to consume 48 percent more fuel than factory data in real conditions and ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "2015 Volkswagen Transporter (T6) Kombi 2.0 TSI (150 Hp) L1H2",
+ "url": "https://www.auto-data.net/en/volkswagen-transporter-t6-kombi-2.0-tsi-150hp-l1h2-49664",
+ "text": "\nWhat is the body type, Volkswagen Transporter (T6) Kombi? Van, 4-5 Doors, 4-9 Seats What is the fuel economy, Volkswagen Transporter (T6) Kombi 2.0 TSI (150 Hp) L1H2?\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen Type 2 - Wikipedia",
+ "url": "https://en.wikipedia.org/wiki/Volkswagen_Type_2",
+ "text": "\nThe Volkswagen Type 2 is a forward control light commercial vehicle introduced in 1950 by the German automaker Volkswagen as its second car model.Known officially (depending on body type) as the Transporter, Kombi or Microbus, or, informally, as the Volkswagen Station Wagon (US), Bus (also US), Camper (UK) or Bulli (Germany), it was given the factory designation Type 2 as it followed \u2013 and ...\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Volkswagen MPG (Fuel Consumption) - Car Emissions",
+ "url": "https://www.car-emissions.com/cars/model/volkswagen/",
+ "text": "\n166.0 g/km What is the average MPG (fuel economy) of a Volkswagen ? Volkswagen average fuel consumption is 46.1 MPG or 6.6 litres/100km and average CO2 output is 166.0 g/km based on 6782 models. CO2 (carbon dioxide), mileage, fuel economy and other emissions data for Volkswagen cars. Data table - click on a title to order by column:\n",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Rental Truck Fuel Calculator | U-Pack",
+ "url": "https://www.upack.com/moving-resources/documents-and-tools/fuel-calculator",
+ "text": "\nMiles \u00f7 MPG x Gas Price = Minimum Fuel Cost. ( Miles = total distance of the trip) ( MPG = Moving Truck average mile per gallon) ( Gas Price = average cost of gas per gallon) For example, if you\u2019re planning to drive 850 miles in a U-Haul \u00ae truck that gets 10 miles to the gallon, and gas averages $2.50, you should budget around $212.50 for ...\nVisible: 0% - 100%",
+ "pub_date": null,
+ "extra": null
+ },
+ {
+ "type": "webpage",
+ "title": "Search results for query: '2014 Volkswagen Transporter fuel consumption'",
+ "url": "",
+ "text": "# \u30100\u20202014 Volkswagen Transporter MPG - Actual MPG from 14 2014 ... - Fuelly\u2020www.fuelly.com\u3011\nBased on data from 14 vehicles, 639 fuel-ups and 260,822 miles of driving, the 2014 Volkswagen Transporter gets a combined Avg MPG of 23.76 with a 0.37 MPG margin of error. Below you can see a distribution of the fuel-ups with 24 outliers (3.62%) removed. Following shows the average MPG of each of the 14 vehicles in the system. T5.1 Transporter\n# \u30101\u2020Gas Mileage of 2014 Vehicles by Volkswagen - FuelEconomy.gov\u2020www.fueleconomy.gov\u3011\nFuel Economy of 2014 Volkswagen Vehicles Search by Manufacturer. Search by make for fuel efficient new and used cars and trucks\n# \u30102\u20202014 Volkswagen Transporter MPG - Actual MPG from 18 2014 ... - Fuelly\u2020www.fuelly.com\u3011\nAdded Feb 2015 \u2022 171 Fuel-ups. ... 2014 Volkswagen Transporter 2.0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Apr 2018 \u2022 134 Fuel-ups. Property of Elec61 . 25.4 Avg MPG. Transporter. 2014 Volkswagen Transporter 2.0L L4 DIESEL Manual 6 Speed Van Camper Added Jun 2021 \u2022 26 Fuel-ups. Property of damian1471 .\n# \u30103\u2020Volkswagen Transporter MPG - Actual MPG from 528 Volkswagen ... - Fuelly\u2020www.fuelly.com\u3011\n2019 27.1 Avg MPG 6 Vehicles 416 Fuel-ups 178,106 Miles Tracked View All 2019 Volkswagen Transporters 2018 26.5 Avg MPG 15 Vehicles 622 Fuel-ups 235,245 Miles Tracked View All 2018 Volkswagen Transporters 2017 28.2 Avg MPG 13 Vehicles 1,287 Fuel-ups 554,573 Miles Tracked View All 2017 Volkswagen Transporters 2016 26.5 Avg MPG 22 Vehicles\n# \u30104\u2020Volkswagen Transporter 2014 | CarsGuide\u2020www.carsguide.com.au\u3011\nFind all of our 2014 Volkswagen Transporter Reviews, Videos, FAQs & News in one place. Learn how it drives and what features set the 2014 Volkswagen Transporter apart from its rivals. Our comprehensive reviews include detailed ratings on Price and Features, Design, Practicality, Engine, Fuel Consumption, Ownership, Driving & Safety.\n# \u30105\u20202014 Volkswagen Transporter Review, Price and Specification\u2020www.carexpert.com.au\u3011\nPrice $12,400 - $29,500 About the Transporter The 2014 Volkswagen Transporter was available in one hundred and twenty-four variants, is classed as a Van and was built in Germany. It uses Diesel fuel. The 2014 Volkswagen Transporter was sold with an engine size of 2.0L and with turbocharged four-cylinder. Official Links\n# \u30106\u2020Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Specs\u2020www.ultimatespecs.com\u3011\nVolkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Technical Specs 2019,2020,2021: 150 PS (148 hp), Diesel, Fuel consumption:6.7 l/100km (35 MPG), Dimensions: Length: 208.82 in (530.4 cm), Width: 74.96 in (190.4 cm), Height: 78.35 in (199.0 cm) ... Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Fuel Consumption (Economy), Emissions and Range. Fuel ...\n# \u30107\u2020Volkswagen T5 Transporter (2013-2015) van review | Auto Express\u2020www.autoexpress.co.uk\u3011\nOptional BlueMotion Technology delivers lower emissions and improved fuel consumption on 83bhp and 112bhp versions, while there\u2019s also a 112bhp Transporter BlueMotion promising nearly 45mpg ...\n# \u30108\u2020Volkswagen Transporter Review - Drive\u2020www.drive.com.au\u3011\nReview 4 doors, 2 seats 2.0DT, 4 cyl. 103kW, 340Nm Diesel 8.2L/100KM FWD Auto (DCT) 3 Yr, Unltd KMs NA See Pricing + Full Specs All Work Cars Best Vans All Volkswagen Volkswagen Transporter Review David Zalstein 12:44 12 May 2014 0 comments\n# \u30109\u2020Volkswagen Transporter van review (2010-2015) - Parkers\u2020www.parkers.co.uk\u3011\n4.5 out of 5 4.5 This Transporter was naturally powered by a new generation 2.0-litre turbodiesel engine, which is both smooth and refined. It was offered in four different power outputs. The 84 and 102hp versions come with a five-speed gearbox that is well-suited to the wide spread of power.\n# \u301010\u2020Fuel Economy Trip Calculator\u2020www.fueleconomy.gov\u3011\nYou will be able to modify the route by dragging the route line on the map. section above will be updated with the cost of fuel for the trip. This website is administered by Oak Ridge National Laboratory for the U.S. Department of Energy and the U.S. Environmental Protection Agency. My Trip Calculator. Plan your route, estimate fuel costs, and ...\n# \u301011\u2020Volkswagen | Technical Specs, Fuel consumption, Dimensions\u2020www.auto-data.net\u3011\nThe 1.8 T has 5 valves per cylinder - 3 intake and 2 exhaust. Dacia Ford GMC Haval Honda Hummer Jaguar Jeep Koenigsegg Lada Lamborghini Lancia Lexus Maserati. Volkswagen | Technical Specs, Fuel consumption, Dimensions, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size, Body type, Doors, Seats.\n# \u301012\u2020Volkswagen Transporter T4 MPG - Car Emissions\u2020www.car-emissions.com\u3011\nFuel Economy, Road Tax & Emissions. CO2 (carbon dioxide), mileage, fuel economy and other emissions data for Volkswagen Transporter T4 cars. average fuel consumption is 46.1 MPG or 6.6 litres/100km. and average CO2 output is 166.0 g/km. based on 6782 models.\n# \u301013\u20202014 Volkswagen Transporter TDI340 T5 LWB Auto MY14 - Carsales\u2020www.carsales.com.au\u3011\nFuel Consumption Combined. 8.2 L/100km . Fuel Consumption Extra Urban. 6.9 L/100km . Fuel Consumption Urban. 10.2 L/100km ... 2014 Volkswagen Transporter TDI340 T5 SWB Manual MY15 $18,600* Excl. Govt. Charges Volkswagen Transporter Car Reviews. View all. Review 21 ...\n# \u301014\u2020Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Specs\u2020www.ultimatespecs.com\u3011\nVolkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Technical Specs 2019,2020,2021: 110 PS (109 hp), Diesel, Fuel consumption:6.4 l/100km (37 MPG), Dimensions: Length: 208.82 in (530.4 cm), Width: 74.96 in (190.4 cm), Height: 78.35 in (199.0 cm) ... Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Fuel Consumption (Economy), Emissions and Range. Fuel ...\n# \u301015\u2020Volkswagen T5 Transporter Combi 2.0 TDI (140 hp) 2009-2015 MPG, WLTP ...\u2020motoreu.com\u3011\nConsumption in city is 10.2 lt/100km, consumption in highway is 6.9 lt/100km and combined fuel consumption is 8.1 lt/100km according to official factory data. Estimated T5 Transporter Combi 2.0 TDI Short 4MOTION real World MPG/consumption is 12,4 lt/100km. It is expected to consume 48 percent more fuel than factory data in real conditions and ...\n# \u301016\u20202015 Volkswagen Transporter (T6) Kombi 2.0 TSI (150 Hp) L1H2\u2020www.auto-data.net\u3011\nWhat is the body type, Volkswagen Transporter (T6) Kombi? Van, 4-5 Doors, 4-9 Seats What is the fuel economy, Volkswagen Transporter (T6) Kombi 2.0 TSI (150 Hp) L1H2?\n# \u301017\u2020Volkswagen Type 2 - Wikipedia\u2020en.wikipedia.org\u3011\nThe Volkswagen Type 2 is a forward control light commercial vehicle introduced in 1950 by the German automaker Volkswagen as its second car model.Known officially (depending on body type) as the Transporter, Kombi or Microbus, or, informally, as the Volkswagen Station Wagon (US), Bus (also US), Camper (UK) or Bulli (Germany), it was given the factory designation Type 2 as it followed \u2013 and ...\n# \u301018\u2020Volkswagen MPG (Fuel Consumption) - Car Emissions\u2020www.car-emissions.com\u3011\n166.0 g/km What is the average MPG (fuel economy) of a Volkswagen ? Volkswagen average fuel consumption is 46.1 MPG or 6.6 litres/100km and average CO2 output is 166.0 g/km based on 6782 models. CO2 (carbon dioxide), mileage, fuel economy and other emissions data for Volkswagen cars. Data table - click on a title to order by column:\n# \u301019\u2020Rental Truck Fuel Calculator | U-Pack\u2020www.upack.com\u3011\nMiles \u00f7 MPG x Gas Price = Minimum Fuel Cost. ( Miles = total distance of the trip) ( MPG = Moving Truck average mile per gallon) ( Gas Price = average cost of gas per gallon) For example, if you\u2019re planning to drive 850 miles in a U-Haul \u00ae truck that gets 10 miles to the gallon, and gas averages $2.50, you should budget around $212.50 for ...\nVisible: 0% - 100%",
+ "pub_date": null,
+ "extra": null
+ }
+ ],
+ "original_query": null
+ },
+ "command": "search",
+ "args": ["2014 Volkswagen Transporter fuel consumption"],
+ "status": "finished",
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "gpt-4",
+ "parent_id": "bea0c246-0b82-4df0-88e3-e28c9c28b909",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "bea0c246-0b82-4df0-88e3-e28c9c28b909",
+ "children": ["bdad1960-d8ec-4bbb-b27f-437d54655069"]
+ },
+ "bdad1960-d8ec-4bbb-b27f-437d54655069": {
+ "id": "bdad1960-d8ec-4bbb-b27f-437d54655069",
+ "message": {
+ "id": "bdad1960-d8ec-4bbb-b27f-437d54655069",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1704629977.590843,
+ "update_time": null,
+ "content": {
+ "content_type": "code",
+ "language": "unknown",
+ "text": "mclick([0, 2, 13])"
+ },
+ "status": "finished_successfully",
+ "end_turn": false,
+ "weight": 1.0,
+ "metadata": {
+ "finish_details": {
+ "type": "stop",
+ "stop_tokens": [100265]
+ },
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "gpt-4",
+ "parent_id": "3db71263-feb6-445a-b60c-dddc8302fa52",
+ "timestamp_": "absolute"
+ },
+ "recipient": "browser"
+ },
+ "parent": "3db71263-feb6-445a-b60c-dddc8302fa52",
+ "children": ["adf6394d-b5a9-422a-bc85-e388a20b219b"]
+ },
+ "adf6394d-b5a9-422a-bc85-e388a20b219b": {
+ "id": "adf6394d-b5a9-422a-bc85-e388a20b219b",
+ "message": {
+ "id": "adf6394d-b5a9-422a-bc85-e388a20b219b",
+ "author": {
+ "role": "tool",
+ "name": "browser",
+ "metadata": {}
+ },
+ "create_time": 1704629977.598691,
+ "update_time": null,
+ "content": {
+ "content_type": "tether_quote",
+ "url": "https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/",
+ "domain": "www.carsales.com.au",
+ "text": "\nNumber of Airbags\n\n2 \n\n### Engine \n\nEngine Type\n\nPiston \n\nEngine Location\n\nFront \n\nEngine Size (cc)\n\n1968 cc \n\nEngine Size (L)\n\n2.0 L \n\nInduction\n\nTurbo Intercooled \n\nEngine Configuration\n\nIn-line \n\nCylinders\n\n4 \n\nCamshaft\n\nDouble Overhead Cam \n\nValves/Ports per Cylinder\n\n4 \n\nCompression ratio\n\n16.5 \n\nEngine Code\n\nCAAC \n\nGeneric Engine Type\n\nPiston \n\nPower\n\n103.0kW @ 3500rpm \n\nTorque\n\n340Nm @ 1750-2500rpm \n\nPower to Weight Ratio\n\n52.9 kW/t \n\n### Transmission & drivetrain \n\nGears\n\n7 \n\nGear Type\n\nDirect-Shift Gearbox (Sports Automatic Dual Clutch) \n\nGeneric Gear Type\n\nAutomatic \n\nGear Location\n\nDash \n\nDrive\n\nFront Wheel Drive \n\n### Fuel \n\nFuel Type\n\nDiesel \n\nFuel Capacity\n\n80 L \n\nFuel Delivery\n\nCommon-rail Direct Injection \n\nMethod of Delivery\n\nElectronic Sequential \n\nFuel Consumption Combined\n\n8.2 L/100km \n\nFuel Consumption Highway\n\n6.9 L/100km \n\nFuel Consumption City\n\n10.2 L/100km \n\nFuel Average Distance\n\n976 km \n\nFuel Maximum Distance\n\n1159 km \n\nFuel Minimum Distance\n\n784 km \n\nCO2 Emission Combined\n\n216 g/km \n\nCO2 Extra Urban\n\n182 g/km \n\nCO2 Urban\n\n274 g/km \n\nGreenhouse Rating\n\n6 \n\nAir Pollution Rating\n\n5 \n\nGreen Star Rating\n\n3 \n\n### Steering \n\nSteering\n\nRack and Pinion \n\n### Wheels & tyres \n\nRim Material\n\nSteel \n\nFront Rim Description\n\n16x6.5 \n\nRear Rim Description\n\n16x6.5 \n\nFront Tyre Description\n\n\u301092\u2020205/65 R16 C\u2020www.tyresales.com.au\u3011 \n\nRear Tyre Description\n\n\u301092\u2020205/65 R16 C\u2020www.tyresales.com.au\u3011 \n\n### Dimensions & weights \n\nLength\n\n5292 mm \n\nWidth\n\n1904 mm \n\nHeight\n\n1990 mm \n\nWheelbase\n\n3400 mm \n\nTrack Front\n\n1628 mm \n\nTrack Rear\n\n1628 mm \n\nKerb Weight\n\n1948 kg \n\nBoot / Load Space Max (L)\n\n6700 L \n\nGross Vehicle Mass\n\n3000 kg \n\nPayload\n\n1052 kg \n\nTowing Capacity (braked)\n\n2000 kg \n\nTowing Capacity (Unbraked)\n\n750 kg \n\nLoad Length\n\n2753 mm \n\nLoad Width\n\n1692 mm \n\nLoad Height\n\n1410 mm \n\nWidth Between Wheel Arches\n\n1244 mm \n\nRear Side Door Width\n\n1020 mm \n\nRear Side Door Height\n\n1284 mm \n\n### Warranty & service \n\nWarranty in Years from First Registration\n\n3 yr \n\nWarranty in Km\n\nUnlimited km \n\nWarranty Customer Assistance\n\n3Yrs Roadside \n\nWarranty Anti Corrosion in Years from First Registration\n\n12 yr \n\nRegular Service Interval in Km\n\n15000 km \n\nRegular Service Interval in Months\n\n12 mth \n\n### Other \n\nCountry of Origin\n\nGERMANY \n\nLaunch Year\n\n2013 \n\nLaunch Month\n\n9 \n\nGeneration Name\n\nT5 \n\nSeries\n\nT5 \n\nModel Year\n\nMY14 \n\nBadge\n\nTDI340 \n\nDoors\n\n4 \n\nSeat Capacity\n\n5 \n\nBody Style\n\nCrewvan (Van) \n\nOverview\n\nCovering all aspects utilitarian, from cab/chassis through the crew cab, the short and long wheel based front-wheel-drive vans, right up to the all-wheel-drive 4-Motion, the T5 Transporter remains VW's best selling commercial vehicle. That fact alone speaks volumes in this tough sector. Adopting car-like ambiance and feel, sporting a range of 2.0-litre turbodiesels and transmissions, you can then opt for any number of the bewildering array of options. Load volumes for the Crew vans runs from 5.8 m up to 7.8 m in the LWB medium-roof. Safety is very good for the class with four ANCAP stars. \n\n### P plate status \n\nNSW Probationary Status\n\nAllowed \n\n### Approximate Running Costs \n\nFuel cost per 1000km\n\n$159.00 \n\nFuel cost per fill\n\n$154.00 \n\n### Audio, visual & communication \n\nInputs\n\nMP3 decoder \n\nCD / DVD\n\nCD player \n\n### Safety & security \n\nAirbags\n\nDriver \n\nPassenger \n\nSeatbelts\n\nLap/sash for 2 seats \n\nPretensioners 1st row (front) \n\nAdjustable height 1st row \n\nEmergency\n\nBrake assist \n\nVehicle control\n\nABS (antilock brakes) \n\nTraction \n\nElectronic stability \n\nHill holder \n\nEBD (electronic brake force distribution) \n\nSecurity\n\nCentral locking - remote/keyless \n\nEngine immobiliser \n\n### Comfort & convenience \n\nAir conditioning\n\nAir conditioning \n\n### Lights & windows \n\nLights\n\nDaytime running lamps \n\nFog lamps - rear \n\nPower windows\n\nFront only \n\n### Interior \n\nCloth\n\nTrim \n\n### Instruments & controls \n\nDisplay\n\nClock - digital \n\nGauges\n\nTacho \n\n### Exterior \n\nMirrors\n\nElectric - heated \n\nMudflaps\n\nFront \n\nRear \n\n### Body \n\nDoors\n\nSide sliding lhs(passenger side) \n\n### Brakes \n\nFront\n\nVentilated \n\nRear\n\nSolid \n\n### Suspension \n\nType\n\nIndependent front suspension \n\n### Option pack \n\nOption pack\n\nComfort Pack \n\nAirbags - Front Side & Head \n\n- Airbags - Head for 1st Row Seats (Front)\n\n- Airbags - Side for 1st Row Occupants (Front)\n\nControl - Park Distance Front & Rear \n\n- Control - Park Distance Front\n\n- Control - Park Distance Rear\n\nControl - Park Distance Front & Rear with Camera \n\n- Control - Park Distance Front\n\n- Control - Park Distance Rear\n\n- Parking Assist - Graphical Display\n\n- Camera - Rear Vision\n\nLight & Sight Pack \n\n- Headlamps - See me home\n\n- Rain Sensor (Auto wipers)\n\n### Audio, visual & communication \n\nInputs\n\nMedia Device Interface - Aux Ipod/USB Socket \n\nBluetooth\n\nBluetooth Phone Preparation \n\nRadio\n\nRCD310 Radio with Media-In Interface \n\n### Safety & security \n\nDriver assistance\n\nControl - Park Distance Rear \n\nSecurity\n\nAlarm \n\n### Comfort & convenience \n\nAir conditioning\n\nAir Conditioning - Rear \n\nDriving\n\nCruise Control \n\nArmrests\n\nArmrest - Drivers Seat \n\nArmrest - Front (Driver & Passenger) \n\nCargo space\n\nFixed Partition with Fixed Window \n\n### Lights & windows \n\nLights\n\nFog Lamps - Front with Fixed Corner Function \n\nWindows\n\nWindow - Side Slide Centre Left \n\nWindow - Side Slide Centre Right \n\n### Interior \n\nOther\n\nRubber - Cargo Floor Covering \n\nLining material\n\nCargo Area - Fully Trimmed Sides \n\nWooden Cargo Floor \n\n### Seating \n\nFront row seats\n\nSeat - Drivers Height Adjust (includes lumbar) \n\nSeat - Double Bench \n\nSeat - Height Adjust Driver/Passen (incl. lumbar) \n\n### Instruments & controls \n\nDisplay\n\nMulti-functionTrip Comp w/- open door display \n\nTrip Computer - Basic \n\nNavigation\n\nGPS (Satellite Navigation) RNS510 inc MFD/Aux In \n\n### Exterior \n\nBody coloured\n\nBody Colour - Bumpers \n\nMirrors\n\nPower Door Mirrors - Folding \n\nPaint\n\nPaint - Metallic \n\nPaint - Pearl \n\nSunroof\n\nSunroof - Sliding/Tilting in Cab \n\n### Body \n\nDoors\n\nDoor - side sliding RHS(drivers side) \n\nDoors - Rear Wing 270 degree opening \n\nDoors - Rear Wing w/- Heated Windows \n\nPower Sliding Side Doors \n\nRoof\n\nHigh Roof in Body Colour \n\nHigh Roof in White \n\nMid Roof in Body Colour \n\nC-Rail Roof Rack Prep \n\n### Electrical \n\nBattery\n\nBattery - Dual (2nd) \n\nBattery - Stronger \n\n### Steering \n\nOperation\n\nMulti-function Steering Wheel \n\n### Suspension \n\nType\n\nReinforced Standard Dampers & Springs \n\nSuspension - Upgraded Shocks & Springs \n\n### Wheels & tyres \n\nFront rim\n\n17\" Alloy Wheels - Thunder \n\nOther\n\nChild lock on side slilding door \n\n## Currently listed for sale\n\n\u301087\u2020View all\u3011 \n\n \u301093\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $23,000* Excl. Govt. Charges \u3011 \u301094\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Manual MY14 $29,888 Drive Away \u3011 \u301095\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Auto MY14 $20,000* Excl. Govt. Charges \u3011 \u301096\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Auto MY15 $35,000* Excl. Govt. Charges \u3011 \u301097\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY14 $15,000* Excl. Govt. Charges \u3011 \u301098\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $32,990 Drive Away \u3011 \u301099\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $19,500* Excl. Govt. Charges \u3011 \u3010100\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Manual MY14 $16,900* Excl. Govt. Charges \u3011 \n\n## Volkswagen Transporter Car Reviews\n\n\u3010101\u2020View all\u3011 \n\n \u3010102\u2020 Review 21 Volkswagen Transporter 2016 Review April 2016 \u3011 \u3010103\u2020 Review 12 Volkswagen Transporter 2016 Review December 2015 \u3011 \u3010104\u2020 Review 16 Volkswagen Transporter and Multivan T6 2015 Review July 2015 \u3011 \n\n## Volkswagen Transporter Car News\n\n\u3010105\u2020View ",
+ "title": "Volkswagen Transporter TDI340 2014 - Pricing & Specifications | carsales.com.au"
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 0.0,
+ "metadata": {
+ "_cite_metadata": {
+ "citation_format": {
+ "name": "tether_og"
+ },
+ "metadata_list": [
+ {
+ "type": "webpage",
+ "title": "Volkswagen Transporter TDI340 2014 - Pricing & Specifications | carsales.com.au",
+ "url": "https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/",
+ "text": "\nNumber of Airbags\n\n2 \n\n### Engine \n\nEngine Type\n\nPiston \n\nEngine Location\n\nFront \n\nEngine Size (cc)\n\n1968 cc \n\nEngine Size (L)\n\n2.0 L \n\nInduction\n\nTurbo Intercooled \n\nEngine Configuration\n\nIn-line \n\nCylinders\n\n4 \n\nCamshaft\n\nDouble Overhead Cam \n\nValves/Ports per Cylinder\n\n4 \n\nCompression ratio\n\n16.5 \n\nEngine Code\n\nCAAC \n\nGeneric Engine Type\n\nPiston \n\nPower\n\n103.0kW @ 3500rpm \n\nTorque\n\n340Nm @ 1750-2500rpm \n\nPower to Weight Ratio\n\n52.9 kW/t \n\n### Transmission & drivetrain \n\nGears\n\n7 \n\nGear Type\n\nDirect-Shift Gearbox (Sports Automatic Dual Clutch) \n\nGeneric Gear Type\n\nAutomatic \n\nGear Location\n\nDash \n\nDrive\n\nFront Wheel Drive \n\n### Fuel \n\nFuel Type\n\nDiesel \n\nFuel Capacity\n\n80 L \n\nFuel Delivery\n\nCommon-rail Direct Injection \n\nMethod of Delivery\n\nElectronic Sequential \n\nFuel Consumption Combined\n\n8.2 L/100km \n\nFuel Consumption Highway\n\n6.9 L/100km \n\nFuel Consumption City\n\n10.2 L/100km \n\nFuel Average Distance\n\n976 km \n\nFuel Maximum Distance\n\n1159 km \n\nFuel Minimum Distance\n\n784 km \n\nCO2 Emission Combined\n\n216 g/km \n\nCO2 Extra Urban\n\n182 g/km \n\nCO2 Urban\n\n274 g/km \n\nGreenhouse Rating\n\n6 \n\nAir Pollution Rating\n\n5 \n\nGreen Star Rating\n\n3 \n\n### Steering \n\nSteering\n\nRack and Pinion \n\n### Wheels & tyres \n\nRim Material\n\nSteel \n\nFront Rim Description\n\n16x6.5 \n\nRear Rim Description\n\n16x6.5 \n\nFront Tyre Description\n\n\u301092\u2020205/65 R16 C\u2020www.tyresales.com.au\u3011 \n\nRear Tyre Description\n\n\u301092\u2020205/65 R16 C\u2020www.tyresales.com.au\u3011 \n\n### Dimensions & weights \n\nLength\n\n5292 mm \n\nWidth\n\n1904 mm \n\nHeight\n\n1990 mm \n\nWheelbase\n\n3400 mm \n\nTrack Front\n\n1628 mm \n\nTrack Rear\n\n1628 mm \n\nKerb Weight\n\n1948 kg \n\nBoot / Load Space Max (L)\n\n6700 L \n\nGross Vehicle Mass\n\n3000 kg \n\nPayload\n\n1052 kg \n\nTowing Capacity (braked)\n\n2000 kg \n\nTowing Capacity (Unbraked)\n\n750 kg \n\nLoad Length\n\n2753 mm \n\nLoad Width\n\n1692 mm \n\nLoad Height\n\n1410 mm \n\nWidth Between Wheel Arches\n\n1244 mm \n\nRear Side Door Width\n\n1020 mm \n\nRear Side Door Height\n\n1284 mm \n\n### Warranty & service \n\nWarranty in Years from First Registration\n\n3 yr \n\nWarranty in Km\n\nUnlimited km \n\nWarranty Customer Assistance\n\n3Yrs Roadside \n\nWarranty Anti Corrosion in Years from First Registration\n\n12 yr \n\nRegular Service Interval in Km\n\n15000 km \n\nRegular Service Interval in Months\n\n12 mth \n\n### Other \n\nCountry of Origin\n\nGERMANY \n\nLaunch Year\n\n2013 \n\nLaunch Month\n\n9 \n\nGeneration Name\n\nT5 \n\nSeries\n\nT5 \n\nModel Year\n\nMY14 \n\nBadge\n\nTDI340 \n\nDoors\n\n4 \n\nSeat Capacity\n\n5 \n\nBody Style\n\nCrewvan (Van) \n\nOverview\n\nCovering all aspects utilitarian, from cab/chassis through the crew cab, the short and long wheel based front-wheel-drive vans, right up to the all-wheel-drive 4-Motion, the T5 Transporter remains VW's best selling commercial vehicle. That fact alone speaks volumes in this tough sector. Adopting car-like ambiance and feel, sporting a range of 2.0-litre turbodiesels and transmissions, you can then opt for any number of the bewildering array of options. Load volumes for the Crew vans runs from 5.8 m up to 7.8 m in the LWB medium-roof. Safety is very good for the class with four ANCAP stars. \n\n### P plate status \n\nNSW Probationary Status\n\nAllowed \n\n### Approximate Running Costs \n\nFuel cost per 1000km\n\n$159.00 \n\nFuel cost per fill\n\n$154.00 \n\n### Audio, visual & communication \n\nInputs\n\nMP3 decoder \n\nCD / DVD\n\nCD player \n\n### Safety & security \n\nAirbags\n\nDriver \n\nPassenger \n\nSeatbelts\n\nLap/sash for 2 seats \n\nPretensioners 1st row (front) \n\nAdjustable height 1st row \n\nEmergency\n\nBrake assist \n\nVehicle control\n\nABS (antilock brakes) \n\nTraction \n\nElectronic stability \n\nHill holder \n\nEBD (electronic brake force distribution) \n\nSecurity\n\nCentral locking - remote/keyless \n\nEngine immobiliser \n\n### Comfort & convenience \n\nAir conditioning\n\nAir conditioning \n\n### Lights & windows \n\nLights\n\nDaytime running lamps \n\nFog lamps - rear \n\nPower windows\n\nFront only \n\n### Interior \n\nCloth\n\nTrim \n\n### Instruments & controls \n\nDisplay\n\nClock - digital \n\nGauges\n\nTacho \n\n### Exterior \n\nMirrors\n\nElectric - heated \n\nMudflaps\n\nFront \n\nRear \n\n### Body \n\nDoors\n\nSide sliding lhs(passenger side) \n\n### Brakes \n\nFront\n\nVentilated \n\nRear\n\nSolid \n\n### Suspension \n\nType\n\nIndependent front suspension \n\n### Option pack \n\nOption pack\n\nComfort Pack \n\nAirbags - Front Side & Head \n\n- Airbags - Head for 1st Row Seats (Front)\n\n- Airbags - Side for 1st Row Occupants (Front)\n\nControl - Park Distance Front & Rear \n\n- Control - Park Distance Front\n\n- Control - Park Distance Rear\n\nControl - Park Distance Front & Rear with Camera \n\n- Control - Park Distance Front\n\n- Control - Park Distance Rear\n\n- Parking Assist - Graphical Display\n\n- Camera - Rear Vision\n\nLight & Sight Pack \n\n- Headlamps - See me home\n\n- Rain Sensor (Auto wipers)\n\n### Audio, visual & communication \n\nInputs\n\nMedia Device Interface - Aux Ipod/USB Socket \n\nBluetooth\n\nBluetooth Phone Preparation \n\nRadio\n\nRCD310 Radio with Media-In Interface \n\n### Safety & security \n\nDriver assistance\n\nControl - Park Distance Rear \n\nSecurity\n\nAlarm \n\n### Comfort & convenience \n\nAir conditioning\n\nAir Conditioning - Rear \n\nDriving\n\nCruise Control \n\nArmrests\n\nArmrest - Drivers Seat \n\nArmrest - Front (Driver & Passenger) \n\nCargo space\n\nFixed Partition with Fixed Window \n\n### Lights & windows \n\nLights\n\nFog Lamps - Front with Fixed Corner Function \n\nWindows\n\nWindow - Side Slide Centre Left \n\nWindow - Side Slide Centre Right \n\n### Interior \n\nOther\n\nRubber - Cargo Floor Covering \n\nLining material\n\nCargo Area - Fully Trimmed Sides \n\nWooden Cargo Floor \n\n### Seating \n\nFront row seats\n\nSeat - Drivers Height Adjust (includes lumbar) \n\nSeat - Double Bench \n\nSeat - Height Adjust Driver/Passen (incl. lumbar) \n\n### Instruments & controls \n\nDisplay\n\nMulti-functionTrip Comp w/- open door display \n\nTrip Computer - Basic \n\nNavigation\n\nGPS (Satellite Navigation) RNS510 inc MFD/Aux In \n\n### Exterior \n\nBody coloured\n\nBody Colour - Bumpers \n\nMirrors\n\nPower Door Mirrors - Folding \n\nPaint\n\nPaint - Metallic \n\nPaint - Pearl \n\nSunroof\n\nSunroof - Sliding/Tilting in Cab \n\n### Body \n\nDoors\n\nDoor - side sliding RHS(drivers side) \n\nDoors - Rear Wing 270 degree opening \n\nDoors - Rear Wing w/- Heated Windows \n\nPower Sliding Side Doors \n\nRoof\n\nHigh Roof in Body Colour \n\nHigh Roof in White \n\nMid Roof in Body Colour \n\nC-Rail Roof Rack Prep \n\n### Electrical \n\nBattery\n\nBattery - Dual (2nd) \n\nBattery - Stronger \n\n### Steering \n\nOperation\n\nMulti-function Steering Wheel \n\n### Suspension \n\nType\n\nReinforced Standard Dampers & Springs \n\nSuspension - Upgraded Shocks & Springs \n\n### Wheels & tyres \n\nFront rim\n\n17\" Alloy Wheels - Thunder \n\nOther\n\nChild lock on side slilding door \n\n## Currently listed for sale\n\n\u301087\u2020View all\u3011 \n\n \u301093\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $23,000* Excl. Govt. Charges \u3011 \u301094\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Manual MY14 $29,888 Drive Away \u3011 \u301095\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Auto MY14 $20,000* Excl. Govt. Charges \u3011 \u301096\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Auto MY15 $35,000* Excl. Govt. Charges \u3011 \u301097\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY14 $15,000* Excl. Govt. Charges \u3011 \u301098\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $32,990 Drive Away \u3011 \u301099\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $19,500* Excl. Govt. Charges \u3011 \u3010100\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Manual MY14 $16,900* Excl. Govt. Charges \u3011 \n\n## Volkswagen Transporter Car Reviews\n\n\u3010101\u2020View all\u3011 \n\n \u3010102\u2020 Review 21 Volkswagen Transporter 2016 Review April 2016 \u3011 \u3010103\u2020 Review 12 Volkswagen Transporter 2016 Review December 2015 \u3011 \u3010104\u2020 Review 16 Volkswagen Transporter and Multivan T6 2015 Review July 2015 \u3011 \n\n## Volkswagen Transporter Car News\n\n\u3010105\u2020View ",
+ "pub_date": null,
+ "extra": null
+ }
+ ],
+ "original_query": null
+ },
+ "command": "mclick",
+ "args": ["[0, 2, 13]"],
+ "status": "finished",
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "gpt-4",
+ "parent_id": "bdad1960-d8ec-4bbb-b27f-437d54655069",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "bdad1960-d8ec-4bbb-b27f-437d54655069",
+ "children": ["9e0f92f9-9b23-464a-98ec-2b034164f2ec"]
+ },
+ "9e0f92f9-9b23-464a-98ec-2b034164f2ec": {
+ "id": "9e0f92f9-9b23-464a-98ec-2b034164f2ec",
+ "message": {
+ "id": "9e0f92f9-9b23-464a-98ec-2b034164f2ec",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1704629977.607185,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ "The fuel consumption of a 2014 Volkswagen Transporter, specifically the TDI340 model with a 2.0 L engine, is reported as follows:\n- Combined: 8.2 L/100km\n- Highway: 6.9 L/100km\n- City: 10.2 L/100km\n\nThese figures can vary based on driving conditions and the vehicle's load. The actual consumption might be higher when carrying eight people due to the additional weight\u301013\u2020source\u3011."
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": true,
+ "weight": 1.0,
+ "metadata": {
+ "finish_details": {
+ "type": "stop",
+ "stop_tokens": [100260]
+ },
+ "citations": [
+ {
+ "start_ix": 368,
+ "end_ix": 379,
+ "citation_format_type": "tether_og",
+ "metadata": {
+ "type": "webpage",
+ "title": "Volkswagen Transporter TDI340 2014 - Pricing & Specifications | carsales.com.au",
+ "url": "https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/",
+ "text": "\nNumber of Airbags\n\n2 \n\n### Engine \n\nEngine Type\n\nPiston \n\nEngine Location\n\nFront \n\nEngine Size (cc)\n\n1968 cc \n\nEngine Size (L)\n\n2.0 L \n\nInduction\n\nTurbo Intercooled \n\nEngine Configuration\n\nIn-line \n\nCylinders\n\n4 \n\nCamshaft\n\nDouble Overhead Cam \n\nValves/Ports per Cylinder\n\n4 \n\nCompression ratio\n\n16.5 \n\nEngine Code\n\nCAAC \n\nGeneric Engine Type\n\nPiston \n\nPower\n\n103.0kW @ 3500rpm \n\nTorque\n\n340Nm @ 1750-2500rpm \n\nPower to Weight Ratio\n\n52.9 kW/t \n\n### Transmission & drivetrain \n\nGears\n\n7 \n\nGear Type\n\nDirect-Shift Gearbox (Sports Automatic Dual Clutch) \n\nGeneric Gear Type\n\nAutomatic \n\nGear Location\n\nDash \n\nDrive\n\nFront Wheel Drive \n\n### Fuel \n\nFuel Type\n\nDiesel \n\nFuel Capacity\n\n80 L \n\nFuel Delivery\n\nCommon-rail Direct Injection \n\nMethod of Delivery\n\nElectronic Sequential \n\nFuel Consumption Combined\n\n8.2 L/100km \n\nFuel Consumption Highway\n\n6.9 L/100km \n\nFuel Consumption City\n\n10.2 L/100km \n\nFuel Average Distance\n\n976 km \n\nFuel Maximum Distance\n\n1159 km \n\nFuel Minimum Distance\n\n784 km \n\nCO2 Emission Combined\n\n216 g/km \n\nCO2 Extra Urban\n\n182 g/km \n\nCO2 Urban\n\n274 g/km \n\nGreenhouse Rating\n\n6 \n\nAir Pollution Rating\n\n5 \n\nGreen Star Rating\n\n3 \n\n### Steering \n\nSteering\n\nRack and Pinion \n\n### Wheels & tyres \n\nRim Material\n\nSteel \n\nFront Rim Description\n\n16x6.5 \n\nRear Rim Description\n\n16x6.5 \n\nFront Tyre Description\n\n\u301092\u2020205/65 R16 C\u2020www.tyresales.com.au\u3011 \n\nRear Tyre Description\n\n\u301092\u2020205/65 R16 C\u2020www.tyresales.com.au\u3011 \n\n### Dimensions & weights \n\nLength\n\n5292 mm \n\nWidth\n\n1904 mm \n\nHeight\n\n1990 mm \n\nWheelbase\n\n3400 mm \n\nTrack Front\n\n1628 mm \n\nTrack Rear\n\n1628 mm \n\nKerb Weight\n\n1948 kg \n\nBoot / Load Space Max (L)\n\n6700 L \n\nGross Vehicle Mass\n\n3000 kg \n\nPayload\n\n1052 kg \n\nTowing Capacity (braked)\n\n2000 kg \n\nTowing Capacity (Unbraked)\n\n750 kg \n\nLoad Length\n\n2753 mm \n\nLoad Width\n\n1692 mm \n\nLoad Height\n\n1410 mm \n\nWidth Between Wheel Arches\n\n1244 mm \n\nRear Side Door Width\n\n1020 mm \n\nRear Side Door Height\n\n1284 mm \n\n### Warranty & service \n\nWarranty in Years from First Registration\n\n3 yr \n\nWarranty in Km\n\nUnlimited km \n\nWarranty Customer Assistance\n\n3Yrs Roadside \n\nWarranty Anti Corrosion in Years from First Registration\n\n12 yr \n\nRegular Service Interval in Km\n\n15000 km \n\nRegular Service Interval in Months\n\n12 mth \n\n### Other \n\nCountry of Origin\n\nGERMANY \n\nLaunch Year\n\n2013 \n\nLaunch Month\n\n9 \n\nGeneration Name\n\nT5 \n\nSeries\n\nT5 \n\nModel Year\n\nMY14 \n\nBadge\n\nTDI340 \n\nDoors\n\n4 \n\nSeat Capacity\n\n5 \n\nBody Style\n\nCrewvan (Van) \n\nOverview\n\nCovering all aspects utilitarian, from cab/chassis through the crew cab, the short and long wheel based front-wheel-drive vans, right up to the all-wheel-drive 4-Motion, the T5 Transporter remains VW's best selling commercial vehicle. That fact alone speaks volumes in this tough sector. Adopting car-like ambiance and feel, sporting a range of 2.0-litre turbodiesels and transmissions, you can then opt for any number of the bewildering array of options. Load volumes for the Crew vans runs from 5.8 m up to 7.8 m in the LWB medium-roof. Safety is very good for the class with four ANCAP stars. \n\n### P plate status \n\nNSW Probationary Status\n\nAllowed \n\n### Approximate Running Costs \n\nFuel cost per 1000km\n\n$159.00 \n\nFuel cost per fill\n\n$154.00 \n\n### Audio, visual & communication \n\nInputs\n\nMP3 decoder \n\nCD / DVD\n\nCD player \n\n### Safety & security \n\nAirbags\n\nDriver \n\nPassenger \n\nSeatbelts\n\nLap/sash for 2 seats \n\nPretensioners 1st row (front) \n\nAdjustable height 1st row \n\nEmergency\n\nBrake assist \n\nVehicle control\n\nABS (antilock brakes) \n\nTraction \n\nElectronic stability \n\nHill holder \n\nEBD (electronic brake force distribution) \n\nSecurity\n\nCentral locking - remote/keyless \n\nEngine immobiliser \n\n### Comfort & convenience \n\nAir conditioning\n\nAir conditioning \n\n### Lights & windows \n\nLights\n\nDaytime running lamps \n\nFog lamps - rear \n\nPower windows\n\nFront only \n\n### Interior \n\nCloth\n\nTrim \n\n### Instruments & controls \n\nDisplay\n\nClock - digital \n\nGauges\n\nTacho \n\n### Exterior \n\nMirrors\n\nElectric - heated \n\nMudflaps\n\nFront \n\nRear \n\n### Body \n\nDoors\n\nSide sliding lhs(passenger side) \n\n### Brakes \n\nFront\n\nVentilated \n\nRear\n\nSolid \n\n### Suspension \n\nType\n\nIndependent front suspension \n\n### Option pack \n\nOption pack\n\nComfort Pack \n\nAirbags - Front Side & Head \n\n- Airbags - Head for 1st Row Seats (Front)\n\n- Airbags - Side for 1st Row Occupants (Front)\n\nControl - Park Distance Front & Rear \n\n- Control - Park Distance Front\n\n- Control - Park Distance Rear\n\nControl - Park Distance Front & Rear with Camera \n\n- Control - Park Distance Front\n\n- Control - Park Distance Rear\n\n- Parking Assist - Graphical Display\n\n- Camera - Rear Vision\n\nLight & Sight Pack \n\n- Headlamps - See me home\n\n- Rain Sensor (Auto wipers)\n\n### Audio, visual & communication \n\nInputs\n\nMedia Device Interface - Aux Ipod/USB Socket \n\nBluetooth\n\nBluetooth Phone Preparation \n\nRadio\n\nRCD310 Radio with Media-In Interface \n\n### Safety & security \n\nDriver assistance\n\nControl - Park Distance Rear \n\nSecurity\n\nAlarm \n\n### Comfort & convenience \n\nAir conditioning\n\nAir Conditioning - Rear \n\nDriving\n\nCruise Control \n\nArmrests\n\nArmrest - Drivers Seat \n\nArmrest - Front (Driver & Passenger) \n\nCargo space\n\nFixed Partition with Fixed Window \n\n### Lights & windows \n\nLights\n\nFog Lamps - Front with Fixed Corner Function \n\nWindows\n\nWindow - Side Slide Centre Left \n\nWindow - Side Slide Centre Right \n\n### Interior \n\nOther\n\nRubber - Cargo Floor Covering \n\nLining material\n\nCargo Area - Fully Trimmed Sides \n\nWooden Cargo Floor \n\n### Seating \n\nFront row seats\n\nSeat - Drivers Height Adjust (includes lumbar) \n\nSeat - Double Bench \n\nSeat - Height Adjust Driver/Passen (incl. lumbar) \n\n### Instruments & controls \n\nDisplay\n\nMulti-functionTrip Comp w/- open door display \n\nTrip Computer - Basic \n\nNavigation\n\nGPS (Satellite Navigation) RNS510 inc MFD/Aux In \n\n### Exterior \n\nBody coloured\n\nBody Colour - Bumpers \n\nMirrors\n\nPower Door Mirrors - Folding \n\nPaint\n\nPaint - Metallic \n\nPaint - Pearl \n\nSunroof\n\nSunroof - Sliding/Tilting in Cab \n\n### Body \n\nDoors\n\nDoor - side sliding RHS(drivers side) \n\nDoors - Rear Wing 270 degree opening \n\nDoors - Rear Wing w/- Heated Windows \n\nPower Sliding Side Doors \n\nRoof\n\nHigh Roof in Body Colour \n\nHigh Roof in White \n\nMid Roof in Body Colour \n\nC-Rail Roof Rack Prep \n\n### Electrical \n\nBattery\n\nBattery - Dual (2nd) \n\nBattery - Stronger \n\n### Steering \n\nOperation\n\nMulti-function Steering Wheel \n\n### Suspension \n\nType\n\nReinforced Standard Dampers & Springs \n\nSuspension - Upgraded Shocks & Springs \n\n### Wheels & tyres \n\nFront rim\n\n17\" Alloy Wheels - Thunder \n\nOther\n\nChild lock on side slilding door \n\n## Currently listed for sale\n\n\u301087\u2020View all\u3011 \n\n \u301093\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $23,000* Excl. Govt. Charges \u3011 \u301094\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Manual MY14 $29,888 Drive Away \u3011 \u301095\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Auto MY14 $20,000* Excl. Govt. Charges \u3011 \u301096\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Auto MY15 $35,000* Excl. Govt. Charges \u3011 \u301097\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY14 $15,000* Excl. Govt. Charges \u3011 \u301098\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $32,990 Drive Away \u3011 \u301099\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $19,500* Excl. Govt. Charges \u3011 \u3010100\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Manual MY14 $16,900* Excl. Govt. Charges \u3011 \n\n## Volkswagen Transporter Car Reviews\n\n\u3010101\u2020View all\u3011 \n\n \u3010102\u2020 Review 21 Volkswagen Transporter 2016 Review April 2016 \u3011 \u3010103\u2020 Review 12 Volkswagen Transporter 2016 Review December 2015 \u3011 \u3010104\u2020 Review 16 Volkswagen Transporter and Multivan T6 2015 Review July 2015 \u3011 \n\n## Volkswagen Transporter Car News\n\n\u3010105\u2020View ",
+ "pub_date": null,
+ "extra": {
+ "cited_message_idx": 13,
+ "evidence_text": "source"
+ }
+ }
+ }
+ ],
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "gpt-4",
+ "parent_id": "adf6394d-b5a9-422a-bc85-e388a20b219b",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "adf6394d-b5a9-422a-bc85-e388a20b219b",
+ "children": ["bbb26113-6bb3-40ef-91a2-02b92f6fd1d0"]
+ },
+ "bbb26113-6bb3-40ef-91a2-02b92f6fd1d0": {
+ "id": "bbb26113-6bb3-40ef-91a2-02b92f6fd1d0",
+ "message": {
+ "id": "bbb26113-6bb3-40ef-91a2-02b92f6fd1d0",
+ "author": {
+ "role": "user",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1704630026.249975,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": ["Is it for fully loaded car?"]
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 1.0,
+ "metadata": {
+ "finish_details": {
+ "type": "unknown"
+ },
+ "citations": [],
+ "voice_mode_message": false,
+ "timestamp_": "absolute",
+ "message_type": null
+ },
+ "recipient": "all"
+ },
+ "parent": "9e0f92f9-9b23-464a-98ec-2b034164f2ec",
+ "children": ["88a0cf9f-e860-4b34-8e7e-65f8346f4862"]
+ },
+ "88a0cf9f-e860-4b34-8e7e-65f8346f4862": {
+ "id": "88a0cf9f-e860-4b34-8e7e-65f8346f4862",
+ "message": {
+ "id": "88a0cf9f-e860-4b34-8e7e-65f8346f4862",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1704630034.629284,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ "The fuel consumption figures for the 2014 Volkswagen Transporter TDI340, such as the combined 8.2 L/100km, are typically based on standardized tests. These tests don't necessarily reflect a fully loaded vehicle. The actual fuel consumption can vary and is likely to be higher when the vehicle is fully loaded with passengers or cargo, due to the increased weight and potential changes in driving conditions such as more frequent acceleration and deceleration\u301013\u2020source\u3011."
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": true,
+ "weight": 1.0,
+ "metadata": {
+ "finish_details": {
+ "type": "stop",
+ "stop_tokens": [100260]
+ },
+ "citations": [
+ {
+ "start_ix": 458,
+ "end_ix": 469,
+ "citation_format_type": "tether_og",
+ "metadata": {
+ "type": "webpage",
+ "title": "Volkswagen Transporter TDI340 2014 - Pricing & Specifications | carsales.com.au",
+ "url": "https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/",
+ "text": "\nNumber of Airbags\n\n2 \n\n### Engine \n\nEngine Type\n\nPiston \n\nEngine Location\n\nFront \n\nEngine Size (cc)\n\n1968 cc \n\nEngine Size (L)\n\n2.0 L \n\nInduction\n\nTurbo Intercooled \n\nEngine Configuration\n\nIn-line \n\nCylinders\n\n4 \n\nCamshaft\n\nDouble Overhead Cam \n\nValves/Ports per Cylinder\n\n4 \n\nCompression ratio\n\n16.5 \n\nEngine Code\n\nCAAC \n\nGeneric Engine Type\n\nPiston \n\nPower\n\n103.0kW @ 3500rpm \n\nTorque\n\n340Nm @ 1750-2500rpm \n\nPower to Weight Ratio\n\n52.9 kW/t \n\n### Transmission & drivetrain \n\nGears\n\n7 \n\nGear Type\n\nDirect-Shift Gearbox (Sports Automatic Dual Clutch) \n\nGeneric Gear Type\n\nAutomatic \n\nGear Location\n\nDash \n\nDrive\n\nFront Wheel Drive \n\n### Fuel \n\nFuel Type\n\nDiesel \n\nFuel Capacity\n\n80 L \n\nFuel Delivery\n\nCommon-rail Direct Injection \n\nMethod of Delivery\n\nElectronic Sequential \n\nFuel Consumption Combined\n\n8.2 L/100km \n\nFuel Consumption Highway\n\n6.9 L/100km \n\nFuel Consumption City\n\n10.2 L/100km \n\nFuel Average Distance\n\n976 km \n\nFuel Maximum Distance\n\n1159 km \n\nFuel Minimum Distance\n\n784 km \n\nCO2 Emission Combined\n\n216 g/km \n\nCO2 Extra Urban\n\n182 g/km \n\nCO2 Urban\n\n274 g/km \n\nGreenhouse Rating\n\n6 \n\nAir Pollution Rating\n\n5 \n\nGreen Star Rating\n\n3 \n\n### Steering \n\nSteering\n\nRack and Pinion \n\n### Wheels & tyres \n\nRim Material\n\nSteel \n\nFront Rim Description\n\n16x6.5 \n\nRear Rim Description\n\n16x6.5 \n\nFront Tyre Description\n\n\u301092\u2020205/65 R16 C\u2020www.tyresales.com.au\u3011 \n\nRear Tyre Description\n\n\u301092\u2020205/65 R16 C\u2020www.tyresales.com.au\u3011 \n\n### Dimensions & weights \n\nLength\n\n5292 mm \n\nWidth\n\n1904 mm \n\nHeight\n\n1990 mm \n\nWheelbase\n\n3400 mm \n\nTrack Front\n\n1628 mm \n\nTrack Rear\n\n1628 mm \n\nKerb Weight\n\n1948 kg \n\nBoot / Load Space Max (L)\n\n6700 L \n\nGross Vehicle Mass\n\n3000 kg \n\nPayload\n\n1052 kg \n\nTowing Capacity (braked)\n\n2000 kg \n\nTowing Capacity (Unbraked)\n\n750 kg \n\nLoad Length\n\n2753 mm \n\nLoad Width\n\n1692 mm \n\nLoad Height\n\n1410 mm \n\nWidth Between Wheel Arches\n\n1244 mm \n\nRear Side Door Width\n\n1020 mm \n\nRear Side Door Height\n\n1284 mm \n\n### Warranty & service \n\nWarranty in Years from First Registration\n\n3 yr \n\nWarranty in Km\n\nUnlimited km \n\nWarranty Customer Assistance\n\n3Yrs Roadside \n\nWarranty Anti Corrosion in Years from First Registration\n\n12 yr \n\nRegular Service Interval in Km\n\n15000 km \n\nRegular Service Interval in Months\n\n12 mth \n\n### Other \n\nCountry of Origin\n\nGERMANY \n\nLaunch Year\n\n2013 \n\nLaunch Month\n\n9 \n\nGeneration Name\n\nT5 \n\nSeries\n\nT5 \n\nModel Year\n\nMY14 \n\nBadge\n\nTDI340 \n\nDoors\n\n4 \n\nSeat Capacity\n\n5 \n\nBody Style\n\nCrewvan (Van) \n\nOverview\n\nCovering all aspects utilitarian, from cab/chassis through the crew cab, the short and long wheel based front-wheel-drive vans, right up to the all-wheel-drive 4-Motion, the T5 Transporter remains VW's best selling commercial vehicle. That fact alone speaks volumes in this tough sector. Adopting car-like ambiance and feel, sporting a range of 2.0-litre turbodiesels and transmissions, you can then opt for any number of the bewildering array of options. Load volumes for the Crew vans runs from 5.8 m up to 7.8 m in the LWB medium-roof. Safety is very good for the class with four ANCAP stars. \n\n### P plate status \n\nNSW Probationary Status\n\nAllowed \n\n### Approximate Running Costs \n\nFuel cost per 1000km\n\n$159.00 \n\nFuel cost per fill\n\n$154.00 \n\n### Audio, visual & communication \n\nInputs\n\nMP3 decoder \n\nCD / DVD\n\nCD player \n\n### Safety & security \n\nAirbags\n\nDriver \n\nPassenger \n\nSeatbelts\n\nLap/sash for 2 seats \n\nPretensioners 1st row (front) \n\nAdjustable height 1st row \n\nEmergency\n\nBrake assist \n\nVehicle control\n\nABS (antilock brakes) \n\nTraction \n\nElectronic stability \n\nHill holder \n\nEBD (electronic brake force distribution) \n\nSecurity\n\nCentral locking - remote/keyless \n\nEngine immobiliser \n\n### Comfort & convenience \n\nAir conditioning\n\nAir conditioning \n\n### Lights & windows \n\nLights\n\nDaytime running lamps \n\nFog lamps - rear \n\nPower windows\n\nFront only \n\n### Interior \n\nCloth\n\nTrim \n\n### Instruments & controls \n\nDisplay\n\nClock - digital \n\nGauges\n\nTacho \n\n### Exterior \n\nMirrors\n\nElectric - heated \n\nMudflaps\n\nFront \n\nRear \n\n### Body \n\nDoors\n\nSide sliding lhs(passenger side) \n\n### Brakes \n\nFront\n\nVentilated \n\nRear\n\nSolid \n\n### Suspension \n\nType\n\nIndependent front suspension \n\n### Option pack \n\nOption pack\n\nComfort Pack \n\nAirbags - Front Side & Head \n\n- Airbags - Head for 1st Row Seats (Front)\n\n- Airbags - Side for 1st Row Occupants (Front)\n\nControl - Park Distance Front & Rear \n\n- Control - Park Distance Front\n\n- Control - Park Distance Rear\n\nControl - Park Distance Front & Rear with Camera \n\n- Control - Park Distance Front\n\n- Control - Park Distance Rear\n\n- Parking Assist - Graphical Display\n\n- Camera - Rear Vision\n\nLight & Sight Pack \n\n- Headlamps - See me home\n\n- Rain Sensor (Auto wipers)\n\n### Audio, visual & communication \n\nInputs\n\nMedia Device Interface - Aux Ipod/USB Socket \n\nBluetooth\n\nBluetooth Phone Preparation \n\nRadio\n\nRCD310 Radio with Media-In Interface \n\n### Safety & security \n\nDriver assistance\n\nControl - Park Distance Rear \n\nSecurity\n\nAlarm \n\n### Comfort & convenience \n\nAir conditioning\n\nAir Conditioning - Rear \n\nDriving\n\nCruise Control \n\nArmrests\n\nArmrest - Drivers Seat \n\nArmrest - Front (Driver & Passenger) \n\nCargo space\n\nFixed Partition with Fixed Window \n\n### Lights & windows \n\nLights\n\nFog Lamps - Front with Fixed Corner Function \n\nWindows\n\nWindow - Side Slide Centre Left \n\nWindow - Side Slide Centre Right \n\n### Interior \n\nOther\n\nRubber - Cargo Floor Covering \n\nLining material\n\nCargo Area - Fully Trimmed Sides \n\nWooden Cargo Floor \n\n### Seating \n\nFront row seats\n\nSeat - Drivers Height Adjust (includes lumbar) \n\nSeat - Double Bench \n\nSeat - Height Adjust Driver/Passen (incl. lumbar) \n\n### Instruments & controls \n\nDisplay\n\nMulti-functionTrip Comp w/- open door display \n\nTrip Computer - Basic \n\nNavigation\n\nGPS (Satellite Navigation) RNS510 inc MFD/Aux In \n\n### Exterior \n\nBody coloured\n\nBody Colour - Bumpers \n\nMirrors\n\nPower Door Mirrors - Folding \n\nPaint\n\nPaint - Metallic \n\nPaint - Pearl \n\nSunroof\n\nSunroof - Sliding/Tilting in Cab \n\n### Body \n\nDoors\n\nDoor - side sliding RHS(drivers side) \n\nDoors - Rear Wing 270 degree opening \n\nDoors - Rear Wing w/- Heated Windows \n\nPower Sliding Side Doors \n\nRoof\n\nHigh Roof in Body Colour \n\nHigh Roof in White \n\nMid Roof in Body Colour \n\nC-Rail Roof Rack Prep \n\n### Electrical \n\nBattery\n\nBattery - Dual (2nd) \n\nBattery - Stronger \n\n### Steering \n\nOperation\n\nMulti-function Steering Wheel \n\n### Suspension \n\nType\n\nReinforced Standard Dampers & Springs \n\nSuspension - Upgraded Shocks & Springs \n\n### Wheels & tyres \n\nFront rim\n\n17\" Alloy Wheels - Thunder \n\nOther\n\nChild lock on side slilding door \n\n## Currently listed for sale\n\n\u301087\u2020View all\u3011 \n\n \u301093\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $23,000* Excl. Govt. Charges \u3011 \u301094\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Manual MY14 $29,888 Drive Away \u3011 \u301095\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Auto MY14 $20,000* Excl. Govt. Charges \u3011 \u301096\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Auto MY15 $35,000* Excl. Govt. Charges \u3011 \u301097\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY14 $15,000* Excl. Govt. Charges \u3011 \u301098\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $32,990 Drive Away \u3011 \u301099\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $19,500* Excl. Govt. Charges \u3011 \u3010100\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Manual MY14 $16,900* Excl. Govt. Charges \u3011 \n\n## Volkswagen Transporter Car Reviews\n\n\u3010101\u2020View all\u3011 \n\n \u3010102\u2020 Review 21 Volkswagen Transporter 2016 Review April 2016 \u3011 \u3010103\u2020 Review 12 Volkswagen Transporter 2016 Review December 2015 \u3011 \u3010104\u2020 Review 16 Volkswagen Transporter and Multivan T6 2015 Review July 2015 \u3011 \n\n## Volkswagen Transporter Car News\n\n\u3010105\u2020View ",
+ "pub_date": null,
+ "extra": {
+ "cited_message_idx": 13,
+ "evidence_text": "source"
+ }
+ }
+ }
+ ],
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "gpt-4",
+ "parent_id": "bbb26113-6bb3-40ef-91a2-02b92f6fd1d0",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "bbb26113-6bb3-40ef-91a2-02b92f6fd1d0",
+ "children": []
+ }
+ },
+ "moderation_results": [],
+ "current_node": "88a0cf9f-e860-4b34-8e7e-65f8346f4862",
+ "plugin_ids": null,
+ "conversation_id": "d6523d1e-7ec3-474f-a363-0e9dffdb3d93",
+ "conversation_template_id": null,
+ "gizmo_id": null,
+ "is_archived": false,
+ "safe_urls": [
+ "https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/",
+ "https://www.drive.com.au/reviews/volkswagen-transporter-review/",
+ "https://www.ultimatespecs.com/car-specs/volkswagen/118424/2021-volkswagen-transporter-t61-l2h1-20-tdi-110hp.html"
+ ],
+ "default_model_slug": null,
+ "id": "d6523d1e-7ec3-474f-a363-0e9dffdb3d93"
+ },
+ {
+ "title": "Conversation 2",
+ "create_time": 1697373097.899566,
+ "update_time": 1697373123.975989,
+ "mapping": {
+ "35f71f31-2118-461f-9032-e019a94eb20e": {
+ "id": "35f71f31-2118-461f-9032-e019a94eb20e",
+ "message": {
+ "id": "35f71f31-2118-461f-9032-e019a94eb20e",
+ "author": {
+ "role": "system",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": null,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": [""]
+ },
+ "status": "finished_successfully",
+ "end_turn": true,
+ "weight": 0.0,
+ "metadata": {
+ "is_visually_hidden_from_conversation": true
+ },
+ "recipient": "all"
+ },
+ "parent": "aaa1f95f-dd59-4786-ac6e-37f463163896",
+ "children": ["aaa2e334-b386-4ed8-b33b-5d788c882f1d"]
+ },
+ "aaa1f95f-dd59-4786-ac6e-37f463163896": {
+ "id": "aaa1f95f-dd59-4786-ac6e-37f463163896",
+ "message": null,
+ "parent": null,
+ "children": ["35f71f31-2118-461f-9032-e019a94eb20e"]
+ },
+ "aaa2e334-b386-4ed8-b33b-5d788c882f1d": {
+ "id": "aaa2e334-b386-4ed8-b33b-5d788c882f1d",
+ "message": {
+ "id": "aaa2e334-b386-4ed8-b33b-5d788c882f1d",
+ "author": {
+ "role": "user",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1697373097.900124,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ "In programming how to find if all intervals have the one point where they intersect. \nFor example in [2, 5] [4,5] [3,7] it will be 4."
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 1.0,
+ "metadata": {
+ "timestamp_": "absolute",
+ "message_type": null
+ },
+ "recipient": "all"
+ },
+ "parent": "35f71f31-2118-461f-9032-e019a94eb20e",
+ "children": ["9f97ac83-7408-4872-9e3b-ee4272c94e10"]
+ },
+ "9f97ac83-7408-4872-9e3b-ee4272c94e10": {
+ "id": "9f97ac83-7408-4872-9e3b-ee4272c94e10",
+ "message": {
+ "id": "9f97ac83-7408-4872-9e3b-ee4272c94e10",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1697373107.211353,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ "To find the point where all intervals intersect, you need to determine the common overlap region among all the intervals. Here's how you can do it in Python:\n\n```python\ndef find_intersection(intervals):\n # Sort intervals based on the start point\n intervals.sort(key=lambda x: x[0])\n\n # Initialize the result with the first interval\n result_start, result_end = intervals[0]\n\n # Iterate through the sorted intervals and find the common overlap\n for interval_start, interval_end in intervals[1:]:\n # If the current interval is within the current result interval\n if interval_start <= result_end:\n result_start = max(result_start, interval_start)\n result_end = min(result_end, interval_end)\n else:\n # No overlap found, return None\n return None\n\n # Return the common overlap region\n return result_start, result_end\n\n# Example intervals\nintervals = [[2, 5], [4, 5], [3, 7]]\n\n# Find the intersection\nintersection = find_intersection(intervals)\n\nif intersection:\n print(f\"The intersection point is: {intersection[0]}\")\nelse:\n print(\"There is no common intersection point.\")\n```\n\nIn this code, the `find_intersection` function takes a list of intervals as input. It sorts the intervals based on their start points and then iterates through the sorted intervals to find the common overlap region. If there is a common overlap, it returns the start and end points of the overlap. If there is no common overlap, it returns `None`. In the given example, the output will be:\n\n```\nThe intersection point is: 4\n```"
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": true,
+ "weight": 1.0,
+ "metadata": {
+ "finish_details": {
+ "type": "stop",
+ "stop_tokens": [100260]
+ },
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "text-davinci-002-render-sha",
+ "parent_id": "aaa2e334-b386-4ed8-b33b-5d788c882f1d",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "aaa2e334-b386-4ed8-b33b-5d788c882f1d",
+ "children": ["aaa21d18-8c68-43b2-8939-86fadac2b861"]
+ },
+ "aaa21d18-8c68-43b2-8939-86fadac2b861": {
+ "id": "aaa21d18-8c68-43b2-8939-86fadac2b861",
+ "message": {
+ "id": "aaa21d18-8c68-43b2-8939-86fadac2b861",
+ "author": {
+ "role": "user",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1697373115.892485,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": ["give me code in C#"]
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 1.0,
+ "metadata": {
+ "timestamp_": "absolute",
+ "message_type": null
+ },
+ "recipient": "all"
+ },
+ "parent": "9f97ac83-7408-4872-9e3b-ee4272c94e10",
+ "children": ["73a2fe12-36bd-4cc2-8460-8108d16cc42d"]
+ },
+ "73a2fe12-36bd-4cc2-8460-8108d16cc42d": {
+ "id": "73a2fe12-36bd-4cc2-8460-8108d16cc42d",
+ "message": {
+ "id": "73a2fe12-36bd-4cc2-8460-8108d16cc42d",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1697373123.970743,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ "Certainly! Here's the same logic written in the C# programming language:\n\n```csharp\nusing System;\nusing System.Collections.Generic;\n\nclass Program\n{\n static void Main()\n {\n List intervals = new List\n {\n new int[] {2, 5},\n new int[] {4, 5},\n new int[] {3, 7}\n };\n\n var intersection = FindIntersection(intervals);\n\n if (intersection != null)\n {\n Console.WriteLine($\"The intersection point is: {intersection[0]}\");\n }\n else\n {\n Console.WriteLine(\"There is no common intersection point.\");\n }\n }\n\n static int[] FindIntersection(List intervals)\n {\n intervals.Sort((x, y) => x[0].CompareTo(y[0]));\n\n int[] result = intervals[0];\n \n foreach (var interval in intervals)\n {\n if (interval[0] <= result[1])\n {\n result[0] = Math.Max(result[0], interval[0]);\n result[1] = Math.Min(result[1], interval[1]);\n }\n else\n {\n return null;\n }\n }\n\n return result;\n }\n}\n```\n\nThis C# code performs the same task as the Python code provided earlier. It finds the common intersection point among a list of intervals and outputs the result."
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": true,
+ "weight": 1.0,
+ "metadata": {
+ "finish_details": {
+ "type": "stop",
+ "stop_tokens": [100260]
+ },
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "text-davinci-002-render-sha",
+ "parent_id": "aaa21d18-8c68-43b2-8939-86fadac2b861",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "aaa21d18-8c68-43b2-8939-86fadac2b861",
+ "children": []
+ }
+ },
+ "moderation_results": [],
+ "current_node": "73a2fe12-36bd-4cc2-8460-8108d16cc42d",
+ "plugin_ids": null,
+ "conversation_id": "7c5ab593-dbab-43bd-862d-2c3c1eeebf6a",
+ "conversation_template_id": null,
+ "gizmo_id": null,
+ "is_archived": false,
+ "safe_urls": [],
+ "default_model_slug": null,
+ "id": "7c5ab593-dbab-43bd-862d-2c3c1eeebf6a"
+ }
+]
diff --git a/api/server/utils/import/__data__/chatgpt-tree.json b/api/server/utils/import/__data__/chatgpt-tree.json
new file mode 100644
index 0000000..7f01417
--- /dev/null
+++ b/api/server/utils/import/__data__/chatgpt-tree.json
@@ -0,0 +1,429 @@
+[
+ {
+ "title": "Assist user with summary",
+ "create_time": 1714585031.148505,
+ "update_time": 1714585060.879308,
+ "mapping": {
+ "d38605d2-7b2c-43de-b044-22ce472c749b": {
+ "id": "d38605d2-7b2c-43de-b044-22ce472c749b",
+ "message": {
+ "id": "d38605d2-7b2c-43de-b044-22ce472c749b",
+ "author": {
+ "role": "system",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": null,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": [""]
+ },
+ "status": "finished_successfully",
+ "end_turn": true,
+ "weight": 0,
+ "metadata": {
+ "is_visually_hidden_from_conversation": true
+ },
+ "recipient": "all"
+ },
+ "parent": "aaa1f70c-100e-46f0-999e-10c8565f047f",
+ "children": ["aaa297ba-e2da-440e-84f4-e62e7be8b003"]
+ },
+ "aaa1f70c-100e-46f0-999e-10c8565f047f": {
+ "id": "aaa1f70c-100e-46f0-999e-10c8565f047f",
+ "message": null,
+ "parent": null,
+ "children": ["d38605d2-7b2c-43de-b044-22ce472c749b"]
+ },
+ "aaa297ba-e2da-440e-84f4-e62e7be8b003": {
+ "id": "aaa297ba-e2da-440e-84f4-e62e7be8b003",
+ "message": {
+ "id": "aaa297ba-e2da-440e-84f4-e62e7be8b003",
+ "author": {
+ "role": "user",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1714585031.150442,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": ["hi there"]
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 1,
+ "metadata": {
+ "request_id": "87d189bb49d412c5-IAD",
+ "timestamp_": "absolute",
+ "message_type": null
+ },
+ "recipient": "all"
+ },
+ "parent": "d38605d2-7b2c-43de-b044-22ce472c749b",
+ "children": ["bda8a275-886d-4f59-b38c-d7037144f0d5"]
+ },
+ "bda8a275-886d-4f59-b38c-d7037144f0d5": {
+ "id": "bda8a275-886d-4f59-b38c-d7037144f0d5",
+ "message": {
+ "id": "bda8a275-886d-4f59-b38c-d7037144f0d5",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1714585031.757056,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": ["Hello! How can I assist you today?"]
+ },
+ "status": "finished_successfully",
+ "end_turn": true,
+ "weight": 1,
+ "metadata": {
+ "finish_details": {
+ "type": "stop",
+ "stop_tokens": [100260]
+ },
+ "citations": [],
+ "gizmo_id": null,
+ "message_type": null,
+ "model_slug": "text-davinci-002-render-sha",
+ "default_model_slug": "text-davinci-002-render-sha",
+ "pad": "AAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "parent_id": "aaa297ba-e2da-440e-84f4-e62e7be8b003",
+ "is_complete": true,
+ "request_id": "87d189bb49d412c5-IAD",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "aaa297ba-e2da-440e-84f4-e62e7be8b003",
+ "children": ["aaa24023-b02f-4d49-b568-5856b41750c0", "aaa236a3-cdfc-4eb1-b5c5-790c6641f880"]
+ },
+ "aaa24023-b02f-4d49-b568-5856b41750c0": {
+ "id": "aaa24023-b02f-4d49-b568-5856b41750c0",
+ "message": {
+ "id": "aaa24023-b02f-4d49-b568-5856b41750c0",
+ "author": {
+ "role": "user",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1714585034.306995,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": ["so cool bro"]
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 1,
+ "metadata": {
+ "request_id": "87d189cf3df512c5-IAD",
+ "timestamp_": "absolute",
+ "message_type": null
+ },
+ "recipient": "all"
+ },
+ "parent": "bda8a275-886d-4f59-b38c-d7037144f0d5",
+ "children": ["23afbea9-ca08-49f2-b417-e7ae58a1c97d"]
+ },
+ "23afbea9-ca08-49f2-b417-e7ae58a1c97d": {
+ "id": "23afbea9-ca08-49f2-b417-e7ae58a1c97d",
+ "message": {
+ "id": "23afbea9-ca08-49f2-b417-e7ae58a1c97d",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1714585034.755907,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": ["Thanks! What brings you here today?"]
+ },
+ "status": "finished_successfully",
+ "end_turn": true,
+ "weight": 1,
+ "metadata": {
+ "finish_details": {
+ "type": "stop",
+ "stop_tokens": [100260]
+ },
+ "citations": [],
+ "gizmo_id": null,
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "text-davinci-002-render-sha",
+ "default_model_slug": "text-davinci-002-render-sha",
+ "pad": "AAAAAAAAAAAAAAAAAAAAAAAAA",
+ "parent_id": "aaa24023-b02f-4d49-b568-5856b41750c0",
+ "request_id": "87d189cf3df512c5-IAD",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "aaa24023-b02f-4d49-b568-5856b41750c0",
+ "children": ["aaa292cc-1842-4dbf-bd79-13cf7150366a"]
+ },
+ "aaa292cc-1842-4dbf-bd79-13cf7150366a": {
+ "id": "aaa292cc-1842-4dbf-bd79-13cf7150366a",
+ "message": {
+ "id": "aaa292cc-1842-4dbf-bd79-13cf7150366a",
+ "author": {
+ "role": "user",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1714585037.56986,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": ["tell me a story"]
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 1,
+ "metadata": {
+ "request_id": "87d189e3dac712c5-IAD",
+ "timestamp_": "absolute",
+ "message_type": null
+ },
+ "recipient": "all"
+ },
+ "parent": "23afbea9-ca08-49f2-b417-e7ae58a1c97d",
+ "children": ["ada93f81-f59e-4b31-933d-1357efd68bfc"]
+ },
+ "ada93f81-f59e-4b31-933d-1357efd68bfc": {
+ "id": "ada93f81-f59e-4b31-933d-1357efd68bfc",
+ "message": {
+ "id": "ada93f81-f59e-4b31-933d-1357efd68bfc",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1714585045.606752,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ "Sure! Here's a short story for you:\n\n---\n\nOnce upon a time, in a small village nestled between rolling"
+ ]
+ },
+ "status": "in_progress",
+ "end_turn": null,
+ "weight": 1,
+ "metadata": {
+ "citations": [],
+ "gizmo_id": null,
+ "message_type": null,
+ "model_slug": "text-davinci-002-render-sha",
+ "default_model_slug": "text-davinci-002-render-sha",
+ "pad": "AAAAAAAAAAAAAAAAAA",
+ "parent_id": "aaa292cc-1842-4dbf-bd79-13cf7150366a",
+ "finish_details": {
+ "type": "interrupted"
+ },
+ "request_id": "87d189e3dac712c5-IAD",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "aaa292cc-1842-4dbf-bd79-13cf7150366a",
+ "children": []
+ },
+ "aaa236a3-cdfc-4eb1-b5c5-790c6641f880": {
+ "id": "aaa236a3-cdfc-4eb1-b5c5-790c6641f880",
+ "message": {
+ "id": "aaa236a3-cdfc-4eb1-b5c5-790c6641f880",
+ "author": {
+ "role": "user",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1714585050.906034,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": ["hi again"]
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 1,
+ "metadata": {
+ "request_id": "87d18a36cf9312c5-IAD",
+ "timestamp_": "absolute",
+ "message_type": null
+ },
+ "recipient": "all"
+ },
+ "parent": "bda8a275-886d-4f59-b38c-d7037144f0d5",
+ "children": ["db88eddf-3622-4246-8527-b6eaf0e9e8cd"]
+ },
+ "db88eddf-3622-4246-8527-b6eaf0e9e8cd": {
+ "id": "db88eddf-3622-4246-8527-b6eaf0e9e8cd",
+ "message": {
+ "id": "db88eddf-3622-4246-8527-b6eaf0e9e8cd",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1714585051.690729,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": ["Hey! Welcome back. What's on your mind?"]
+ },
+ "status": "finished_successfully",
+ "end_turn": true,
+ "weight": 1,
+ "metadata": {
+ "finish_details": {
+ "type": "stop",
+ "stop_tokens": [100260]
+ },
+ "citations": [],
+ "gizmo_id": null,
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "text-davinci-002-render-sha",
+ "default_model_slug": "text-davinci-002-render-sha",
+ "pad": "AAAAAAAAAAAAAAAAAAAAA",
+ "parent_id": "aaa236a3-cdfc-4eb1-b5c5-790c6641f880",
+ "request_id": "87d18a36cf9312c5-IAD",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "aaa236a3-cdfc-4eb1-b5c5-790c6641f880",
+ "children": ["aaa20127-b9e3-44f6-afbe-a2475838625a"]
+ },
+ "aaa20127-b9e3-44f6-afbe-a2475838625a": {
+ "id": "aaa20127-b9e3-44f6-afbe-a2475838625a",
+ "message": {
+ "id": "aaa20127-b9e3-44f6-afbe-a2475838625a",
+ "author": {
+ "role": "user",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1714585055.908847,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": ["tell me a joke"]
+ },
+ "status": "finished_successfully",
+ "end_turn": null,
+ "weight": 1,
+ "metadata": {
+ "request_id": "87d18a6e39a312c5-IAD",
+ "timestamp_": "absolute",
+ "message_type": null
+ },
+ "recipient": "all"
+ },
+ "parent": "db88eddf-3622-4246-8527-b6eaf0e9e8cd",
+ "children": ["d0d2a7df-d2fc-4df9-bf0a-1c5121e227ae", "f63b8e17-aa5c-4ca6-a1bf-d4d285e269b8"]
+ },
+ "d0d2a7df-d2fc-4df9-bf0a-1c5121e227ae": {
+ "id": "d0d2a7df-d2fc-4df9-bf0a-1c5121e227ae",
+ "message": {
+ "id": "d0d2a7df-d2fc-4df9-bf0a-1c5121e227ae",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1714585056.580956,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ "Sure, here's one for you:\n\nWhy don't scientists trust atoms?\n\nBecause they make up everything!"
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": true,
+ "weight": 1,
+ "metadata": {
+ "finish_details": {
+ "type": "stop",
+ "stop_tokens": [100260]
+ },
+ "citations": [],
+ "gizmo_id": null,
+ "message_type": null,
+ "model_slug": "text-davinci-002-render-sha",
+ "default_model_slug": "text-davinci-002-render-sha",
+ "pad": "AAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "parent_id": "aaa20127-b9e3-44f6-afbe-a2475838625a",
+ "is_complete": true,
+ "request_id": "87d18a55ca6212c5-IAD",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "aaa20127-b9e3-44f6-afbe-a2475838625a",
+ "children": []
+ },
+ "f63b8e17-aa5c-4ca6-a1bf-d4d285e269b8": {
+ "id": "f63b8e17-aa5c-4ca6-a1bf-d4d285e269b8",
+ "message": {
+ "id": "f63b8e17-aa5c-4ca6-a1bf-d4d285e269b8",
+ "author": {
+ "role": "assistant",
+ "name": null,
+ "metadata": {}
+ },
+ "create_time": 1714585060.598792,
+ "update_time": null,
+ "content": {
+ "content_type": "text",
+ "parts": [
+ "Sure, here's one for you:\n\nWhy don't scientists trust atoms?\n\nBecause they make up everything!"
+ ]
+ },
+ "status": "finished_successfully",
+ "end_turn": true,
+ "weight": 1,
+ "metadata": {
+ "finish_details": {
+ "type": "stop",
+ "stop_tokens": [100260]
+ },
+ "citations": [],
+ "gizmo_id": null,
+ "is_complete": true,
+ "message_type": null,
+ "model_slug": "text-davinci-002-render-sha",
+ "default_model_slug": "text-davinci-002-render-sha",
+ "pad": "AAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "parent_id": "aaa20127-b9e3-44f6-afbe-a2475838625a",
+ "request_id": "87d18a6e39a312c5-IAD",
+ "timestamp_": "absolute"
+ },
+ "recipient": "all"
+ },
+ "parent": "aaa20127-b9e3-44f6-afbe-a2475838625a",
+ "children": []
+ }
+ },
+ "moderation_results": [],
+ "current_node": "f63b8e17-aa5c-4ca6-a1bf-d4d285e269b8",
+ "plugin_ids": null,
+ "conversation_id": "d5dc5307-6807-41a0-8b04-4acee626eeb7",
+ "conversation_template_id": null,
+ "gizmo_id": null,
+ "is_archived": false,
+ "safe_urls": [],
+ "default_model_slug": "text-davinci-002-render-sha",
+ "id": "d5dc5307-6807-41a0-8b04-4acee626eeb7"
+ }
+]
diff --git a/api/server/utils/import/__data__/librechat-export.json b/api/server/utils/import/__data__/librechat-export.json
new file mode 100644
index 0000000..4cec7f3
--- /dev/null
+++ b/api/server/utils/import/__data__/librechat-export.json
@@ -0,0 +1,137 @@
+{
+ "conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733",
+ "endpoint": "openAI",
+ "title": "Conversation 1. Web Search",
+ "exportAt": "16:33:32 GMT+0200 (Central European Summer Time)",
+ "branches": true,
+ "recursive": true,
+ "options": {
+ "presetId": null,
+ "model": "gpt-3.5-turbo",
+ "chatGptLabel": null,
+ "promptPrefix": null,
+ "temperature": 1,
+ "top_p": 1,
+ "presence_penalty": 0,
+ "frequency_penalty": 0,
+ "resendFiles": true,
+ "imageDetail": "auto",
+ "endpoint": "openAI",
+ "title": "VW Transporter 2014 Fuel Consumption. Web Search"
+ },
+ "messages": [
+ {
+ "_id": "6615516574dc2ddcdebe40b6",
+ "messageId": "b123942f-ca1a-4b16-9e1f-ea4af5171168",
+ "__v": 0,
+ "conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733",
+ "createdAt": "2024-04-09T14:32:05.230Z",
+ "endpoint": "openAI",
+ "error": false,
+ "isCreatedByUser": true,
+ "model": null,
+ "parentMessageId": "00000000-0000-0000-0000-000000000000",
+ "sender": "user",
+ "text": "What is the fuel consumption of vw transporter with 8 people in l/km",
+ "unfinished": false,
+ "updatedAt": "2024-04-09T14:32:05.230Z",
+ "user": "65f1ad8c90523874d2d409f8",
+ "children": [
+ {
+ "_id": "6615516574dc2ddcdebe40b8",
+ "messageId": "549a4f45-cf93-4e3b-ae62-1abf02afbfc8",
+ "__v": 0,
+ "conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733",
+ "createdAt": "2024-04-09T14:32:05.242Z",
+ "endpoint": "openAI",
+ "error": false,
+ "isCreatedByUser": false,
+ "model": null,
+ "parentMessageId": "b123942f-ca1a-4b16-9e1f-ea4af5171168",
+ "sender": "GPT-3.5",
+ "text": "The fuel consumption of a Volkswagen Transporter can vary based on the specific model and driving conditions. For example, the 2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP, which is one of the recent models, doesn't have its fuel consumption data explicitly mentioned in the sources I found. However, a review of a Volkswagen Transporter model, which is equipped with a 2.0-litre four-cylinder turbo diesel engine and a seven-speed dual-clutch automatic transmission, mentions a combined cycle fuel consumption figure of 8.2 litres per 100km. It's important to note that this figure might vary, especially when the vehicle is carrying eight people, as more weight can lead to increased fuel consumption.\n\nFor more detailed information, you might need to refer to the specific model's manual or contact a Volkswagen dealer. Also, keep in mind that real-world fuel consumption can be influenced by factors such as driving style, road conditions, and maintenance of the vehicle ([2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP specs, dimensions](https://www.ultimatespecs.com/car-specs/Volkswagen/118424/2021-Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html)) ([Volkswagen Transporter Review - Drive](https://www.drive.com.au/reviews/volkswagen-transporter-review/)).",
+ "unfinished": false,
+ "updatedAt": "2024-04-09T14:32:05.242Z",
+ "user": "65f1ad8c90523874d2d409f8",
+ "children": [
+ {
+ "_id": "6615516574dc2ddcdebe40ba",
+ "messageId": "880e5357-3e0c-4218-b351-fd3fc184adef",
+ "__v": 0,
+ "conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733",
+ "createdAt": "2024-04-09T14:32:05.247Z",
+ "endpoint": "openAI",
+ "error": false,
+ "isCreatedByUser": true,
+ "model": null,
+ "parentMessageId": "549a4f45-cf93-4e3b-ae62-1abf02afbfc8",
+ "sender": "user",
+ "text": "What about 10 year old model",
+ "unfinished": false,
+ "updatedAt": "2024-04-09T14:32:05.247Z",
+ "user": "65f1ad8c90523874d2d409f8",
+ "children": [
+ {
+ "_id": "6615516574dc2ddcdebe40bc",
+ "messageId": "e9796d11-3bdf-4e25-9f0e-4802bbbb8c6d",
+ "__v": 0,
+ "conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733",
+ "createdAt": "2024-04-09T14:32:05.256Z",
+ "endpoint": "openAI",
+ "error": false,
+ "isCreatedByUser": false,
+ "model": null,
+ "parentMessageId": "880e5357-3e0c-4218-b351-fd3fc184adef",
+ "sender": "GPT-3.5",
+ "text": "The fuel consumption of a 2014 Volkswagen Transporter, specifically the TDI340 model with a 2.0 L engine, is reported as follows:\n- Combined: 8.2 L/100km\n- Highway: 6.9 L/100km\n- City: 10.2 L/100km\n\nThese figures can vary based on driving conditions and the vehicle's load. The actual consumption might be higher when carrying eight people due to the additional weight ([Volkswagen Transporter TDI340 2014 - Pricing & Specifications | carsales.com.au](https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/)).",
+ "unfinished": false,
+ "updatedAt": "2024-04-09T14:32:05.256Z",
+ "user": "65f1ad8c90523874d2d409f8",
+ "children": [
+ {
+ "_id": "6615516574dc2ddcdebe40be",
+ "messageId": "04408c06-62dc-4961-8ef5-4336b68e7a0a",
+ "__v": 0,
+ "conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733",
+ "createdAt": "2024-04-09T14:32:05.261Z",
+ "endpoint": "openAI",
+ "error": false,
+ "isCreatedByUser": true,
+ "model": null,
+ "parentMessageId": "e9796d11-3bdf-4e25-9f0e-4802bbbb8c6d",
+ "sender": "user",
+ "text": "Is it for fully loaded car?",
+ "unfinished": false,
+ "updatedAt": "2024-04-09T14:32:05.261Z",
+ "user": "65f1ad8c90523874d2d409f8",
+ "children": [
+ {
+ "_id": "6615516574dc2ddcdebe40c0",
+ "messageId": "645b925b-ef14-451f-9be3-814de55a5ae4",
+ "__v": 0,
+ "conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733",
+ "createdAt": "2024-04-09T14:32:05.266Z",
+ "endpoint": "openAI",
+ "error": false,
+ "isCreatedByUser": false,
+ "model": null,
+ "parentMessageId": "04408c06-62dc-4961-8ef5-4336b68e7a0a",
+ "sender": "GPT-3.5",
+ "text": "The fuel consumption figures for the 2014 Volkswagen Transporter TDI340, such as the combined 8.2 L/100km, are typically based on standardized tests. These tests don't necessarily reflect a fully loaded vehicle. The actual fuel consumption can vary and is likely to be higher when the vehicle is fully loaded with passengers or cargo, due to the increased weight and potential changes in driving conditions such as more frequent acceleration and deceleration ([Volkswagen Transporter TDI340 2014 - Pricing & Specifications | carsales.com.au](https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/)).",
+ "unfinished": false,
+ "updatedAt": "2024-04-09T14:32:05.266Z",
+ "user": "65f1ad8c90523874d2d409f8",
+ "children": []
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/api/server/utils/import/__data__/librechat-linear.json b/api/server/utils/import/__data__/librechat-linear.json
new file mode 100644
index 0000000..79a94d2
--- /dev/null
+++ b/api/server/utils/import/__data__/librechat-linear.json
@@ -0,0 +1,40 @@
+{
+ "conversationId": "4c9e1402-f746-479d-8a18-2dfab1bf750f",
+ "endpoint": "azureOpenAI",
+ "title": "Adventure Across Time and Space",
+ "exportAt": "20:33:31 GMT-0400 (Eastern Daylight Time)",
+ "branches": false,
+ "recursive": false,
+ "messages": [
+ {
+ "messageId": "ed3fdaaa-e174-4d70-89a9-b06e70939723",
+ "conversationId": "4c9e1402-f746-479d-8a18-2dfab1bf750f",
+ "createdAt": "2024-05-29T00:33:03.661Z",
+ "endpoint": "azureOpenAI",
+ "error": false,
+ "isCreatedByUser": true,
+ "isEdited": false,
+ "model": null,
+ "parentMessageId": null,
+ "sender": "user",
+ "text": "tell me a long story",
+ "unfinished": false,
+ "updatedAt": "2024-05-29T00:33:03.661Z"
+ },
+ {
+ "messageId": "113b8336-4b19-4f91-8eed-741b3dc2940e",
+ "conversationId": "4c9e1402-f746-479d-8a18-2dfab1bf750f",
+ "createdAt": "2024-05-29T00:33:03.661Z",
+ "endpoint": "azureOpenAI",
+ "error": false,
+ "isCreatedByUser": false,
+ "isEdited": false,
+ "model": "gpt-4o",
+ "parentMessageId": "ed3fdaaa-e174-4d70-89a9-b06e70939723",
+ "sender": "GPT-4",
+ "text": "Of course! Here’s a detailed and engaging story:\n\n---\n\n### The Legend of Eldoria\n\nNestled between towering mountains and dense, ancient forests was the enigmatic kingdom of Eldoria. This realm, clo aked in perpetual twilight, was the stuff of legends. It was said that the land was blessed by the gods and guarded by mythical creatures. Eldoria was a place where magic and realism intertwined seamlessly, creating a land of beauty, wonder, and peril.\n\nIn the heart of this kingdom lay the grand city of Lumina, known",
+ "unfinished": false,
+ "updatedAt": "2024-05-29T00:33:03.661Z"
+ }
+ ]
+}
diff --git a/api/server/utils/import/__data__/librechat-opts-nonr-branches.json b/api/server/utils/import/__data__/librechat-opts-nonr-branches.json
new file mode 100644
index 0000000..eb02a7e
--- /dev/null
+++ b/api/server/utils/import/__data__/librechat-opts-nonr-branches.json
@@ -0,0 +1,99 @@
+{
+ "conversationId": "27b593be-9500-479c-94cb-050cab8f5033",
+ "endpoint": "azureOpenAI",
+ "title": "Original",
+ "exportAt": "08:12:16 GMT-0400 (Eastern Daylight Time)",
+ "branches": true,
+ "recursive": false,
+ "options": {
+ "presetId": null,
+ "model": "gpt-4o",
+ "chatGptLabel": null,
+ "promptPrefix": null,
+ "temperature": 1,
+ "top_p": 1,
+ "presence_penalty": 0,
+ "frequency_penalty": 0,
+ "resendFiles": true,
+ "imageDetail": "auto",
+ "endpoint": "azureOpenAI",
+ "title": "Original"
+ },
+ "messages": [
+ {
+ "messageId": "115a6247-8fb0-4937-a536-12956669098d",
+ "conversationId": "27b593be-9500-479c-94cb-050cab8f5033",
+ "createdAt": "2024-05-28T18:08:55.014Z",
+ "endpoint": "azureOpenAI",
+ "error": false,
+ "isCreatedByUser": true,
+ "model": null,
+ "parentMessageId": "00000000-0000-0000-0000-000000000000",
+ "sender": "User",
+ "text": "tell me a long story",
+ "tokenCount": 9,
+ "unfinished": false,
+ "updatedAt": "2024-05-28T18:09:27.193Z"
+ },
+ {
+ "messageId": "069b9c22-7649-45a9-b90b-fc050533ea21",
+ "conversationId": "27b593be-9500-479c-94cb-050cab8f5033",
+ "createdAt": "2024-05-28T18:08:55.390Z",
+ "error": false,
+ "isCreatedByUser": false,
+ "model": "gpt-4o",
+ "parentMessageId": "115a6247-8fb0-4937-a536-12956669098d",
+ "sender": "GPT-4",
+ "text": "Of course! Settle in for a tale of adventure across time and space.\n\n---\n\nOnce upon a time in the small, sleepy village of Eldoria, there was a young woman named Elara who longed for adventure. Eldoria was a place of routine and simplicity, nestled between rolling hills and dense forests, but Elara always felt that there was more to the world than the boundaries",
+ "unfinished": false,
+ "updatedAt": "2024-05-28T18:08:58.669Z",
+ "endpoint": "azureOpenAI",
+ "tokenCount": 78,
+ "finish_reason": "incomplete"
+ },
+ {
+ "messageId": "303e4c2c-f03e-4e0a-8551-c96ec73be5fe",
+ "conversationId": "27b593be-9500-479c-94cb-050cab8f5033",
+ "createdAt": "2024-05-28T18:09:27.444Z",
+ "error": false,
+ "isCreatedByUser": false,
+ "model": "gpt-4o",
+ "parentMessageId": "115a6247-8fb0-4937-a536-12956669098d",
+ "sender": "GPT-4",
+ "text": "Sure, I can craft a long story for you. Here it goes:\n\n### The Chronicles of Elenor: The Luminary of Anduril\n\nIn an age long forgotten by men, in a world kissed by the glow of dual suns, the Kingdom of Anduril flourished. Verdant valleys graced its land, majestic mountains shielded",
+ "unfinished": true,
+ "updatedAt": "2024-05-28T18:09:30.448Z"
+ },
+ {
+ "messageId": "599e1908-8c52-4a73-ba6b-f6dffbd79ba0",
+ "conversationId": "27b593be-9500-479c-94cb-050cab8f5033",
+ "createdAt": "2024-05-28T18:14:07.988Z",
+ "endpoint": "azureOpenAI",
+ "error": false,
+ "isCreatedByUser": true,
+ "model": null,
+ "parentMessageId": "00000000-0000-0000-0000-000000000000",
+ "sender": "User",
+ "text": "tell me a long long story",
+ "tokenCount": 9,
+ "unfinished": false,
+ "updatedAt": "2024-05-28T18:14:07.988Z"
+ },
+ {
+ "messageId": "de9a4e7c-020d-4856-a5a6-ce6794efef99",
+ "conversationId": "27b593be-9500-479c-94cb-050cab8f5033",
+ "createdAt": "2024-05-28T18:14:08.403Z",
+ "error": false,
+ "isCreatedByUser": false,
+ "model": "gpt-4o",
+ "parentMessageId": "599e1908-8c52-4a73-ba6b-f6dffbd79ba0",
+ "sender": "GPT-4",
+ "text": "Of course! Here’s a detailed and engaging story:\n\n---\n\n### The Legend of Eldoria\n\nNestled between towering mountains and dense, ancient forests was the enigmatic kingdom of Eldoria. This realm, clo aked in perpetual twilight, was the stuff of legends. It was said that the land was blessed by the gods and guarded by mythical creatures. Eldoria was a place where magic and realism intertwined seamlessly, creating a land of beauty, wonder, and peril.\n\nIn the heart of this kingdom lay the grand city of Lumina, known",
+ "unfinished": false,
+ "updatedAt": "2024-05-28T18:14:20.349Z",
+ "endpoint": "azureOpenAI",
+ "finish_reason": "incomplete",
+ "tokenCount": 110
+ }
+ ]
+}
diff --git a/api/server/utils/import/__data__/librechat-tree.json b/api/server/utils/import/__data__/librechat-tree.json
new file mode 100644
index 0000000..8e94cb1
--- /dev/null
+++ b/api/server/utils/import/__data__/librechat-tree.json
@@ -0,0 +1,151 @@
+{
+ "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
+ "endpoint": "openAI",
+ "title": "Troubleshooting Python Virtual Environment Activation Issue",
+ "exportAt": "13:18:04 GMT-0400 (Eastern Daylight Time)",
+ "branches": true,
+ "recursive": true,
+ "options": {
+ "presetId": null,
+ "model": "gpt-4-turbo",
+ "chatGptLabel": "Software Engineer",
+ "promptPrefix": "You are an expert software engineer.",
+ "temperature": 0.8,
+ "top_p": 1,
+ "presence_penalty": 0,
+ "frequency_penalty": 0,
+ "resendFiles": true,
+ "imageDetail": "auto",
+ "endpoint": "openAI",
+ "title": "Troubleshooting Python Virtual Environment Activation Issue"
+ },
+ "messages": [
+ {
+ "_id": "66326f3f04bed94b7f5be68d",
+ "messageId": "9501f99d-9bbb-40cb-bbb2-16d79aeceb72",
+ "__v": 0,
+ "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
+ "createdAt": "2024-05-01T16:35:11.530Z",
+ "endpoint": "openAI",
+ "error": false,
+ "isCreatedByUser": true,
+ "model": null,
+ "parentMessageId": "00000000-0000-0000-0000-000000000000",
+ "sender": "User",
+ "text": "How do I use source venv/bin/activate?\nbash: venv/bin/activate: No such file or directory",
+ "tokenCount": 59,
+ "unfinished": false,
+ "updatedAt": "2024-05-01T16:35:39.114Z",
+ "user": "64d4f005d73ef522e1f48818",
+ "children": [
+ {
+ "_id": "66326f4004bed94b7f5be699",
+ "messageId": "4c395510-fdfd-4556-b034-f9440f025e91",
+ "__v": 0,
+ "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
+ "createdAt": "2024-05-01T16:35:12.604Z",
+ "error": false,
+ "isCreatedByUser": false,
+ "model": "gpt-4-turbo",
+ "parentMessageId": "9501f99d-9bbb-40cb-bbb2-16d79aeceb72",
+ "sender": "Software Engineer",
+ "text": "It looks like you're trying to activate a Python virtual environment but encountering an issue because the system cannot find the `activate` script in the `venv/bin/` directory. This typically happens if the virtual environment has not been created yet or if the path specified is incorrect. Here are a few steps to troubleshoot and resolve this issue:\n\n1. **Check if the Virtual Environment Exists**:\n Ensure that the virtual environment directory (`venv`) exists in your current directory (`~/denis-libre`). You can check this by listing the directories:\n\n ```bash\n ls -l\n ```\n\n Look for `venv` in the output. If it's not there, you'll need to create it.\n\n2. **Create the Virtual Environment**:\n If the virtual environment does not exist, you can create one by using the following command:\n\n ```bash\n python3 -m venv venv\n ```\n\n This command creates a new virtual environment named `venv` in your current directory.\n\n3. **Activate the Virtual Environment**:\n Once you have confirmed that the `venv` directory exists, try activating the virtual environment again:\n\n ```bash\n source venv/bin/activate\n ```\n\n If you still receive an error, double-check the path. If you are on a Windows system using Git Bash or similar, the path might be `venv\\Scripts\\activate` instead.\n\n4. **Check for Environment Specific Issues**:\n - If you are using a different shell (like `csh` or `fish`), the activation script could be different (e.g., `activate.csh` or `activate.fish`).\n - Ensure there’s no typo in your command or path misconfiguration.\n\n5. **Permissions Issue**:\n Make sure you have the necessary permissions to access the `venv` directory and its contents. If not, you might need to modify the permissions:\n\n ```bash\n chmod -R 755 venv\n ```\n\n6. **Using the Correct Python Version**:\n Confirm that you are using the correct version of Python for which the virtual environment is supposed to be set up. Sometimes, using a different version might cause issues.\n\nBy following these steps, you should be able to resolve the issue with activating your Python virtual environment. If the problem persists, there might be a more specific issue with your Python installation or system configuration.",
+ "unfinished": false,
+ "updatedAt": "2024-05-01T16:35:39.104Z",
+ "user": "64d4f005d73ef522e1f48818",
+ "endpoint": "openAI",
+ "finish_reason": "stop",
+ "tokenCount": 504,
+ "children": [
+ {
+ "messageId": "6e4c95ca-fcb1-4869-adc3-b4bcec83a270",
+ "parentMessageId": "4c395510-fdfd-4556-b034-f9440f025e91",
+ "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
+ "sender": "User",
+ "text": "thanks",
+ "isCreatedByUser": true,
+ "tokenCount": 5,
+ "children": [
+ {
+ "messageId": "5da2cdce-87f7-46b4-87c5-e8224e5d5125",
+ "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
+ "parentMessageId": "6e4c95ca-fcb1-4869-adc3-b4bcec83a270",
+ "isCreatedByUser": false,
+ "model": "gpt-4-turbo",
+ "sender": "Software Engineer",
+ "text": "You're welcome! If you have any more questions or need further assistance, feel free to ask. Good luck with your project!",
+ "promptTokens": 586,
+ "endpoint": "openAI",
+ "finish_reason": "stop",
+ "children": []
+ },
+ {
+ "messageId": "64ed7568-bdcf-42a9-b34a-73386d4d99dc",
+ "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
+ "parentMessageId": "6e4c95ca-fcb1-4869-adc3-b4bcec83a270",
+ "isCreatedByUser": false,
+ "model": "gpt-4-turbo",
+ "sender": "Software Engineer",
+ "text": "You're welcome! If you have any more questions or need further assistance, feel free to ask. Happy coding!",
+ "promptTokens": 586,
+ "endpoint": "openAI",
+ "finish_reason": "stop",
+ "children": [
+ {
+ "messageId": "f82cca6f-7d2b-4fc1-9d5a-636acc137368",
+ "parentMessageId": "64ed7568-bdcf-42a9-b34a-73386d4d99dc",
+ "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
+ "sender": "User",
+ "text": "does coding make you happy",
+ "isCreatedByUser": true,
+ "tokenCount": 9,
+ "children": [
+ {
+ "messageId": "ad7b6ec6-aaa4-434c-9188-def6aadbb2bf",
+ "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
+ "parentMessageId": "f82cca6f-7d2b-4fc1-9d5a-636acc137368",
+ "isCreatedByUser": false,
+ "model": "gpt-4-turbo",
+ "sender": "Software Engineer",
+ "text": "As a software engineer, coding can indeed be a source of happiness and satisfaction for many professionals in the field. Here’s why coding might make someone happy:\n\n1. **Problem Solving**: Coding offers a constant stream of challenges that require problem-solving skills. Solving these problems can be immensely gratifying and can provide a sense of accomplishment.\n\n2. **Creativity**: Writing software is often a creative process, similar to composing music or painting. Developers can express themselves through their code, which can be a fulfilling experience.\n\n3. **Continuous Learning**: The tech field is always evolving, offering endless opportunities to learn new languages, tools, and techniques. For many, this continuous learning keeps the job exciting and engaging.\n\n4. **Impact**: Software engineers often work on applications that impact the lives of millions of people. Knowing that your work is helping others can be a powerful source of happiness.\n\n5. **Community**: The coding community is known for its collaboration and sharing of knowledge through platforms like GitHub, Stack Overflow, and various forums. Being a part of this community can provide a sense of belonging and support.\n\n6. **Career Opportunities**: Coding skills are in high demand, which can lead to job security, good salaries, and the opportunity to work in a variety of industries. This can contribute to overall life satisfaction and stability.\n\nHowever, like any job, coding can also be stressful at times. Deadlines, bugs, difficult problems, and other challenges can cause frustration. The key is to find a balance, continue learning, and choose projects that align with your interests and values. If these elements are in place, coding can indeed be a very happy career choice.",
+ "promptTokens": 622,
+ "endpoint": "openAI",
+ "finish_reason": "stop",
+ "children": []
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "messageId": "d3e12088-962e-42ce-900d-edd6816acae5",
+ "parentMessageId": "4c395510-fdfd-4556-b034-f9440f025e91",
+ "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
+ "sender": "User",
+ "text": "lol",
+ "isCreatedByUser": true,
+ "tokenCount": 5,
+ "children": [
+ {
+ "messageId": "1f062c99-ff0a-4cf4-a1cf-7150261a24e2",
+ "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
+ "parentMessageId": "d3e12088-962e-42ce-900d-edd6816acae5",
+ "isCreatedByUser": false,
+ "model": "gpt-4-turbo",
+ "sender": "Software Engineer",
+ "text": "It looks like you might have been amused or found something funny about the situation! If you have any specific questions or need further help with your virtual environment setup or anything else related to software engineering, feel free to ask!",
+ "promptTokens": 586,
+ "endpoint": "openAI",
+ "finish_reason": "stop",
+ "children": []
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/api/server/utils/import/defaults.js b/api/server/utils/import/defaults.js
new file mode 100644
index 0000000..6d6f806
--- /dev/null
+++ b/api/server/utils/import/defaults.js
@@ -0,0 +1,142 @@
+const { logger, getTenantId } = require('@librechat/data-schemas');
+const { EModelEndpoint, openAISettings, anthropicSettings } = require('librechat-data-provider');
+const { getModelsConfig } = require('~/server/controllers/ModelController');
+
+/**
+ * Last-resort hardcoded defaults used only when the runtime models config is
+ * unavailable or returns no models for the endpoint.
+ */
+const FALLBACK_MODEL_BY_ENDPOINT = {
+ [EModelEndpoint.openAI]: openAISettings.model.default,
+ [EModelEndpoint.anthropic]: anthropicSettings.model.default,
+};
+
+/**
+ * Picks the first available model for an endpoint from a runtime models config.
+ *
+ * @param {string} endpoint - The endpoint key (e.g. EModelEndpoint.anthropic).
+ * @param {TModelsConfig} [modelsConfig] - Map of endpoint -> available model list.
+ * @returns {string | undefined} The first model for the endpoint, or undefined.
+ */
+function pickFirstConfiguredModel(endpoint, modelsConfig) {
+ const models = modelsConfig?.[endpoint];
+ if (!Array.isArray(models)) {
+ return undefined;
+ }
+ for (const model of models) {
+ if (typeof model === 'string' && model.length > 0) {
+ return model;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Resolves the default model that imported conversations should be saved with
+ * for a given endpoint. Prefers the first model exposed by the runtime models
+ * config (admin-configured / provider-discovered), and only falls back to the
+ * hardcoded per-endpoint default if the runtime config is empty or fails.
+ *
+ * @param {object} args
+ * @param {string} args.endpoint - The endpoint key the import is targeting.
+ * @param {string} args.requestUserId - The id of the importing user.
+ * @param {string} [args.userRole] - The role of the importing user.
+ * @returns {Promise} The default model name to persist on the conversation.
+ */
+async function resolveImportDefaultModel({ endpoint, requestUserId, userRole }) {
+ try {
+ const modelsConfig = await getModelsConfig({
+ user: { id: requestUserId, role: userRole, tenantId: getTenantId() },
+ });
+ const configured = pickFirstConfiguredModel(endpoint, modelsConfig);
+ if (configured) {
+ return configured;
+ }
+ } catch (error) {
+ logger.warn(
+ `[import] Failed to resolve default model from modelsConfig for ${endpoint}: ${error.message}`,
+ );
+ }
+ return FALLBACK_MODEL_BY_ENDPOINT[endpoint] ?? openAISettings.model.default;
+}
+
+/**
+ * Preferred endpoint order for conversations cloned without a known source
+ * endpoint. OpenAI is first so deployments that expose it keep prior behavior;
+ * any other configured endpoint is still selected when these are unavailable.
+ */
+const DEFAULT_ENDPOINT_PREFERENCE = [
+ EModelEndpoint.openAI,
+ EModelEndpoint.anthropic,
+ EModelEndpoint.google,
+ EModelEndpoint.azureOpenAI,
+ EModelEndpoint.bedrock,
+];
+
+/**
+ * Endpoints excluded as fork targets because they are stateful: each
+ * conversation needs an assistant_id and thread_id that a cloned conversation
+ * never creates, so the assistants chat controller rejects the first follow-up
+ * ("Missing thread_id for existing conversation"). A fork must land on a
+ * stateless chat endpoint. These can still surface in the runtime models config
+ * (e.g. a deployment exposing only assistant models), so filter them out.
+ */
+const EXCLUDED_FORK_ENDPOINTS = new Set([
+ EModelEndpoint.assistants,
+ EModelEndpoint.azureAssistants,
+]);
+
+/**
+ * Resolves an endpoint and model the requesting user can actually use, for
+ * conversations cloned without a known source endpoint (shared forks, whose
+ * original endpoint is stripped from the sanitized payload). Picks the first
+ * preferred endpoint exposing models, then any other configured endpoint
+ * (excluding stateful assistant endpoints, which a fork cannot resume), so a
+ * deployment that doesn't expose OpenAI doesn't produce a conversation whose
+ * first message is rejected by model validation. Falls back to OpenAI defaults
+ * only when the runtime models config is empty or unavailable.
+ *
+ * @param {object} args
+ * @param {string} args.requestUserId - The id of the requesting user.
+ * @param {string} [args.userRole] - The role of the requesting user.
+ * @returns {Promise<{ endpoint: string, model: string }>} A usable endpoint and model.
+ */
+async function resolveImportDefaultEndpoint({ requestUserId, userRole }) {
+ try {
+ const modelsConfig = await getModelsConfig({
+ user: { id: requestUserId, role: userRole, tenantId: getTenantId() },
+ });
+ if (modelsConfig) {
+ const orderedEndpoints = [
+ ...DEFAULT_ENDPOINT_PREFERENCE,
+ ...Object.keys(modelsConfig).filter(
+ (endpoint) => !DEFAULT_ENDPOINT_PREFERENCE.includes(endpoint),
+ ),
+ ];
+ for (const endpoint of orderedEndpoints) {
+ if (EXCLUDED_FORK_ENDPOINTS.has(endpoint)) {
+ continue;
+ }
+ const model = pickFirstConfiguredModel(endpoint, modelsConfig);
+ if (model) {
+ return { endpoint, model };
+ }
+ }
+ }
+ } catch (error) {
+ logger.warn(
+ `[import] Failed to resolve a default endpoint from modelsConfig: ${error.message}`,
+ );
+ }
+ return {
+ endpoint: EModelEndpoint.openAI,
+ model: FALLBACK_MODEL_BY_ENDPOINT[EModelEndpoint.openAI] ?? openAISettings.model.default,
+ };
+}
+
+module.exports = {
+ FALLBACK_MODEL_BY_ENDPOINT,
+ pickFirstConfiguredModel,
+ resolveImportDefaultModel,
+ resolveImportDefaultEndpoint,
+};
diff --git a/api/server/utils/import/defaults.spec.js b/api/server/utils/import/defaults.spec.js
new file mode 100644
index 0000000..6815efb
--- /dev/null
+++ b/api/server/utils/import/defaults.spec.js
@@ -0,0 +1,205 @@
+const { EModelEndpoint, openAISettings, anthropicSettings } = require('librechat-data-provider');
+
+const mockGetModelsConfig = jest.fn();
+
+jest.mock('~/server/controllers/ModelController', () => ({
+ getModelsConfig: (...args) => mockGetModelsConfig(...args),
+}));
+
+jest.mock('@librechat/data-schemas', () => {
+ const actual = jest.requireActual('@librechat/data-schemas');
+ return {
+ ...actual,
+ getTenantId: () => 'test-tenant',
+ logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() },
+ };
+});
+
+const {
+ pickFirstConfiguredModel,
+ resolveImportDefaultModel,
+ resolveImportDefaultEndpoint,
+ FALLBACK_MODEL_BY_ENDPOINT,
+} = require('./defaults');
+
+afterEach(() => {
+ jest.clearAllMocks();
+});
+
+describe('pickFirstConfiguredModel', () => {
+ it('returns the first non-empty string for the endpoint', () => {
+ const modelsConfig = {
+ [EModelEndpoint.anthropic]: ['claude-opus-4-7', 'claude-3-5-sonnet-latest'],
+ };
+ expect(pickFirstConfiguredModel(EModelEndpoint.anthropic, modelsConfig)).toBe(
+ 'claude-opus-4-7',
+ );
+ });
+
+ it('skips empty strings', () => {
+ const modelsConfig = {
+ [EModelEndpoint.openAI]: ['', 'gpt-4o'],
+ };
+ expect(pickFirstConfiguredModel(EModelEndpoint.openAI, modelsConfig)).toBe('gpt-4o');
+ });
+
+ it('returns undefined when modelsConfig is missing', () => {
+ expect(pickFirstConfiguredModel(EModelEndpoint.anthropic, undefined)).toBeUndefined();
+ });
+
+ it('returns undefined when the endpoint has no models', () => {
+ expect(pickFirstConfiguredModel(EModelEndpoint.anthropic, {})).toBeUndefined();
+ expect(
+ pickFirstConfiguredModel(EModelEndpoint.anthropic, { [EModelEndpoint.anthropic]: [] }),
+ ).toBeUndefined();
+ });
+
+ it('returns undefined when the endpoint value is not an array', () => {
+ expect(
+ pickFirstConfiguredModel(EModelEndpoint.anthropic, {
+ [EModelEndpoint.anthropic]: 'claude-opus-4-7',
+ }),
+ ).toBeUndefined();
+ });
+});
+
+describe('resolveImportDefaultModel', () => {
+ it('returns the first model from modelsConfig when present', async () => {
+ mockGetModelsConfig.mockResolvedValueOnce({
+ [EModelEndpoint.anthropic]: ['claude-opus-4-7'],
+ });
+
+ const result = await resolveImportDefaultModel({
+ endpoint: EModelEndpoint.anthropic,
+ requestUserId: 'user-1',
+ userRole: 'USER',
+ });
+
+ expect(result).toBe('claude-opus-4-7');
+ expect(mockGetModelsConfig).toHaveBeenCalledWith({
+ user: { id: 'user-1', role: 'USER', tenantId: 'test-tenant' },
+ });
+ });
+
+ it('falls back to the per-endpoint default when modelsConfig has no models for the endpoint', async () => {
+ mockGetModelsConfig.mockResolvedValueOnce({});
+
+ const result = await resolveImportDefaultModel({
+ endpoint: EModelEndpoint.anthropic,
+ requestUserId: 'user-1',
+ });
+
+ expect(result).toBe(anthropicSettings.model.default);
+ });
+
+ it('falls back to the openAI default for unknown endpoints with no modelsConfig entry', async () => {
+ mockGetModelsConfig.mockResolvedValueOnce({});
+
+ const result = await resolveImportDefaultModel({
+ endpoint: 'some-custom-endpoint',
+ requestUserId: 'user-1',
+ });
+
+ expect(result).toBe(openAISettings.model.default);
+ });
+
+ it('falls back to the per-endpoint default when getModelsConfig rejects', async () => {
+ mockGetModelsConfig.mockRejectedValueOnce(new Error('boom'));
+
+ const result = await resolveImportDefaultModel({
+ endpoint: EModelEndpoint.anthropic,
+ requestUserId: 'user-1',
+ });
+
+ expect(result).toBe(anthropicSettings.model.default);
+ });
+
+ it('exposes hardcoded fallbacks for openAI and anthropic', () => {
+ expect(FALLBACK_MODEL_BY_ENDPOINT[EModelEndpoint.openAI]).toBe(openAISettings.model.default);
+ expect(FALLBACK_MODEL_BY_ENDPOINT[EModelEndpoint.anthropic]).toBe(
+ anthropicSettings.model.default,
+ );
+ });
+});
+
+describe('resolveImportDefaultEndpoint', () => {
+ it('prefers OpenAI when it exposes models', async () => {
+ mockGetModelsConfig.mockResolvedValueOnce({
+ [EModelEndpoint.openAI]: ['gpt-4o'],
+ [EModelEndpoint.anthropic]: ['claude-opus-4-7'],
+ });
+
+ const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
+
+ expect(result).toEqual({ endpoint: EModelEndpoint.openAI, model: 'gpt-4o' });
+ });
+
+ it('falls back to another configured endpoint when OpenAI is unavailable', async () => {
+ mockGetModelsConfig.mockResolvedValueOnce({
+ [EModelEndpoint.openAI]: [],
+ [EModelEndpoint.anthropic]: ['claude-opus-4-7'],
+ });
+
+ const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
+
+ expect(result).toEqual({ endpoint: EModelEndpoint.anthropic, model: 'claude-opus-4-7' });
+ });
+
+ it('selects a custom endpoint when no preferred endpoint has models', async () => {
+ mockGetModelsConfig.mockResolvedValueOnce({
+ 'my-custom': ['custom-model-1'],
+ });
+
+ const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
+
+ expect(result).toEqual({ endpoint: 'my-custom', model: 'custom-model-1' });
+ });
+
+ it('skips stateful assistant endpoints and selects a stateless one', async () => {
+ mockGetModelsConfig.mockResolvedValueOnce({
+ [EModelEndpoint.assistants]: ['gpt-4o'],
+ [EModelEndpoint.azureAssistants]: ['gpt-4o'],
+ 'my-custom': ['custom-model-1'],
+ });
+
+ const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
+
+ expect(result).toEqual({ endpoint: 'my-custom', model: 'custom-model-1' });
+ });
+
+ it('falls back to OpenAI defaults when only assistant endpoints expose models', async () => {
+ mockGetModelsConfig.mockResolvedValueOnce({
+ [EModelEndpoint.assistants]: ['gpt-4o'],
+ [EModelEndpoint.azureAssistants]: ['gpt-4o'],
+ });
+
+ const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
+
+ expect(result).toEqual({
+ endpoint: EModelEndpoint.openAI,
+ model: openAISettings.model.default,
+ });
+ });
+
+ it('falls back to OpenAI defaults when the models config is empty', async () => {
+ mockGetModelsConfig.mockResolvedValueOnce({});
+
+ const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
+
+ expect(result).toEqual({
+ endpoint: EModelEndpoint.openAI,
+ model: openAISettings.model.default,
+ });
+ });
+
+ it('falls back to OpenAI defaults when getModelsConfig rejects', async () => {
+ mockGetModelsConfig.mockRejectedValueOnce(new Error('boom'));
+
+ const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
+
+ expect(result).toEqual({
+ endpoint: EModelEndpoint.openAI,
+ model: openAISettings.model.default,
+ });
+ });
+});
diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js
new file mode 100644
index 0000000..b0ba7c0
--- /dev/null
+++ b/api/server/utils/import/fork.js
@@ -0,0 +1,553 @@
+const { v4: uuidv4 } = require('uuid');
+const { logger, tenantStorage } = require('@librechat/data-schemas');
+const { EModelEndpoint, Constants, ForkOptions } = require('librechat-data-provider');
+const { getConvo, getMessages, getSharedMessages } = require('~/models');
+const { createImportBatchBuilder } = require('./importBatchBuilder');
+const { getAppConfig } = require('~/server/services/Config');
+const { resolveImportDefaultEndpoint } = require('./defaults');
+const BaseClient = require('~/app/clients/BaseClient');
+
+/**
+ * Helper function to clone messages with proper parent-child relationships and timestamps
+ * @param {TMessage[]} messagesToClone - Original messages to clone
+ * @param {ImportBatchBuilder} importBatchBuilder - Instance of ImportBatchBuilder
+ * @returns {Map} Map of original messageIds to new messageIds
+ */
+function cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder) {
+ const idMapping = new Map();
+
+ // First pass: create ID mapping and sort messages by parentMessageId
+ const sortedMessages = [...messagesToClone].sort((a, b) => {
+ if (a.parentMessageId === Constants.NO_PARENT) {
+ return -1;
+ }
+ if (b.parentMessageId === Constants.NO_PARENT) {
+ return 1;
+ }
+ return 0;
+ });
+
+ // Helper function to ensure date object
+ const ensureDate = (dateValue) => {
+ if (!dateValue) {
+ return new Date();
+ }
+ return dateValue instanceof Date ? dateValue : new Date(dateValue);
+ };
+
+ // Second pass: clone messages while maintaining proper timestamps
+ for (const message of sortedMessages) {
+ const newMessageId = uuidv4();
+ idMapping.set(message.messageId, newMessageId);
+
+ const parentId =
+ message.parentMessageId && message.parentMessageId !== Constants.NO_PARENT
+ ? idMapping.get(message.parentMessageId)
+ : Constants.NO_PARENT;
+
+ // If this message has a parent, ensure its timestamp is after the parent's
+ let createdAt = ensureDate(message.createdAt);
+ if (parentId !== Constants.NO_PARENT) {
+ const parentMessage = importBatchBuilder.messages.find((msg) => msg.messageId === parentId);
+ if (parentMessage) {
+ const parentDate = ensureDate(parentMessage.createdAt);
+ if (createdAt <= parentDate) {
+ createdAt = new Date(parentDate.getTime() + 1);
+ }
+ }
+ }
+
+ const clonedMessage = {
+ ...message,
+ messageId: newMessageId,
+ parentMessageId: parentId,
+ createdAt,
+ };
+
+ importBatchBuilder.saveMessage(clonedMessage);
+ }
+
+ return idMapping;
+}
+
+/**
+ *
+ * @param {object} params - The parameters for the importer.
+ * @param {string} params.originalConvoId - The ID of the conversation to fork.
+ * @param {string} params.targetMessageId - The ID of the message to fork from.
+ * @param {string} params.requestUserId - The ID of the user making the request.
+ * @param {string} [params.newTitle] - Optional new title for the forked conversation uses old title if not provided
+ * @param {string} [params.option=''] - Optional flag for fork option
+ * @param {boolean} [params.records=false] - Optional flag for returning actual database records or resulting conversation and messages.
+ * @param {boolean} [params.splitAtTarget=false] - Optional flag for splitting the messages at the target message level.
+ * @param {string} [params.latestMessageId] - latestMessageId - Required if splitAtTarget is true.
+ * @param {(userId: string) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance.
+ * @returns {Promise} The response after forking the conversation.
+ */
+async function forkConversation({
+ originalConvoId,
+ targetMessageId: targetId,
+ requestUserId,
+ newTitle,
+ option = ForkOptions.TARGET_LEVEL,
+ records = false,
+ splitAtTarget = false,
+ latestMessageId,
+ builderFactory = createImportBatchBuilder,
+}) {
+ try {
+ const originalConvo = await getConvo(requestUserId, originalConvoId);
+ let originalMessages = await getMessages({
+ user: requestUserId,
+ conversationId: originalConvoId,
+ });
+
+ let targetMessageId = targetId;
+ if (splitAtTarget && !latestMessageId) {
+ throw new Error('Latest `messageId` is required for forking from target message.');
+ } else if (splitAtTarget) {
+ originalMessages = splitAtTargetLevel(originalMessages, targetId);
+ targetMessageId = latestMessageId;
+ }
+
+ const importBatchBuilder = builderFactory(requestUserId);
+ importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI);
+
+ let messagesToClone = [];
+
+ if (option === ForkOptions.DIRECT_PATH) {
+ // Direct path only
+ messagesToClone = BaseClient.getMessagesForConversation({
+ messages: originalMessages,
+ parentMessageId: targetMessageId,
+ });
+ } else if (option === ForkOptions.INCLUDE_BRANCHES) {
+ // Direct path and siblings
+ messagesToClone = getAllMessagesUpToParent(originalMessages, targetMessageId);
+ } else if (option === ForkOptions.TARGET_LEVEL || !option) {
+ // Direct path, siblings, and all descendants
+ messagesToClone = getMessagesUpToTargetLevel(originalMessages, targetMessageId);
+ }
+
+ cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
+
+ const result = importBatchBuilder.finishConversation(
+ newTitle || originalConvo.title,
+ new Date(),
+ originalConvo,
+ );
+ await importBatchBuilder.saveBatch();
+ logger.debug(
+ `user: ${requestUserId} | New conversation "${
+ newTitle || originalConvo.title
+ }" forked from conversation ID ${originalConvoId}`,
+ );
+
+ if (!records) {
+ return result;
+ }
+
+ const conversation = await getConvo(requestUserId, result.conversation.conversationId);
+ const messages = await getMessages({
+ user: requestUserId,
+ conversationId: conversation.conversationId,
+ });
+
+ return {
+ conversation,
+ messages,
+ };
+ } catch (error) {
+ logger.error(
+ `user: ${requestUserId} | Error forking conversation from original ID ${originalConvoId}`,
+ error,
+ );
+ throw error;
+ }
+}
+
+/**
+ * Retrieves all messages up to the root from the target message.
+ * @param {TMessage[]} messages - The list of messages to search.
+ * @param {string} targetMessageId - The ID of the target message.
+ * @returns {TMessage[]} The list of messages up to the root from the target message.
+ */
+function getAllMessagesUpToParent(messages, targetMessageId) {
+ const targetMessage = messages.find((msg) => msg.messageId === targetMessageId);
+ if (!targetMessage) {
+ return [];
+ }
+
+ const pathToRoot = new Set();
+ const visited = new Set();
+ let current = targetMessage;
+
+ while (current) {
+ if (visited.has(current.messageId)) {
+ break;
+ }
+
+ visited.add(current.messageId);
+ pathToRoot.add(current.messageId);
+
+ const currentParentId = current.parentMessageId ?? Constants.NO_PARENT;
+ if (currentParentId === Constants.NO_PARENT) {
+ break;
+ }
+
+ current = messages.find((msg) => msg.messageId === currentParentId);
+ }
+
+ // Include all messages that are in the path or whose parent is in the path
+ // Exclude children of the target message
+ return messages.filter(
+ (msg) =>
+ (pathToRoot.has(msg.messageId) && msg.messageId !== targetMessageId) ||
+ (pathToRoot.has(msg.parentMessageId) && msg.parentMessageId !== targetMessageId) ||
+ msg.messageId === targetMessageId,
+ );
+}
+
+/**
+ * Retrieves all messages up to the root from the target message and its neighbors.
+ * @param {TMessage[]} messages - The list of messages to search.
+ * @param {string} targetMessageId - The ID of the target message.
+ * @returns {TMessage[]} The list of inclusive messages up to the root from the target message.
+ */
+function getMessagesUpToTargetLevel(messages, targetMessageId) {
+ if (messages.length === 1 && messages[0] && messages[0].messageId === targetMessageId) {
+ return messages;
+ }
+
+ // Create a map of parentMessageId to children messages
+ const parentToChildrenMap = new Map();
+ for (const message of messages) {
+ if (!parentToChildrenMap.has(message.parentMessageId)) {
+ parentToChildrenMap.set(message.parentMessageId, []);
+ }
+ parentToChildrenMap.get(message.parentMessageId).push(message);
+ }
+
+ // Retrieve the target message
+ const targetMessage = messages.find((msg) => msg.messageId === targetMessageId);
+ if (!targetMessage) {
+ logger.error('Target message not found.');
+ return [];
+ }
+
+ const visited = new Set();
+
+ const rootMessages = parentToChildrenMap.get(Constants.NO_PARENT) || [];
+ let currentLevel = rootMessages.length > 0 ? [...rootMessages] : [targetMessage];
+ const results = new Set(currentLevel);
+
+ // Check if the target message is at the root level
+ if (
+ currentLevel.some((msg) => msg.messageId === targetMessageId) &&
+ targetMessage.parentMessageId === Constants.NO_PARENT
+ ) {
+ return Array.from(results);
+ }
+
+ // Iterate level by level until the target is found
+ let targetFound = false;
+ while (!targetFound && currentLevel.length > 0) {
+ const nextLevel = [];
+ for (const node of currentLevel) {
+ if (visited.has(node.messageId)) {
+ logger.warn('Cycle detected in message tree');
+ continue;
+ }
+ visited.add(node.messageId);
+ const children = parentToChildrenMap.get(node.messageId) || [];
+ for (const child of children) {
+ if (visited.has(child.messageId)) {
+ logger.warn('Cycle detected in message tree');
+ continue;
+ }
+ nextLevel.push(child);
+ results.add(child);
+ if (child.messageId === targetMessageId) {
+ targetFound = true;
+ }
+ }
+ }
+ currentLevel = nextLevel;
+ }
+
+ return Array.from(results);
+}
+
+/**
+ * Splits the conversation at the targeted message level, including the target, its siblings, and all descendant messages.
+ * All target level messages have their parentMessageId set to the root.
+ * @param {TMessage[]} messages - The list of messages to analyze.
+ * @param {string} targetMessageId - The ID of the message to start the split from.
+ * @returns {TMessage[]} The list of messages at and below the target level.
+ */
+function splitAtTargetLevel(messages, targetMessageId) {
+ // Create a map of parentMessageId to children messages
+ const parentToChildrenMap = new Map();
+ for (const message of messages) {
+ if (!parentToChildrenMap.has(message.parentMessageId)) {
+ parentToChildrenMap.set(message.parentMessageId, []);
+ }
+ parentToChildrenMap.get(message.parentMessageId).push(message);
+ }
+
+ // Retrieve the target message
+ const targetMessage = messages.find((msg) => msg.messageId === targetMessageId);
+ if (!targetMessage) {
+ logger.error('Target message not found.');
+ return [];
+ }
+
+ // Initialize the search with root messages
+ const rootMessages = parentToChildrenMap.get(Constants.NO_PARENT) || [];
+ let currentLevel = [...rootMessages];
+ let currentLevelIndex = 0;
+ const levelMap = {};
+
+ // Map messages to their levels
+ rootMessages.forEach((msg) => {
+ levelMap[msg.messageId] = 0;
+ });
+
+ // Search for the target level
+ while (currentLevel.length > 0) {
+ const nextLevel = [];
+ for (const node of currentLevel) {
+ const children = parentToChildrenMap.get(node.messageId) || [];
+ for (const child of children) {
+ nextLevel.push(child);
+ levelMap[child.messageId] = currentLevelIndex + 1;
+ }
+ }
+ currentLevel = nextLevel;
+ currentLevelIndex++;
+ }
+
+ // Determine the target level
+ const targetLevel = levelMap[targetMessageId];
+ if (targetLevel === undefined) {
+ logger.error('Target level not found.');
+ return [];
+ }
+
+ // Filter messages at or below the target level
+ const filteredMessages = messages
+ .map((msg) => {
+ const messageLevel = levelMap[msg.messageId];
+ if (messageLevel < targetLevel) {
+ return null;
+ } else if (messageLevel === targetLevel) {
+ return {
+ ...msg,
+ parentMessageId: Constants.NO_PARENT,
+ };
+ }
+
+ return msg;
+ })
+ .filter((msg) => msg !== null);
+
+ return filteredMessages;
+}
+
+/**
+ * Strips file identifiers from a shared message's `files` and `attachments`.
+ * A shared fork is owned by the requesting user, but the underlying file records
+ * still belong to the original sharer. Persisting their `file_id`s would let the
+ * agents file-resend path collect them on the next turn and call `getUserCodeFiles`,
+ * which looks them up by `file_id` with no ownership filter, rehydrating the
+ * sharer's files into the viewer's run. Dropping the ids keeps a fork's file
+ * access no broader than viewing the read-only share, while leaving render-only
+ * metadata (e.g. `filepath`, `toolCallId`) intact.
+ * @param {TMessage} message - The shared message to sanitize.
+ * @returns {TMessage} The message with file identifiers removed.
+ */
+function stripSharedFileIds(message) {
+ const sanitized = { ...message };
+ if (Array.isArray(sanitized.files)) {
+ sanitized.files = sanitized.files.map(({ file_id: _fileId, ...file }) => file);
+ }
+ if (Array.isArray(sanitized.attachments)) {
+ sanitized.attachments = sanitized.attachments.map(
+ ({ file_id: _fileId, ...attachment }) => attachment,
+ );
+ }
+ return sanitized;
+}
+
+/**
+ * Forks a shared (sanitized) conversation into a fresh conversation owned by the requesting user.
+ * Only the anonymized, allowlisted message fields returned by `getSharedMessages` are cloned,
+ * so no private data from the original owner can leak into the new conversation.
+ * @param {object} params - The parameters for forking the shared conversation.
+ * @param {string} params.shareId - The ID of the shared link to fork from.
+ * @param {string} [params.shareResourceId] - The SharedLink resource ID set by `canAccessSharedLink`.
+ * @param {string} params.requestUserId - The ID of the user making the request.
+ * @param {string} [params.userRole] - The role of the requesting user, used to resolve the default model.
+ * @param {string} [params.userTenantId] - Tenant of the requesting user. `canAccessSharedLink` runs this handler under the share owner's tenant so the share resolves, so the copy must be persisted (and its config/retention resolved) under the requesting user's tenant or it would be invisible (404) when they open it normally.
+ * @param {number} [params.targetMessageIndex] - Index, within the shared payload, of the message at the tip of the branch the viewer has active. When set, only the direct path to that message is cloned so the fork continues the branch that was actually shown rather than the newest sibling. An index is used (not id or `createdAt`) because shared ids are re-anonymized per request while `getSharedMessages` returns a deterministic, stable order, so the same index resolves to the same message on the server.
+ * @param {boolean} [params.snapshotFiles] - When `false`, file/attachment metadata is omitted from the cloned messages, mirroring the GET share route so the global shared-file kill switch is honored.
+ * @param {(userId: string, interfaceConfig?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance.
+ * @param {(options: object) => Promise} [params.loadAppConfig] - Resolves the app config; injectable for tests. Called inside the requesting user's tenant context so retention policy is read from the viewer's tenant, not the share owner's.
+ * @returns {Promise} The new conversation and messages, or null when the share is missing or empty.
+ */
+async function forkSharedConversation({
+ shareId,
+ shareResourceId,
+ requestUserId,
+ userRole,
+ userTenantId,
+ targetMessageIndex,
+ snapshotFiles,
+ builderFactory = createImportBatchBuilder,
+ loadAppConfig = getAppConfig,
+}) {
+ // Mirror the GET share route: when the shared-file snapshot is globally
+ // disabled, omit file/attachment metadata so a fork can't persist filenames
+ // or share file URLs into the new conversation while file serving is off.
+ const share = await getSharedMessages(shareId, shareResourceId, { snapshotFiles });
+ if (!share?.messages?.length) {
+ return null;
+ }
+
+ /**
+ * The shared payload includes sibling branches. Reduce to the direct path of
+ * the viewer's active message so the fork continues exactly the branch that
+ * was shown; without this the default branch selection lands on the newest
+ * sibling. The active tip is located by its index in the shared payload, which
+ * `getSharedMessages` returns in a deterministic order (stored ref-array order)
+ * — unlike ids (re-anonymized per request) or `createdAt` (can collide). Falls
+ * back to the full set when the index is absent or out of range.
+ */
+ let sourceMessages = share.messages;
+ if (
+ Number.isInteger(targetMessageIndex) &&
+ targetMessageIndex >= 0 &&
+ targetMessageIndex < share.messages.length
+ ) {
+ const targetMessage = share.messages[targetMessageIndex];
+ const directPath = BaseClient.getMessagesForConversation({
+ messages: share.messages,
+ parentMessageId: targetMessage.messageId,
+ });
+ if (directPath.length > 0) {
+ sourceMessages = directPath;
+ }
+ }
+
+ const messageIds = new Set(sourceMessages.map((message) => message.messageId));
+ const messagesToClone = sourceMessages.map(({ model: _model, ...message }) =>
+ stripSharedFileIds({
+ ...message,
+ parentMessageId:
+ message.parentMessageId != null && messageIds.has(message.parentMessageId)
+ ? message.parentMessageId
+ : Constants.NO_PARENT,
+ }),
+ );
+
+ /**
+ * Persist and read back under the requesting user's tenant rather than the
+ * share owner's. The read above runs in the share owner's tenant (set by
+ * `canAccessSharedLink`); writing the copy there would leave it invisible to
+ * the user under their normal tenant context (the new conversation would 404
+ * when they navigate to it). Switching to the user's tenant only affects this
+ * deployment when tenant isolation is enabled; otherwise it is a no-op.
+ */
+ return tenantStorage.run({ tenantId: userTenantId, userId: requestUserId }, async () => {
+ // Resolve config inside the viewer's tenant so retention (e.g. all-data
+ // expiry) reflects the requesting user's tenant, not the share owner's.
+ const appConfig = await loadAppConfig({
+ role: userRole,
+ userId: requestUserId,
+ tenantId: userTenantId,
+ });
+ // The shared payload strips the original endpoint, so resolve one the viewer
+ // can actually use; hard-coding OpenAI breaks the first follow-up message on
+ // deployments that don't expose it.
+ const { endpoint, model } = await resolveImportDefaultEndpoint({ requestUserId, userRole });
+ const importBatchBuilder = builderFactory(requestUserId, appConfig?.interfaceConfig);
+ importBatchBuilder.startConversation(endpoint);
+
+ cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
+
+ const result = importBatchBuilder.finishConversation(share.title, new Date(), {}, model);
+ await importBatchBuilder.saveBatch();
+ logger.debug(
+ `user: ${requestUserId} | New conversation "${result.conversation.title}" forked from share ID ${shareId}`,
+ );
+
+ const conversation = await getConvo(requestUserId, result.conversation.conversationId);
+ const messages = await getMessages({
+ user: requestUserId,
+ conversationId: conversation.conversationId,
+ });
+
+ return {
+ conversation,
+ messages,
+ };
+ });
+}
+
+/**
+ * Duplicates a conversation and all its messages.
+ * @param {object} params - The parameters for duplicating the conversation.
+ * @param {string} params.userId - The ID of the user duplicating the conversation.
+ * @param {string} params.conversationId - The ID of the conversation to duplicate.
+ * @param {string} [params.title] - Optional title override for the duplicate.
+ * @returns {Promise<{ conversation: TConversation, messages: TMessage[] }>} The duplicated conversation and messages.
+ */
+async function duplicateConversation({ userId, conversationId, title }) {
+ const originalConvo = await getConvo(userId, conversationId);
+ if (!originalConvo) {
+ throw new Error('Conversation not found');
+ }
+
+ const originalMessages = await getMessages({
+ user: userId,
+ conversationId,
+ });
+
+ const messagesToClone = getMessagesUpToTargetLevel(
+ originalMessages,
+ originalMessages[originalMessages.length - 1].messageId,
+ );
+
+ const importBatchBuilder = createImportBatchBuilder(userId);
+ importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI);
+
+ cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
+
+ const duplicateTitle = title || originalConvo.title;
+ const result = importBatchBuilder.finishConversation(duplicateTitle, new Date(), originalConvo);
+ await importBatchBuilder.saveBatch();
+ logger.debug(
+ `user: ${userId} | New conversation "${duplicateTitle}" duplicated from conversation ID ${conversationId}`,
+ );
+
+ const conversation = await getConvo(userId, result.conversation.conversationId);
+ const messages = await getMessages({
+ user: userId,
+ conversationId: conversation.conversationId,
+ });
+
+ return {
+ conversation,
+ messages,
+ };
+}
+
+module.exports = {
+ forkConversation,
+ splitAtTargetLevel,
+ duplicateConversation,
+ forkSharedConversation,
+ getAllMessagesUpToParent,
+ getMessagesUpToTargetLevel,
+ cloneMessagesWithTimestamps,
+};
diff --git a/api/server/utils/import/fork.spec.js b/api/server/utils/import/fork.spec.js
new file mode 100644
index 0000000..adb576d
--- /dev/null
+++ b/api/server/utils/import/fork.spec.js
@@ -0,0 +1,1368 @@
+const { Constants, ForkOptions } = require('librechat-data-provider');
+
+jest.mock('~/models', () => ({
+ getConvo: jest.fn(),
+ bulkSaveConvos: jest.fn(),
+ getMessages: jest.fn(),
+ bulkSaveMessages: jest.fn(),
+ bulkIncrementTagCounts: jest.fn(),
+ getSharedMessages: jest.fn(),
+}));
+
+jest.mock('~/server/controllers/ModelController', () => ({
+ getModelsConfig: jest.fn().mockResolvedValue({ openAI: ['gpt-test'] }),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn().mockResolvedValue({ interfaceConfig: {} }),
+}));
+
+let mockIdCounter = 0;
+jest.mock('uuid', () => {
+ return {
+ v4: jest.fn(() => {
+ mockIdCounter++;
+ return mockIdCounter.toString();
+ }),
+ };
+});
+
+const {
+ forkConversation,
+ duplicateConversation,
+ forkSharedConversation,
+ splitAtTargetLevel,
+ getAllMessagesUpToParent,
+ getMessagesUpToTargetLevel,
+ cloneMessagesWithTimestamps,
+} = require('./fork');
+const {
+ bulkIncrementTagCounts,
+ getConvo,
+ bulkSaveConvos,
+ getMessages,
+ bulkSaveMessages,
+ getSharedMessages,
+} = require('~/models');
+const { getModelsConfig } = require('~/server/controllers/ModelController');
+const { createImportBatchBuilder } = require('./importBatchBuilder');
+const BaseClient = require('~/app/clients/BaseClient');
+
+/**
+ *
+ * @param {TMessage[]} messages - The list of messages to visualize.
+ * @param {string | null} parentId - The parent message ID.
+ * @param {string} prefix - The prefix to use for each line.
+ * @returns
+ */
+function printMessageTree(messages, parentId = Constants.NO_PARENT, prefix = '') {
+ let treeVisual = '';
+
+ const childMessages = messages.filter((msg) => msg.parentMessageId === parentId);
+ for (let index = 0; index < childMessages.length; index++) {
+ const msg = childMessages[index];
+ const isLast = index === childMessages.length - 1;
+ const connector = isLast ? '└── ' : '├── ';
+
+ treeVisual += `${prefix}${connector}[${msg.messageId}]: ${
+ msg.parentMessageId !== Constants.NO_PARENT ? `Child of ${msg.parentMessageId}` : 'Root'
+ }\n`;
+ treeVisual += printMessageTree(messages, msg.messageId, prefix + (isLast ? ' ' : '| '));
+ }
+
+ return treeVisual;
+}
+
+const mockMessages = [
+ {
+ messageId: '0',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Root message 1',
+ createdAt: '2021-01-01',
+ },
+ {
+ messageId: '1',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Root message 2',
+ createdAt: '2021-01-01',
+ },
+ { messageId: '2', parentMessageId: '1', text: 'Child of 1', createdAt: '2021-01-02' },
+ { messageId: '3', parentMessageId: '1', text: 'Child of 1', createdAt: '2021-01-03' },
+ { messageId: '4', parentMessageId: '2', text: 'Child of 2', createdAt: '2021-01-04' },
+ { messageId: '5', parentMessageId: '2', text: 'Child of 2', createdAt: '2021-01-05' },
+ { messageId: '6', parentMessageId: '3', text: 'Child of 3', createdAt: '2021-01-06' },
+ { messageId: '7', parentMessageId: '3', text: 'Child of 3', createdAt: '2021-01-07' },
+ { messageId: '8', parentMessageId: '7', text: 'Child of 7', createdAt: '2021-01-07' },
+];
+
+const mockConversation = { convoId: 'abc123', title: 'Original Title' };
+
+describe('forkConversation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockIdCounter = 0;
+ getConvo.mockResolvedValue(mockConversation);
+ getMessages.mockResolvedValue(mockMessages);
+ bulkSaveConvos.mockResolvedValue(null);
+ bulkSaveMessages.mockResolvedValue(null);
+ });
+
+ test('should fork conversation without branches', async () => {
+ const result = await forkConversation({
+ originalConvoId: 'abc123',
+ targetMessageId: '3',
+ requestUserId: 'user1',
+ option: ForkOptions.DIRECT_PATH,
+ });
+ console.debug('forkConversation: direct path\n', printMessageTree(result.messages));
+
+ // Reversed order due to setup in function
+ const expectedMessagesTexts = ['Child of 1', 'Root message 2'];
+ expect(getMessages).toHaveBeenCalled();
+ expect(bulkSaveMessages).toHaveBeenCalledWith(
+ expect.arrayContaining(
+ expectedMessagesTexts.map((text) => expect.objectContaining({ text })),
+ ),
+ true,
+ );
+ });
+
+ test('should fork conversation without branches (deeper)', async () => {
+ const result = await forkConversation({
+ originalConvoId: 'abc123',
+ targetMessageId: '8',
+ requestUserId: 'user1',
+ option: ForkOptions.DIRECT_PATH,
+ });
+ console.debug('forkConversation: direct path (deeper)\n', printMessageTree(result.messages));
+
+ const expectedMessagesTexts = ['Child of 7', 'Child of 3', 'Child of 1', 'Root message 2'];
+ expect(getMessages).toHaveBeenCalled();
+ expect(bulkSaveMessages).toHaveBeenCalledWith(
+ expect.arrayContaining(
+ expectedMessagesTexts.map((text) => expect.objectContaining({ text })),
+ ),
+ true,
+ );
+ });
+
+ test('should fork conversation with branches', async () => {
+ const result = await forkConversation({
+ originalConvoId: 'abc123',
+ targetMessageId: '3',
+ requestUserId: 'user1',
+ option: ForkOptions.INCLUDE_BRANCHES,
+ });
+
+ console.debug('forkConversation: include branches\n', printMessageTree(result.messages));
+
+ const expectedMessagesTexts = ['Root message 2', 'Child of 1', 'Child of 1'];
+ expect(getMessages).toHaveBeenCalled();
+ expect(bulkSaveMessages).toHaveBeenCalledWith(
+ expect.arrayContaining(
+ expectedMessagesTexts.map((text) => expect.objectContaining({ text })),
+ ),
+ true,
+ );
+ });
+
+ test('should fork conversation up to target level', async () => {
+ const result = await forkConversation({
+ originalConvoId: 'abc123',
+ targetMessageId: '3',
+ requestUserId: 'user1',
+ option: ForkOptions.TARGET_LEVEL,
+ });
+
+ console.debug('forkConversation: target level\n', printMessageTree(result.messages));
+
+ const expectedMessagesTexts = ['Root message 1', 'Root message 2', 'Child of 1', 'Child of 1'];
+ expect(getMessages).toHaveBeenCalled();
+ expect(bulkSaveMessages).toHaveBeenCalledWith(
+ expect.arrayContaining(
+ expectedMessagesTexts.map((text) => expect.objectContaining({ text })),
+ ),
+ true,
+ );
+ });
+
+ test('should handle errors during message fetching', async () => {
+ getMessages.mockRejectedValue(new Error('Failed to fetch messages'));
+
+ await expect(
+ forkConversation({
+ originalConvoId: 'abc123',
+ targetMessageId: '3',
+ requestUserId: 'user1',
+ }),
+ ).rejects.toThrow('Failed to fetch messages');
+ });
+
+ test('should increment tag counts when forking conversation with tags', async () => {
+ const mockConvoWithTags = {
+ ...mockConversation,
+ tags: ['bookmark1', 'bookmark2'],
+ };
+ getConvo.mockResolvedValue(mockConvoWithTags);
+
+ await forkConversation({
+ originalConvoId: 'abc123',
+ targetMessageId: '3',
+ requestUserId: 'user1',
+ option: ForkOptions.DIRECT_PATH,
+ });
+
+ // Verify that bulkIncrementTagCounts was called with correct tags
+ expect(bulkIncrementTagCounts).toHaveBeenCalledWith('user1', ['bookmark1', 'bookmark2']);
+ });
+
+ test('should handle conversation without tags when forking', async () => {
+ const mockConvoWithoutTags = {
+ ...mockConversation,
+ // No tags field
+ };
+ getConvo.mockResolvedValue(mockConvoWithoutTags);
+
+ await forkConversation({
+ originalConvoId: 'abc123',
+ targetMessageId: '3',
+ requestUserId: 'user1',
+ option: ForkOptions.DIRECT_PATH,
+ });
+
+ // bulkIncrementTagCounts will be called with array containing undefined
+ expect(bulkIncrementTagCounts).toHaveBeenCalled();
+ });
+
+ test('should handle empty tags array when forking', async () => {
+ const mockConvoWithEmptyTags = {
+ ...mockConversation,
+ tags: [],
+ };
+ getConvo.mockResolvedValue(mockConvoWithEmptyTags);
+
+ await forkConversation({
+ originalConvoId: 'abc123',
+ targetMessageId: '3',
+ requestUserId: 'user1',
+ option: ForkOptions.DIRECT_PATH,
+ });
+
+ // bulkIncrementTagCounts will be called with empty array
+ expect(bulkIncrementTagCounts).toHaveBeenCalledWith('user1', []);
+ });
+});
+
+describe('duplicateConversation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockIdCounter = 0;
+ getConvo.mockResolvedValue(mockConversation);
+ getMessages.mockResolvedValue(mockMessages);
+ bulkSaveConvos.mockResolvedValue(null);
+ bulkSaveMessages.mockResolvedValue(null);
+ bulkIncrementTagCounts.mockResolvedValue(null);
+ });
+
+ test('should duplicate conversation and increment tag counts', async () => {
+ const mockConvoWithTags = {
+ ...mockConversation,
+ tags: ['important', 'work', 'project'],
+ };
+ getConvo.mockResolvedValue(mockConvoWithTags);
+
+ await duplicateConversation({
+ userId: 'user1',
+ conversationId: 'abc123',
+ });
+
+ // Verify that bulkIncrementTagCounts was called with correct tags
+ expect(bulkIncrementTagCounts).toHaveBeenCalledWith('user1', ['important', 'work', 'project']);
+ });
+
+ test('should duplicate conversation without tags', async () => {
+ const mockConvoWithoutTags = {
+ ...mockConversation,
+ // No tags field
+ };
+ getConvo.mockResolvedValue(mockConvoWithoutTags);
+
+ await duplicateConversation({
+ userId: 'user1',
+ conversationId: 'abc123',
+ });
+
+ // bulkIncrementTagCounts will be called with array containing undefined
+ expect(bulkIncrementTagCounts).toHaveBeenCalled();
+ });
+
+ test('should handle empty tags array when duplicating', async () => {
+ const mockConvoWithEmptyTags = {
+ ...mockConversation,
+ tags: [],
+ };
+ getConvo.mockResolvedValue(mockConvoWithEmptyTags);
+
+ await duplicateConversation({
+ userId: 'user1',
+ conversationId: 'abc123',
+ });
+
+ // bulkIncrementTagCounts will be called with empty array
+ expect(bulkIncrementTagCounts).toHaveBeenCalledWith('user1', []);
+ });
+});
+
+describe('forkSharedConversation', () => {
+ const mockSharedMessages = [
+ {
+ messageId: 'msg_a',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Shared root',
+ isCreatedByUser: true,
+ createdAt: '2021-01-01',
+ },
+ {
+ messageId: 'msg_b',
+ parentMessageId: 'msg_a',
+ text: 'Shared reply',
+ isCreatedByUser: false,
+ createdAt: '2021-01-02',
+ },
+ ];
+
+ const mockShare = {
+ shareId: 'share123',
+ conversationId: 'convo_anon',
+ title: 'Shared Title',
+ messages: mockSharedMessages,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockIdCounter = 0;
+ getSharedMessages.mockResolvedValue(mockShare);
+ getConvo.mockResolvedValue(mockConversation);
+ getMessages.mockResolvedValue(mockSharedMessages);
+ bulkSaveConvos.mockResolvedValue(null);
+ bulkSaveMessages.mockResolvedValue(null);
+ bulkIncrementTagCounts.mockResolvedValue(null);
+ });
+
+ test('should clone shared messages into a conversation owned by the requesting user', async () => {
+ const result = await forkSharedConversation({
+ shareId: 'share123',
+ shareResourceId: 'resource123',
+ requestUserId: 'user1',
+ });
+
+ expect(getSharedMessages).toHaveBeenCalledWith('share123', 'resource123', {
+ snapshotFiles: undefined,
+ });
+
+ const savedMessages = bulkSaveMessages.mock.calls[0][0];
+ expect(savedMessages).toHaveLength(2);
+ const [root, reply] = savedMessages;
+ expect(root).toMatchObject({
+ text: 'Shared root',
+ user: 'user1',
+ endpoint: 'openAI',
+ parentMessageId: Constants.NO_PARENT,
+ });
+ expect(reply).toMatchObject({
+ text: 'Shared reply',
+ user: 'user1',
+ parentMessageId: root.messageId,
+ });
+ expect(root.messageId).not.toBe('msg_a');
+ expect(reply.messageId).not.toBe('msg_b');
+
+ const savedConvos = bulkSaveConvos.mock.calls[0][0];
+ expect(savedConvos[0]).toMatchObject({
+ user: 'user1',
+ title: 'Shared Title',
+ endpoint: 'openAI',
+ model: 'gpt-test',
+ });
+
+ expect(getConvo).toHaveBeenCalledWith('user1', savedConvos[0].conversationId);
+ expect(result).toMatchObject({ conversation: mockConversation, messages: mockSharedMessages });
+ });
+
+ test('should use an available endpoint when the deployment does not expose OpenAI', async () => {
+ getModelsConfig.mockResolvedValueOnce({ anthropic: ['claude-test'] });
+
+ await forkSharedConversation({
+ shareId: 'share123',
+ shareResourceId: 'resource123',
+ requestUserId: 'user1',
+ });
+
+ const savedConvos = bulkSaveConvos.mock.calls[0][0];
+ expect(savedConvos[0]).toMatchObject({ endpoint: 'anthropic', model: 'claude-test' });
+
+ const savedMessages = bulkSaveMessages.mock.calls[0][0];
+ expect(savedMessages.every((message) => message.endpoint === 'anthropic')).toBe(true);
+ });
+
+ test('should return null when the share is not found', async () => {
+ getSharedMessages.mockResolvedValue(null);
+
+ const result = await forkSharedConversation({
+ shareId: 'missing',
+ requestUserId: 'user1',
+ });
+
+ expect(result).toBeNull();
+ expect(bulkSaveMessages).not.toHaveBeenCalled();
+ });
+
+ test('should return null when the share has no messages', async () => {
+ getSharedMessages.mockResolvedValue({ ...mockShare, messages: [] });
+
+ const result = await forkSharedConversation({
+ shareId: 'share123',
+ requestUserId: 'user1',
+ });
+
+ expect(result).toBeNull();
+ expect(bulkSaveMessages).not.toHaveBeenCalled();
+ });
+
+ test('should normalize orphaned parentMessageId references to NO_PARENT', async () => {
+ getSharedMessages.mockResolvedValue({
+ ...mockShare,
+ messages: [
+ {
+ messageId: 'msg_orphan',
+ parentMessageId: 'msg_deleted',
+ text: 'Orphaned message',
+ createdAt: '2021-01-01',
+ },
+ ],
+ });
+
+ await forkSharedConversation({
+ shareId: 'share123',
+ requestUserId: 'user1',
+ });
+
+ const savedMessages = bulkSaveMessages.mock.calls[0][0];
+ expect(savedMessages[0].parentMessageId).toBe(Constants.NO_PARENT);
+ });
+
+ test('should forward snapshotFiles to getSharedMessages so the kill switch is honored', async () => {
+ await forkSharedConversation({
+ shareId: 'share123',
+ shareResourceId: 'resource123',
+ requestUserId: 'user1',
+ snapshotFiles: false,
+ });
+
+ expect(getSharedMessages).toHaveBeenCalledWith('share123', 'resource123', {
+ snapshotFiles: false,
+ });
+ });
+
+ test('should strip anonymized model identifiers from cloned messages', async () => {
+ getSharedMessages.mockResolvedValue({
+ ...mockShare,
+ messages: [
+ {
+ messageId: 'msg_a',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Assistant message',
+ model: 'a_anon123',
+ createdAt: '2021-01-01',
+ },
+ ],
+ });
+
+ await forkSharedConversation({
+ shareId: 'share123',
+ requestUserId: 'user1',
+ });
+
+ const savedMessages = bulkSaveMessages.mock.calls[0][0];
+ expect(savedMessages[0].model).not.toBe('a_anon123');
+ });
+
+ test('should strip file_id from cloned files and attachments', async () => {
+ getSharedMessages.mockResolvedValue({
+ ...mockShare,
+ messages: [
+ {
+ messageId: 'msg_a',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Message with files',
+ isCreatedByUser: true,
+ createdAt: '2021-01-01',
+ files: [{ file_id: 'owner-file-1', filepath: '/images/owner/a.png' }],
+ attachments: [
+ { file_id: 'owner-file-2', toolCallId: 'tool_1', filepath: '/images/owner/b.png' },
+ ],
+ },
+ ],
+ });
+
+ await forkSharedConversation({
+ shareId: 'share123',
+ requestUserId: 'user1',
+ });
+
+ const savedMessages = bulkSaveMessages.mock.calls[0][0];
+ const [message] = savedMessages;
+ expect(message.files[0]).not.toHaveProperty('file_id');
+ expect(message.attachments[0]).not.toHaveProperty('file_id');
+ // Render-only metadata is preserved
+ expect(message.files[0].filepath).toBe('/images/owner/a.png');
+ expect(message.attachments[0].toolCallId).toBe('tool_1');
+ });
+
+ test('should resolve interfaceConfig from the app config and pass it to the builder', async () => {
+ const interfaceConfig = { retentionMode: 'all', retention: { days: 30 } };
+ const loadAppConfig = jest.fn().mockResolvedValue({ interfaceConfig });
+ const builderFactory = jest.fn((userId, config) => createImportBatchBuilder(userId, config));
+
+ await forkSharedConversation({
+ shareId: 'share123',
+ requestUserId: 'user1',
+ userRole: 'USER',
+ userTenantId: 'tenant-viewer',
+ loadAppConfig,
+ builderFactory,
+ });
+
+ expect(loadAppConfig).toHaveBeenCalledWith({
+ role: 'USER',
+ userId: 'user1',
+ tenantId: 'tenant-viewer',
+ });
+ expect(builderFactory).toHaveBeenCalledWith('user1', interfaceConfig);
+ });
+
+ test('should resolve the app config under the requesting user tenant', async () => {
+ const { tenantStorage, getTenantId } = require('@librechat/data-schemas');
+ let tenantDuringConfigLoad;
+ const loadAppConfig = jest.fn(async () => {
+ tenantDuringConfigLoad = getTenantId();
+ return { interfaceConfig: {} };
+ });
+
+ await tenantStorage.run({ tenantId: 'tenant-share-owner' }, () =>
+ forkSharedConversation({
+ shareId: 'share123',
+ requestUserId: 'user1',
+ userTenantId: 'tenant-viewer',
+ loadAppConfig,
+ }),
+ );
+
+ expect(tenantDuringConfigLoad).toBe('tenant-viewer');
+ });
+
+ test('should clone only the active branch path when targetMessageIndex is provided', async () => {
+ getSharedMessages.mockResolvedValue({
+ ...mockShare,
+ messages: [
+ {
+ messageId: 'msg_root',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Root',
+ createdAt: '2021-01-01T00:00:00.000Z',
+ },
+ {
+ messageId: 'msg_branch_a',
+ parentMessageId: 'msg_root',
+ text: 'Branch A (shared)',
+ createdAt: '2021-01-02T00:00:00.000Z',
+ },
+ {
+ messageId: 'msg_branch_b',
+ parentMessageId: 'msg_root',
+ text: 'Branch B (newer sibling)',
+ createdAt: '2021-01-03T00:00:00.000Z',
+ },
+ ],
+ });
+
+ // Index 1 = the "Branch A" tip the viewer had active.
+ await forkSharedConversation({
+ shareId: 'share123',
+ requestUserId: 'user1',
+ targetMessageIndex: 1,
+ });
+
+ const savedTexts = bulkSaveMessages.mock.calls[0][0].map((message) => message.text);
+ expect(savedTexts).toEqual(['Root', 'Branch A (shared)']);
+ expect(savedTexts).not.toContain('Branch B (newer sibling)');
+ });
+
+ test('should select the correct branch even when siblings share a createdAt', async () => {
+ getSharedMessages.mockResolvedValue({
+ ...mockShare,
+ messages: [
+ {
+ messageId: 'msg_root',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Root',
+ createdAt: '2021-01-01T00:00:00.000Z',
+ },
+ {
+ messageId: 'msg_sib_a',
+ parentMessageId: 'msg_root',
+ text: 'Sibling A',
+ createdAt: '2021-01-02T00:00:00.000Z',
+ },
+ {
+ messageId: 'msg_sib_b',
+ parentMessageId: 'msg_root',
+ text: 'Sibling B (same timestamp)',
+ createdAt: '2021-01-02T00:00:00.000Z',
+ },
+ ],
+ });
+
+ // Index 2 unambiguously targets Sibling B despite the shared createdAt.
+ await forkSharedConversation({
+ shareId: 'share123',
+ requestUserId: 'user1',
+ targetMessageIndex: 2,
+ });
+
+ const savedTexts = bulkSaveMessages.mock.calls[0][0].map((message) => message.text);
+ expect(savedTexts).toEqual(['Root', 'Sibling B (same timestamp)']);
+ expect(savedTexts).not.toContain('Sibling A');
+ });
+
+ test('should fall back to the full set when targetMessageIndex is out of range', async () => {
+ await forkSharedConversation({
+ shareId: 'share123',
+ requestUserId: 'user1',
+ targetMessageIndex: 999,
+ });
+
+ expect(bulkSaveMessages.mock.calls[0][0]).toHaveLength(mockSharedMessages.length);
+ });
+
+ test('should persist under the requesting user tenant, not the share tenant', async () => {
+ const { tenantStorage, getTenantId } = require('@librechat/data-schemas');
+ let tenantDuringSave;
+ bulkSaveConvos.mockImplementation(async () => {
+ tenantDuringSave = getTenantId();
+ });
+
+ // Simulate the handler running inside the share owner's tenant context
+ // (as `canAccessSharedLink` does) and ensure the write switches to the viewer's.
+ await tenantStorage.run({ tenantId: 'tenant-share-owner' }, () =>
+ forkSharedConversation({
+ shareId: 'share123',
+ requestUserId: 'user1',
+ userTenantId: 'tenant-viewer',
+ }),
+ );
+
+ expect(tenantDuringSave).toBe('tenant-viewer');
+ });
+});
+
+const mockMessagesComplex = [
+ { messageId: '7', parentMessageId: Constants.NO_PARENT, text: 'Message 7' },
+ { messageId: '8', parentMessageId: Constants.NO_PARENT, text: 'Message 8' },
+ { messageId: '5', parentMessageId: '7', text: 'Message 5' },
+ { messageId: '6', parentMessageId: '7', text: 'Message 6' },
+ { messageId: '9', parentMessageId: '8', text: 'Message 9' },
+ { messageId: '2', parentMessageId: '5', text: 'Message 2' },
+ { messageId: '3', parentMessageId: '5', text: 'Message 3' },
+ { messageId: '1', parentMessageId: '6', text: 'Message 1' },
+ { messageId: '4', parentMessageId: '6', text: 'Message 4' },
+ { messageId: '10', parentMessageId: '3', text: 'Message 10' },
+];
+
+describe('getMessagesUpToTargetLevel', () => {
+ test('should get all messages up to target level', async () => {
+ const result = getMessagesUpToTargetLevel(mockMessagesComplex, '5');
+ const mappedResult = result.map((msg) => msg.messageId);
+ console.debug(
+ '[getMessagesUpToTargetLevel] should get all messages up to target level\n',
+ mappedResult,
+ );
+ console.debug('mockMessages\n', printMessageTree(mockMessagesComplex));
+ console.debug('result\n', printMessageTree(result));
+ expect(mappedResult).toEqual(['7', '8', '5', '6', '9']);
+ });
+
+ test('should get all messages if target is deepest level', async () => {
+ const result = getMessagesUpToTargetLevel(mockMessagesComplex, '10');
+ expect(result.length).toEqual(mockMessagesComplex.length);
+ });
+
+ test('should return target if only message', async () => {
+ const result = getMessagesUpToTargetLevel(
+ [mockMessagesComplex[mockMessagesComplex.length - 1]],
+ '10',
+ );
+ const mappedResult = result.map((msg) => msg.messageId);
+ console.debug(
+ '[getMessagesUpToTargetLevel] should return target if only message\n',
+ mappedResult,
+ );
+ console.debug('mockMessages\n', printMessageTree(mockMessages));
+ console.debug('result\n', printMessageTree(result));
+ expect(mappedResult).toEqual(['10']);
+ });
+
+ test('should return empty array if target message ID does not exist', async () => {
+ const result = getMessagesUpToTargetLevel(mockMessagesComplex, '123');
+ expect(result).toEqual([]);
+ });
+
+ test('should return correct messages when target is a root message', async () => {
+ const result = getMessagesUpToTargetLevel(mockMessagesComplex, '7');
+ const mappedResult = result.map((msg) => msg.messageId);
+ expect(mappedResult).toEqual(['7', '8']);
+ });
+
+ test('should correctly handle single message with non-matching ID', async () => {
+ const singleMessage = [
+ { messageId: '30', parentMessageId: Constants.NO_PARENT, text: 'Message 30' },
+ ];
+ const result = getMessagesUpToTargetLevel(singleMessage, '31');
+ expect(result).toEqual([]);
+ });
+
+ test('should correctly handle case with circular dependencies', async () => {
+ const circularMessages = [
+ { messageId: '40', parentMessageId: '42', text: 'Message 40' },
+ { messageId: '41', parentMessageId: '40', text: 'Message 41' },
+ { messageId: '42', parentMessageId: '41', text: 'Message 42' },
+ ];
+ const result = getMessagesUpToTargetLevel(circularMessages, '40');
+ const mappedResult = result.map((msg) => msg.messageId);
+ expect(new Set(mappedResult)).toEqual(new Set(['40', '41', '42']));
+ });
+
+ test('should return all messages when all are interconnected and target is deep in hierarchy', async () => {
+ const interconnectedMessages = [
+ { messageId: '50', parentMessageId: Constants.NO_PARENT, text: 'Root Message' },
+ { messageId: '51', parentMessageId: '50', text: 'Child Level 1' },
+ { messageId: '52', parentMessageId: '51', text: 'Child Level 2' },
+ { messageId: '53', parentMessageId: '52', text: 'Child Level 3' },
+ ];
+ const result = getMessagesUpToTargetLevel(interconnectedMessages, '53');
+ const mappedResult = result.map((msg) => msg.messageId);
+ expect(mappedResult).toEqual(['50', '51', '52', '53']);
+ });
+});
+
+describe('getAllMessagesUpToParent', () => {
+ const mockMessages = [
+ { messageId: '11', parentMessageId: Constants.NO_PARENT, text: 'Message 11' },
+ { messageId: '12', parentMessageId: Constants.NO_PARENT, text: 'Message 12' },
+ { messageId: '13', parentMessageId: '11', text: 'Message 13' },
+ { messageId: '14', parentMessageId: '12', text: 'Message 14' },
+ { messageId: '15', parentMessageId: '13', text: 'Message 15' },
+ { messageId: '16', parentMessageId: '13', text: 'Message 16' },
+ { messageId: '21', parentMessageId: '13', text: 'Message 21' },
+ { messageId: '17', parentMessageId: '14', text: 'Message 17' },
+ { messageId: '18', parentMessageId: '16', text: 'Message 18' },
+ { messageId: '19', parentMessageId: '18', text: 'Message 19' },
+ { messageId: '20', parentMessageId: '19', text: 'Message 20' },
+ ];
+
+ test('should handle empty message list', async () => {
+ const result = getAllMessagesUpToParent([], '10');
+ expect(result).toEqual([]);
+ });
+
+ test('should handle target message not found', async () => {
+ const result = getAllMessagesUpToParent(mockMessages, 'invalid-id');
+ expect(result).toEqual([]);
+ });
+
+ test('should handle single level tree (no parents)', async () => {
+ const result = getAllMessagesUpToParent(
+ [
+ { messageId: '11', parentMessageId: Constants.NO_PARENT, text: 'Message 11' },
+ { messageId: '12', parentMessageId: Constants.NO_PARENT, text: 'Message 12' },
+ ],
+ '11',
+ );
+ const mappedResult = result.map((msg) => msg.messageId);
+ expect(mappedResult).toEqual(['11']);
+ });
+
+ test('should correctly retrieve messages in a deeply nested structure', async () => {
+ const result = getAllMessagesUpToParent(mockMessages, '20');
+ const mappedResult = result.map((msg) => msg.messageId);
+ expect(mappedResult).toContain('11');
+ expect(mappedResult).toContain('13');
+ expect(mappedResult).toContain('16');
+ expect(mappedResult).toContain('18');
+ expect(mappedResult).toContain('19');
+ expect(mappedResult).toContain('20');
+ });
+
+ test('should return only the target message if it has no parent', async () => {
+ const result = getAllMessagesUpToParent(mockMessages, '11');
+ const mappedResult = result.map((msg) => msg.messageId);
+ expect(mappedResult).toEqual(['11']);
+ });
+
+ test('should handle messages without a parent ID defined', async () => {
+ const additionalMessages = [
+ ...mockMessages,
+ { messageId: '22', text: 'Message 22' }, // No parentMessageId field
+ ];
+ const result = getAllMessagesUpToParent(additionalMessages, '22');
+ const mappedResult = result.map((msg) => msg.messageId);
+ expect(mappedResult).toEqual(['22']);
+ });
+
+ test('should retrieve all messages from the target to the root (including indirect ancestors)', async () => {
+ const result = getAllMessagesUpToParent(mockMessages, '18');
+ const mappedResult = result.map((msg) => msg.messageId);
+ console.debug(
+ '[getAllMessagesUpToParent] should retrieve all messages from the target to the root\n',
+ mappedResult,
+ );
+ console.debug('mockMessages\n', printMessageTree(mockMessages));
+ console.debug('result\n', printMessageTree(result));
+ expect(mappedResult).toEqual(['11', '13', '15', '16', '21', '18']);
+ });
+
+ test('should handle circular dependencies gracefully', () => {
+ const mockMessages = [
+ { messageId: '1', parentMessageId: '2' },
+ { messageId: '2', parentMessageId: '3' },
+ { messageId: '3', parentMessageId: '1' },
+ ];
+
+ const targetMessageId = '1';
+ const result = getAllMessagesUpToParent(mockMessages, targetMessageId);
+
+ const uniqueIds = new Set(result.map((msg) => msg.messageId));
+ expect(uniqueIds.size).toBe(result.length);
+ expect(result.map((msg) => msg.messageId).sort()).toEqual(['1', '2', '3'].sort());
+ });
+
+ test('should return target if only message', async () => {
+ const result = getAllMessagesUpToParent([mockMessages[mockMessages.length - 1]], '20');
+ const mappedResult = result.map((msg) => msg.messageId);
+ console.debug(
+ '[getAllMessagesUpToParent] should return target if only message\n',
+ mappedResult,
+ );
+ console.debug('mockMessages\n', printMessageTree(mockMessages));
+ console.debug('result\n', printMessageTree(result));
+ expect(mappedResult).toEqual(['20']);
+ });
+});
+
+describe('getMessagesForConversation', () => {
+ const mockMessages = [
+ { messageId: '11', parentMessageId: Constants.NO_PARENT, text: 'Message 11' },
+ { messageId: '12', parentMessageId: Constants.NO_PARENT, text: 'Message 12' },
+ { messageId: '13', parentMessageId: '11', text: 'Message 13' },
+ { messageId: '14', parentMessageId: '12', text: 'Message 14' },
+ { messageId: '15', parentMessageId: '13', text: 'Message 15' },
+ { messageId: '16', parentMessageId: '13', text: 'Message 16' },
+ { messageId: '21', parentMessageId: '13', text: 'Message 21' },
+ { messageId: '17', parentMessageId: '14', text: 'Message 17' },
+ { messageId: '18', parentMessageId: '16', text: 'Message 18' },
+ { messageId: '19', parentMessageId: '18', text: 'Message 19' },
+ { messageId: '20', parentMessageId: '19', text: 'Message 20' },
+ ];
+
+ test('should provide the direct path to the target without branches', async () => {
+ const result = BaseClient.getMessagesForConversation({
+ messages: mockMessages,
+ parentMessageId: '18',
+ });
+ const mappedResult = result.map((msg) => msg.messageId);
+ console.debug(
+ '[getMessagesForConversation] should provide the direct path to the target without branches\n',
+ mappedResult,
+ );
+ console.debug('mockMessages\n', printMessageTree(mockMessages));
+ console.debug('result\n', printMessageTree(result));
+ expect(new Set(mappedResult)).toEqual(new Set(['11', '13', '16', '18']));
+ });
+
+ test('should return target if only message', async () => {
+ const result = BaseClient.getMessagesForConversation({
+ messages: [mockMessages[mockMessages.length - 1]],
+ parentMessageId: '20',
+ });
+ const mappedResult = result.map((msg) => msg.messageId);
+ console.debug(
+ '[getMessagesForConversation] should return target if only message\n',
+ mappedResult,
+ );
+ console.debug('mockMessages\n', printMessageTree(mockMessages));
+ console.debug('result\n', printMessageTree(result));
+ expect(new Set(mappedResult)).toEqual(new Set(['20']));
+ });
+
+ test('should break on detecting a circular dependency', async () => {
+ const mockMessagesWithCycle = [
+ ...mockMessagesComplex,
+ { messageId: '100', parentMessageId: '101', text: 'Message 100' },
+ { messageId: '101', parentMessageId: '100', text: 'Message 101' }, // introduces circular dependency
+ ];
+
+ const result = BaseClient.getMessagesForConversation({
+ messages: mockMessagesWithCycle,
+ parentMessageId: '100',
+ });
+ const mappedResult = result.map((msg) => msg.messageId);
+ console.debug(
+ '[getMessagesForConversation] should break on detecting a circular dependency\n',
+ mappedResult,
+ );
+ expect(mappedResult).toEqual(['101', '100']);
+ });
+
+ // Testing with mockMessagesComplex
+ test('should correctly find the conversation path including root messages', async () => {
+ const result = BaseClient.getMessagesForConversation({
+ messages: mockMessagesComplex,
+ parentMessageId: '2',
+ });
+ const mappedResult = result.map((msg) => msg.messageId);
+ console.debug(
+ '[getMessagesForConversation] should correctly find the conversation path including root messages\n',
+ mappedResult,
+ );
+ expect(new Set(mappedResult)).toEqual(new Set(['7', '5', '2']));
+ });
+
+ // Testing summary feature
+ test('should stop at summary if option is enabled', async () => {
+ const messagesWithSummary = [
+ ...mockMessagesComplex,
+ { messageId: '11', parentMessageId: '7', text: 'Message 11', summary: 'Summary for 11' },
+ ];
+
+ const result = BaseClient.getMessagesForConversation({
+ messages: messagesWithSummary,
+ parentMessageId: '11',
+ summary: true,
+ });
+ const mappedResult = result.map((msg) => msg.messageId);
+ console.debug(
+ '[getMessagesForConversation] should stop at summary if option is enabled\n',
+ mappedResult,
+ );
+ expect(mappedResult).toEqual(['11']); // Should include only the summarizing message
+ });
+
+ // Testing no parent condition
+ test('should return only the root message if no parent exists', async () => {
+ const result = BaseClient.getMessagesForConversation({
+ messages: mockMessagesComplex,
+ parentMessageId: '8',
+ });
+ const mappedResult = result.map((msg) => msg.messageId);
+ console.debug(
+ '[getMessagesForConversation] should return only the root message if no parent exists\n',
+ mappedResult,
+ );
+ expect(mappedResult).toEqual(['8']); // The message with no parent in the thread
+ });
+});
+
+describe('splitAtTargetLevel', () => {
+ /* const mockMessagesComplex = [
+ { messageId: '7', parentMessageId: Constants.NO_PARENT, text: 'Message 7' },
+ { messageId: '8', parentMessageId: Constants.NO_PARENT, text: 'Message 8' },
+ { messageId: '5', parentMessageId: '7', text: 'Message 5' },
+ { messageId: '6', parentMessageId: '7', text: 'Message 6' },
+ { messageId: '9', parentMessageId: '8', text: 'Message 9' },
+ { messageId: '2', parentMessageId: '5', text: 'Message 2' },
+ { messageId: '3', parentMessageId: '5', text: 'Message 3' },
+ { messageId: '1', parentMessageId: '6', text: 'Message 1' },
+ { messageId: '4', parentMessageId: '6', text: 'Message 4' },
+ { messageId: '10', parentMessageId: '3', text: 'Message 10' },
+ ];
+
+ mockMessages
+ ├── [7]: Root
+ | ├── [5]: Child of 7
+ | | ├── [2]: Child of 5
+ | | └── [3]: Child of 5
+ | | └── [10]: Child of 3
+ | └── [6]: Child of 7
+ | ├── [1]: Child of 6
+ | └── [4]: Child of 6
+ └── [8]: Root
+ └── [9]: Child of 8
+ */
+ test('should include target message level and all descendants (1/2)', () => {
+ console.debug('splitAtTargetLevel: mockMessages\n', printMessageTree(mockMessagesComplex));
+ const result = splitAtTargetLevel(mockMessagesComplex, '2');
+ const mappedResult = result.map((msg) => msg.messageId);
+ console.debug(
+ 'splitAtTargetLevel: include target message level and all descendants (1/2)\n',
+ printMessageTree(result),
+ );
+ expect(mappedResult).toEqual(['2', '3', '1', '4', '10']);
+ });
+
+ test('should include target message level and all descendants (2/2)', () => {
+ console.debug('splitAtTargetLevel: mockMessages\n', printMessageTree(mockMessagesComplex));
+ const result = splitAtTargetLevel(mockMessagesComplex, '5');
+ const mappedResult = result.map((msg) => msg.messageId);
+ console.debug(
+ 'splitAtTargetLevel: include target message level and all descendants (2/2)\n',
+ printMessageTree(result),
+ );
+ expect(mappedResult).toEqual(['5', '6', '9', '2', '3', '1', '4', '10']);
+ });
+
+ test('should handle when target message is root', () => {
+ const result = splitAtTargetLevel(mockMessagesComplex, '7');
+ console.debug('splitAtTargetLevel: target level is root message\n', printMessageTree(result));
+ expect(result.length).toBe(mockMessagesComplex.length);
+ });
+
+ test('should handle when target message is deepest, lonely child', () => {
+ const result = splitAtTargetLevel(mockMessagesComplex, '10');
+ const mappedResult = result.map((msg) => msg.messageId);
+ console.debug(
+ 'splitAtTargetLevel: target message is deepest, lonely child\n',
+ printMessageTree(result),
+ );
+ expect(mappedResult).toEqual(['10']);
+ });
+
+ test('should handle when target level is last with many neighbors', () => {
+ const mockMessages = [
+ ...mockMessagesComplex,
+ { messageId: '11', parentMessageId: '10', text: 'Message 11' },
+ { messageId: '12', parentMessageId: '10', text: 'Message 12' },
+ { messageId: '13', parentMessageId: '10', text: 'Message 13' },
+ { messageId: '14', parentMessageId: '10', text: 'Message 14' },
+ { messageId: '15', parentMessageId: '4', text: 'Message 15' },
+ { messageId: '16', parentMessageId: '15', text: 'Message 15' },
+ ];
+ const result = splitAtTargetLevel(mockMessages, '11');
+ const mappedResult = result.map((msg) => msg.messageId);
+ console.debug(
+ 'splitAtTargetLevel: should handle when target level is last with many neighbors\n',
+ printMessageTree(result),
+ );
+ expect(mappedResult).toEqual(['11', '12', '13', '14', '16']);
+ });
+
+ test('should handle non-existent target message', () => {
+ // Non-existent message ID
+ const result = splitAtTargetLevel(mockMessagesComplex, '99');
+ expect(result.length).toBe(0);
+ });
+});
+
+describe('cloneMessagesWithTimestamps', () => {
+ test('should maintain proper timestamp order between parent and child messages', () => {
+ // Create messages with out-of-order timestamps
+ const messagesToClone = [
+ {
+ messageId: 'parent',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Parent Message',
+ createdAt: '2023-01-01T00:02:00Z', // Later timestamp
+ },
+ {
+ messageId: 'child1',
+ parentMessageId: 'parent',
+ text: 'Child Message 1',
+ createdAt: '2023-01-01T00:01:00Z', // Earlier timestamp
+ },
+ {
+ messageId: 'child2',
+ parentMessageId: 'parent',
+ text: 'Child Message 2',
+ createdAt: '2023-01-01T00:03:00Z',
+ },
+ ];
+
+ const importBatchBuilder = createImportBatchBuilder('testUser');
+ importBatchBuilder.startConversation();
+
+ cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
+
+ // Verify timestamps are properly ordered
+ const clonedMessages = importBatchBuilder.messages;
+ expect(clonedMessages.length).toBe(3);
+
+ // Find cloned messages (they'll have new IDs)
+ const parent = clonedMessages.find((msg) => msg.parentMessageId === Constants.NO_PARENT);
+ const children = clonedMessages.filter((msg) => msg.parentMessageId === parent.messageId);
+
+ // Verify parent timestamp is earlier than all children
+ children.forEach((child) => {
+ expect(new Date(child.createdAt).getTime()).toBeGreaterThan(
+ new Date(parent.createdAt).getTime(),
+ );
+ });
+ });
+
+ test('should handle multi-level message chains', () => {
+ const messagesToClone = [
+ {
+ messageId: 'root',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Root',
+ createdAt: '2023-01-01T00:03:00Z', // Latest
+ },
+ {
+ messageId: 'parent',
+ parentMessageId: 'root',
+ text: 'Parent',
+ createdAt: '2023-01-01T00:01:00Z', // Earliest
+ },
+ {
+ messageId: 'child',
+ parentMessageId: 'parent',
+ text: 'Child',
+ createdAt: '2023-01-01T00:02:00Z', // Middle
+ },
+ ];
+
+ const importBatchBuilder = createImportBatchBuilder('testUser');
+ importBatchBuilder.startConversation();
+
+ cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
+
+ const clonedMessages = importBatchBuilder.messages;
+ expect(clonedMessages.length).toBe(3);
+
+ // Verify the chain of timestamps
+ const root = clonedMessages.find((msg) => msg.parentMessageId === Constants.NO_PARENT);
+ const parent = clonedMessages.find((msg) => msg.parentMessageId === root.messageId);
+ const child = clonedMessages.find((msg) => msg.parentMessageId === parent.messageId);
+
+ expect(new Date(parent.createdAt).getTime()).toBeGreaterThan(
+ new Date(root.createdAt).getTime(),
+ );
+ expect(new Date(child.createdAt).getTime()).toBeGreaterThan(
+ new Date(parent.createdAt).getTime(),
+ );
+ });
+
+ test('should handle messages with identical timestamps', () => {
+ const sameTimestamp = '2023-01-01T00:00:00Z';
+ const messagesToClone = [
+ {
+ messageId: 'parent',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Parent',
+ createdAt: sameTimestamp,
+ },
+ {
+ messageId: 'child',
+ parentMessageId: 'parent',
+ text: 'Child',
+ createdAt: sameTimestamp,
+ },
+ ];
+
+ const importBatchBuilder = createImportBatchBuilder('testUser');
+ importBatchBuilder.startConversation();
+
+ cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
+
+ const clonedMessages = importBatchBuilder.messages;
+ const parent = clonedMessages.find((msg) => msg.parentMessageId === Constants.NO_PARENT);
+ const child = clonedMessages.find((msg) => msg.parentMessageId === parent.messageId);
+
+ expect(new Date(child.createdAt).getTime()).toBeGreaterThan(
+ new Date(parent.createdAt).getTime(),
+ );
+ });
+
+ test('should preserve original timestamps when already properly ordered', () => {
+ const messagesToClone = [
+ {
+ messageId: 'parent',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Parent',
+ createdAt: '2023-01-01T00:00:00Z',
+ },
+ {
+ messageId: 'child',
+ parentMessageId: 'parent',
+ text: 'Child',
+ createdAt: '2023-01-01T00:01:00Z',
+ },
+ ];
+
+ const importBatchBuilder = createImportBatchBuilder('testUser');
+ importBatchBuilder.startConversation();
+
+ cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
+
+ const clonedMessages = importBatchBuilder.messages;
+ const parent = clonedMessages.find((msg) => msg.parentMessageId === Constants.NO_PARENT);
+ const child = clonedMessages.find((msg) => msg.parentMessageId === parent.messageId);
+
+ expect(parent.createdAt).toEqual(new Date(messagesToClone[0].createdAt));
+ expect(child.createdAt).toEqual(new Date(messagesToClone[1].createdAt));
+ });
+
+ test('should handle complex multi-branch scenario with out-of-order timestamps', () => {
+ const complexMessages = [
+ // Branch 1: Root -> A -> (B, C) -> D
+ {
+ messageId: 'root1',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Root 1',
+ createdAt: '2023-01-01T00:05:00Z', // Root is later than children
+ },
+ {
+ messageId: 'A1',
+ parentMessageId: 'root1',
+ text: 'A1',
+ createdAt: '2023-01-01T00:02:00Z',
+ },
+ {
+ messageId: 'B1',
+ parentMessageId: 'A1',
+ text: 'B1',
+ createdAt: '2023-01-01T00:01:00Z', // Earlier than parent
+ },
+ {
+ messageId: 'C1',
+ parentMessageId: 'A1',
+ text: 'C1',
+ createdAt: '2023-01-01T00:03:00Z',
+ },
+ {
+ messageId: 'D1',
+ parentMessageId: 'B1',
+ text: 'D1',
+ createdAt: '2023-01-01T00:04:00Z',
+ },
+
+ // Branch 2: Root -> (X, Y, Z) where Z has children but X is latest
+ {
+ messageId: 'root2',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Root 2',
+ createdAt: '2023-01-01T00:06:00Z',
+ },
+ {
+ messageId: 'X2',
+ parentMessageId: 'root2',
+ text: 'X2',
+ createdAt: '2023-01-01T00:09:00Z', // Latest of siblings
+ },
+ {
+ messageId: 'Y2',
+ parentMessageId: 'root2',
+ text: 'Y2',
+ createdAt: '2023-01-01T00:07:00Z',
+ },
+ {
+ messageId: 'Z2',
+ parentMessageId: 'root2',
+ text: 'Z2',
+ createdAt: '2023-01-01T00:08:00Z',
+ },
+ {
+ messageId: 'Z2Child',
+ parentMessageId: 'Z2',
+ text: 'Z2 Child',
+ createdAt: '2023-01-01T00:04:00Z', // Earlier than all parents
+ },
+
+ // Branch 3: Root with alternating early/late timestamps
+ {
+ messageId: 'root3',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Root 3',
+ createdAt: '2023-01-01T00:15:00Z', // Latest of all
+ },
+ {
+ messageId: 'E3',
+ parentMessageId: 'root3',
+ text: 'E3',
+ createdAt: '2023-01-01T00:10:00Z',
+ },
+ {
+ messageId: 'F3',
+ parentMessageId: 'E3',
+ text: 'F3',
+ createdAt: '2023-01-01T00:14:00Z', // Later than parent
+ },
+ {
+ messageId: 'G3',
+ parentMessageId: 'F3',
+ text: 'G3',
+ createdAt: '2023-01-01T00:11:00Z', // Earlier than parent
+ },
+ {
+ messageId: 'H3',
+ parentMessageId: 'G3',
+ text: 'H3',
+ createdAt: '2023-01-01T00:13:00Z',
+ },
+ ];
+
+ const importBatchBuilder = createImportBatchBuilder('testUser');
+ importBatchBuilder.startConversation();
+
+ cloneMessagesWithTimestamps(complexMessages, importBatchBuilder);
+
+ const clonedMessages = importBatchBuilder.messages;
+ console.debug(
+ 'Complex multi-branch scenario\nOriginal messages:\n',
+ printMessageTree(complexMessages),
+ );
+ console.debug('Cloned messages:\n', printMessageTree(clonedMessages));
+
+ // Helper function to verify timestamp order
+ const verifyTimestampOrder = (parentId, messages) => {
+ const parent = messages.find((msg) => msg.messageId === parentId);
+ const children = messages.filter((msg) => msg.parentMessageId === parentId);
+
+ children.forEach((child) => {
+ const parentTime = new Date(parent.createdAt).getTime();
+ const childTime = new Date(child.createdAt).getTime();
+ expect(childTime).toBeGreaterThan(parentTime);
+ // Recursively verify child's children
+ verifyTimestampOrder(child.messageId, messages);
+ });
+ };
+
+ // Verify each branch
+ const roots = clonedMessages.filter((msg) => msg.parentMessageId === Constants.NO_PARENT);
+ roots.forEach((root) => verifyTimestampOrder(root.messageId, clonedMessages));
+
+ // Additional specific checks
+ const getMessageByText = (text) => clonedMessages.find((msg) => msg.text === text);
+
+ // Branch 1 checks
+ const root1 = getMessageByText('Root 1');
+ const b1 = getMessageByText('B1');
+ const d1 = getMessageByText('D1');
+ expect(new Date(b1.createdAt).getTime()).toBeGreaterThan(new Date(root1.createdAt).getTime());
+ expect(new Date(d1.createdAt).getTime()).toBeGreaterThan(new Date(b1.createdAt).getTime());
+
+ // Branch 2 checks
+ const root2 = getMessageByText('Root 2');
+ const x2 = getMessageByText('X2');
+ const z2Child = getMessageByText('Z2 Child');
+ const z2 = getMessageByText('Z2');
+ expect(new Date(x2.createdAt).getTime()).toBeGreaterThan(new Date(root2.createdAt).getTime());
+ expect(new Date(z2Child.createdAt).getTime()).toBeGreaterThan(new Date(z2.createdAt).getTime());
+
+ // Branch 3 checks
+ const f3 = getMessageByText('F3');
+ const g3 = getMessageByText('G3');
+ expect(new Date(g3.createdAt).getTime()).toBeGreaterThan(new Date(f3.createdAt).getTime());
+
+ // Verify all messages are present
+ expect(clonedMessages.length).toBe(complexMessages.length);
+ });
+});
diff --git a/api/server/utils/import/importBatchBuilder.js b/api/server/utils/import/importBatchBuilder.js
new file mode 100644
index 0000000..b185673
--- /dev/null
+++ b/api/server/utils/import/importBatchBuilder.js
@@ -0,0 +1,209 @@
+const { v4: uuidv4 } = require('uuid');
+const {
+ logger,
+ createFallbackRetentionDate,
+ createTempChatExpirationDate,
+} = require('@librechat/data-schemas');
+const {
+ EModelEndpoint,
+ Constants,
+ RetentionMode,
+ openAISettings,
+} = require('librechat-data-provider');
+const { bulkIncrementTagCounts, bulkSaveConvos, bulkSaveMessages } = require('~/models');
+const { FALLBACK_MODEL_BY_ENDPOINT } = require('./defaults');
+
+/**
+ * Factory function for creating an instance of ImportBatchBuilder.
+ * @param {string} requestUserId - The ID of the user making the request.
+ * @param {object} [interfaceConfig] - Runtime interface config for import retention.
+ * @returns {ImportBatchBuilder} - The newly created ImportBatchBuilder instance.
+ */
+function createImportBatchBuilder(requestUserId, interfaceConfig) {
+ return new ImportBatchBuilder(requestUserId, interfaceConfig);
+}
+
+/**
+ * Class for building a batch of conversations and messages and pushing them to DB for Conversation Import functionality
+ */
+class ImportBatchBuilder {
+ /**
+ * Creates an instance of ImportBatchBuilder.
+ * @param {string} requestUserId - The ID of the user making the import request.
+ * @param {object} [interfaceConfig] - Runtime interface config for import retention.
+ */
+ constructor(requestUserId, interfaceConfig) {
+ this.requestUserId = requestUserId;
+ this.interfaceConfig = interfaceConfig;
+ this.conversations = [];
+ this.messages = [];
+ this.retentionFields = undefined;
+ }
+
+ getRetentionFields() {
+ if (this.retentionFields !== undefined) {
+ return this.retentionFields;
+ }
+
+ if (this.interfaceConfig?.retentionMode !== RetentionMode.ALL) {
+ this.retentionFields = {};
+ return this.retentionFields;
+ }
+
+ try {
+ this.retentionFields = {
+ isTemporary: false,
+ expiredAt: createTempChatExpirationDate(this.interfaceConfig),
+ };
+ } catch (error) {
+ logger.error('[ImportBatchBuilder] Error creating import expiration date:', error);
+ this.retentionFields = { isTemporary: false, expiredAt: createFallbackRetentionDate() };
+ }
+ return this.retentionFields;
+ }
+
+ /**
+ * Starts a new conversation in the batch.
+ * @param {string} [endpoint=EModelEndpoint.openAI] - The endpoint for the conversation. Defaults to EModelEndpoint.openAI.
+ * @returns {void}
+ */
+ startConversation(endpoint) {
+ // we are simplifying by using a single model for the entire conversation
+ this.endpoint = endpoint || EModelEndpoint.openAI;
+ this.conversationId = uuidv4();
+ this.lastMessageId = Constants.NO_PARENT;
+ }
+
+ /**
+ * Adds a user message to the current conversation.
+ * @param {string} text - The text of the user message.
+ * @returns {object} The saved message object.
+ */
+ addUserMessage(text) {
+ const message = this.saveMessage({ text, sender: 'user', isCreatedByUser: true });
+ return message;
+ }
+
+ /**
+ * Adds a GPT message to the current conversation.
+ * @param {string} text - The text of the GPT message.
+ * @param {string} [model='defaultModel'] - The model used for generating the GPT message. Defaults to 'defaultModel'.
+ * @param {string} [sender='GPT-3.5'] - The sender of the GPT message. Defaults to 'GPT-3.5'.
+ * @returns {object} The saved message object.
+ */
+ addGptMessage(text, model, sender = 'GPT-3.5') {
+ const message = this.saveMessage({
+ text,
+ sender,
+ isCreatedByUser: false,
+ model: model || openAISettings.model.default,
+ });
+ return message;
+ }
+
+ /**
+ * Finishes the current conversation and adds it to the batch.
+ * @param {string} [title='Imported Chat'] - The title of the conversation. Defaults to 'Imported Chat'.
+ * @param {Date} [createdAt] - The creation date of the conversation.
+ * @param {TConversation} [originalConvo] - The original conversation.
+ * @param {string} [defaultModel] - Resolved default model for this endpoint
+ * (typically derived from the runtime models config). Used only when
+ * originalConvo.model is unset.
+ * @returns {{ conversation: TConversation, messages: TMessage[] }} The resulting conversation and messages.
+ */
+ finishConversation(title, createdAt, originalConvo = {}, defaultModel) {
+ const fallbackModel =
+ defaultModel ?? FALLBACK_MODEL_BY_ENDPOINT[this.endpoint] ?? openAISettings.model.default;
+ const convo = {
+ ...originalConvo,
+ user: this.requestUserId,
+ conversationId: this.conversationId,
+ title: title || 'Imported Chat',
+ createdAt: createdAt,
+ updatedAt: createdAt,
+ overrideTimestamp: true,
+ endpoint: this.endpoint,
+ model: originalConvo.model ?? fallbackModel,
+ ...this.getRetentionFields(),
+ };
+ convo._id && delete convo._id;
+ this.conversations.push(convo);
+
+ return { conversation: convo, messages: this.messages };
+ }
+
+ /**
+ * Saves the batch of conversations and messages to the DB.
+ * Also increments tag counts for any existing tags.
+ * @returns {Promise} A promise that resolves when the batch is saved.
+ * @throws {Error} If there is an error saving the batch.
+ */
+ async saveBatch() {
+ try {
+ const promises = [];
+ promises.push(bulkSaveConvos(this.conversations));
+ promises.push(bulkSaveMessages(this.messages, true));
+ promises.push(
+ bulkIncrementTagCounts(
+ this.requestUserId,
+ this.conversations.flatMap((convo) => convo.tags),
+ ),
+ );
+ await Promise.all(promises);
+ logger.debug(
+ `user: ${this.requestUserId} | Added ${this.conversations.length} conversations and ${this.messages.length} messages to the DB.`,
+ );
+ } catch (error) {
+ logger.error('Error saving batch', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Saves a message to the current conversation.
+ * @param {object} messageDetails - The details of the message.
+ * @param {string} messageDetails.text - The text of the message.
+ * @param {string} messageDetails.sender - The sender of the message.
+ * @param {string} [messageDetails.messageId] - The ID of the current message.
+ * @param {boolean} messageDetails.isCreatedByUser - Indicates whether the message is created by the user.
+ * @param {string} [messageDetails.model] - The model used for generating the message.
+ * @param {string} [messageDetails.endpoint] - The endpoint used for generating the message.
+ * @param {string} [messageDetails.parentMessageId=this.lastMessageId] - The ID of the parent message.
+ * @param {Partial} messageDetails.rest - Additional properties that may be included in the message.
+ * @returns {object} The saved message object.
+ */
+ saveMessage({
+ text,
+ sender,
+ isCreatedByUser,
+ model,
+ messageId,
+ parentMessageId = this.lastMessageId,
+ endpoint,
+ ...rest
+ }) {
+ const newMessageId = messageId ?? uuidv4();
+ const message = {
+ ...rest,
+ parentMessageId,
+ messageId: newMessageId,
+ conversationId: this.conversationId,
+ isCreatedByUser: isCreatedByUser,
+ model: model || this.model,
+ user: this.requestUserId,
+ endpoint: endpoint ?? this.endpoint,
+ unfinished: false,
+ isEdited: false,
+ error: false,
+ sender,
+ text,
+ ...this.getRetentionFields(),
+ };
+ message._id && delete message._id;
+ this.lastMessageId = newMessageId;
+ this.messages.push(message);
+ return message;
+ }
+}
+
+module.exports = { ImportBatchBuilder, createImportBatchBuilder };
diff --git a/api/server/utils/import/importConversations.js b/api/server/utils/import/importConversations.js
new file mode 100644
index 0000000..21bba86
--- /dev/null
+++ b/api/server/utils/import/importConversations.js
@@ -0,0 +1,47 @@
+const fs = require('fs').promises;
+const { resolveImportMaxFileSize } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const { getImporter } = require('./importers');
+const { createImportBatchBuilder } = require('./importBatchBuilder');
+
+const maxFileSize = resolveImportMaxFileSize();
+
+/**
+ * Job definition for importing a conversation.
+ * @param {{ filepath: string, requestUserId: string, userRole?: string, interfaceConfig?: object }} job
+ */
+const importConversations = async (job) => {
+ const { filepath, requestUserId, userRole, interfaceConfig } = job;
+ try {
+ logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`);
+
+ const fileInfo = await fs.stat(filepath);
+ if (fileInfo.size > maxFileSize) {
+ throw new Error(
+ `File size is ${fileInfo.size} bytes. It exceeds the maximum limit of ${maxFileSize} bytes.`,
+ );
+ }
+
+ const fileData = await fs.readFile(filepath, 'utf8');
+ const jsonData = JSON.parse(fileData);
+ const importer = getImporter(jsonData);
+ await importer(
+ jsonData,
+ requestUserId,
+ (userId) => createImportBatchBuilder(userId, interfaceConfig),
+ userRole,
+ );
+ logger.debug(`user: ${requestUserId} | Finished importing conversations`);
+ } catch (error) {
+ logger.error(`user: ${requestUserId} | Failed to import conversation: `, error);
+ throw error; // throw error all the way up so request does not return success
+ } finally {
+ try {
+ await fs.unlink(filepath);
+ } catch (error) {
+ logger.error(`user: ${requestUserId} | Failed to delete file: ${filepath}`, error);
+ }
+ }
+};
+
+module.exports = importConversations;
diff --git a/api/server/utils/import/importers-timestamp.spec.js b/api/server/utils/import/importers-timestamp.spec.js
new file mode 100644
index 0000000..268cc74
--- /dev/null
+++ b/api/server/utils/import/importers-timestamp.spec.js
@@ -0,0 +1,533 @@
+const { logger } = require('@librechat/data-schemas');
+const { Constants } = require('librechat-data-provider');
+const { ImportBatchBuilder } = require('./importBatchBuilder');
+const { getImporter } = require('./importers');
+
+// Mock the database methods
+jest.mock('~/models', () => ({
+ bulkSaveConvos: jest.fn(),
+ bulkSaveMessages: jest.fn(),
+ bulkIncrementTagCounts: jest.fn(),
+}));
+
+const mockGetEndpointsConfig = jest.fn().mockResolvedValue(null);
+jest.mock('~/server/services/Config', () => ({
+ getEndpointsConfig: (...args) => mockGetEndpointsConfig(...args),
+}));
+
+describe('Import Timestamp Ordering', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetEndpointsConfig.mockResolvedValue(null);
+ });
+
+ describe('LibreChat Import - Timestamp Issues', () => {
+ test('should maintain proper timestamp order between parent and child messages', async () => {
+ // Create a LibreChat export with out-of-order timestamps
+ const jsonData = {
+ conversationId: 'test-convo-123',
+ title: 'Test Conversation',
+ messages: [
+ {
+ messageId: 'parent-1',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Parent Message',
+ sender: 'user',
+ isCreatedByUser: true,
+ createdAt: '2023-01-01T00:02:00Z', // Parent created AFTER child
+ },
+ {
+ messageId: 'child-1',
+ parentMessageId: 'parent-1',
+ text: 'Child Message',
+ sender: 'assistant',
+ isCreatedByUser: false,
+ createdAt: '2023-01-01T00:01:00Z', // Child created BEFORE parent
+ },
+ {
+ messageId: 'grandchild-1',
+ parentMessageId: 'child-1',
+ text: 'Grandchild Message',
+ sender: 'user',
+ isCreatedByUser: true,
+ createdAt: '2023-01-01T00:00:30Z', // Even earlier
+ },
+ ],
+ };
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ // Check the actual messages stored in the builder
+ const savedMessages = importBatchBuilder.messages;
+
+ const parent = savedMessages.find((msg) => msg.text === 'Parent Message');
+ const child = savedMessages.find((msg) => msg.text === 'Child Message');
+ const grandchild = savedMessages.find((msg) => msg.text === 'Grandchild Message');
+
+ // Verify all messages were found
+ expect(parent).toBeDefined();
+ expect(child).toBeDefined();
+ expect(grandchild).toBeDefined();
+
+ // FIXED behavior: timestamps ARE corrected
+ expect(new Date(child.createdAt).getTime()).toBeGreaterThan(
+ new Date(parent.createdAt).getTime(),
+ );
+ expect(new Date(grandchild.createdAt).getTime()).toBeGreaterThan(
+ new Date(child.createdAt).getTime(),
+ );
+ });
+
+ test('should handle complex multi-branch scenario with out-of-order timestamps', async () => {
+ const jsonData = {
+ conversationId: 'complex-test-123',
+ title: 'Complex Test',
+ messages: [
+ // Branch 1: Root -> A -> B with reversed timestamps
+ {
+ messageId: 'root-1',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Root 1',
+ sender: 'user',
+ isCreatedByUser: true,
+ createdAt: '2023-01-01T00:03:00Z',
+ },
+ {
+ messageId: 'a-1',
+ parentMessageId: 'root-1',
+ text: 'A1',
+ sender: 'assistant',
+ isCreatedByUser: false,
+ createdAt: '2023-01-01T00:02:00Z', // Before parent
+ },
+ {
+ messageId: 'b-1',
+ parentMessageId: 'a-1',
+ text: 'B1',
+ sender: 'user',
+ isCreatedByUser: true,
+ createdAt: '2023-01-01T00:01:00Z', // Before grandparent
+ },
+ // Branch 2: Root -> C -> D with mixed timestamps
+ {
+ messageId: 'root-2',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Root 2',
+ sender: 'user',
+ isCreatedByUser: true,
+ createdAt: '2023-01-01T00:00:30Z', // Earlier than branch 1
+ },
+ {
+ messageId: 'c-2',
+ parentMessageId: 'root-2',
+ text: 'C2',
+ sender: 'assistant',
+ isCreatedByUser: false,
+ createdAt: '2023-01-01T00:04:00Z', // Much later
+ },
+ {
+ messageId: 'd-2',
+ parentMessageId: 'c-2',
+ text: 'D2',
+ sender: 'user',
+ isCreatedByUser: true,
+ createdAt: '2023-01-01T00:02:30Z', // Between root and parent
+ },
+ ],
+ };
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ const savedMessages = importBatchBuilder.messages;
+
+ // Verify that timestamps are preserved as-is (not corrected)
+ const root1 = savedMessages.find((msg) => msg.text === 'Root 1');
+ const a1 = savedMessages.find((msg) => msg.text === 'A1');
+ const b1 = savedMessages.find((msg) => msg.text === 'B1');
+ const root2 = savedMessages.find((msg) => msg.text === 'Root 2');
+ const c2 = savedMessages.find((msg) => msg.text === 'C2');
+ const d2 = savedMessages.find((msg) => msg.text === 'D2');
+
+ // Branch 1: timestamps should now be in correct order
+ expect(new Date(a1.createdAt).getTime()).toBeGreaterThan(new Date(root1.createdAt).getTime());
+ expect(new Date(b1.createdAt).getTime()).toBeGreaterThan(new Date(a1.createdAt).getTime());
+
+ // Branch 2: all timestamps should be properly ordered
+ expect(new Date(c2.createdAt).getTime()).toBeGreaterThan(new Date(root2.createdAt).getTime());
+ expect(new Date(d2.createdAt).getTime()).toBeGreaterThan(new Date(c2.createdAt).getTime());
+ });
+
+ test('recursive format should NOW have timestamp protection', async () => {
+ // Create a recursive LibreChat export with out-of-order timestamps
+ const jsonData = {
+ conversationId: 'recursive-test-123',
+ title: 'Recursive Test',
+ recursive: true,
+ messages: [
+ {
+ messageId: 'parent-1',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Parent Message',
+ sender: 'User',
+ isCreatedByUser: true,
+ createdAt: '2023-01-01T00:02:00Z', // Parent created AFTER child
+ children: [
+ {
+ messageId: 'child-1',
+ parentMessageId: 'parent-1',
+ text: 'Child Message',
+ sender: 'Assistant',
+ isCreatedByUser: false,
+ createdAt: '2023-01-01T00:01:00Z', // Child created BEFORE parent
+ children: [
+ {
+ messageId: 'grandchild-1',
+ parentMessageId: 'child-1',
+ text: 'Grandchild Message',
+ sender: 'User',
+ isCreatedByUser: true,
+ createdAt: '2023-01-01T00:00:30Z', // Even earlier
+ children: [],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ const savedMessages = importBatchBuilder.messages;
+
+ // Messages should be saved
+ expect(savedMessages).toHaveLength(3);
+
+ // In recursive format, timestamps are NOT included in the saved messages
+ // The saveMessage method doesn't receive createdAt for recursive imports
+ const parent = savedMessages.find((msg) => msg.text === 'Parent Message');
+ const child = savedMessages.find((msg) => msg.text === 'Child Message');
+ const grandchild = savedMessages.find((msg) => msg.text === 'Grandchild Message');
+
+ expect(parent).toBeDefined();
+ expect(child).toBeDefined();
+ expect(grandchild).toBeDefined();
+
+ // Recursive imports NOW preserve and correct timestamps
+ expect(parent.createdAt).toBeDefined();
+ expect(child.createdAt).toBeDefined();
+ expect(grandchild.createdAt).toBeDefined();
+
+ // Timestamps should be corrected to maintain proper order
+ expect(new Date(child.createdAt).getTime()).toBeGreaterThan(
+ new Date(parent.createdAt).getTime(),
+ );
+ expect(new Date(grandchild.createdAt).getTime()).toBeGreaterThan(
+ new Date(child.createdAt).getTime(),
+ );
+ });
+ });
+
+ describe('ChatGPT Import - Timestamp Issues', () => {
+ test('should correct timestamp inversions (child before parent)', async () => {
+ // Simulate ChatGPT export with timestamp inversion (like tool call results)
+ const jsonData = [
+ {
+ title: 'Timestamp Inversion Test',
+ create_time: 1000,
+ mapping: {
+ 'root-node': {
+ id: 'root-node',
+ message: null,
+ parent: null,
+ children: ['parent-msg'],
+ },
+ 'parent-msg': {
+ id: 'parent-msg',
+ message: {
+ id: 'parent-msg',
+ author: { role: 'user' },
+ create_time: 1000.1, // Parent: 1000.1
+ content: { content_type: 'text', parts: ['Parent message'] },
+ metadata: {},
+ },
+ parent: 'root-node',
+ children: ['child-msg'],
+ },
+ 'child-msg': {
+ id: 'child-msg',
+ message: {
+ id: 'child-msg',
+ author: { role: 'assistant' },
+ create_time: 1000.095, // Child: 1000.095 (5ms BEFORE parent)
+ content: { content_type: 'text', parts: ['Child message'] },
+ metadata: {},
+ },
+ parent: 'parent-msg',
+ children: [],
+ },
+ },
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ const savedMessages = importBatchBuilder.messages;
+ const parent = savedMessages.find((msg) => msg.text === 'Parent message');
+ const child = savedMessages.find((msg) => msg.text === 'Child message');
+
+ expect(parent).toBeDefined();
+ expect(child).toBeDefined();
+
+ // Child timestamp should be adjusted to be after parent
+ expect(new Date(child.createdAt).getTime()).toBeGreaterThan(
+ new Date(parent.createdAt).getTime(),
+ );
+ });
+
+ test('should use conv.create_time for null message timestamps', async () => {
+ const convCreateTime = 1500000000; // Conversation create time
+ const jsonData = [
+ {
+ title: 'Null Timestamp Test',
+ create_time: convCreateTime,
+ mapping: {
+ 'root-node': {
+ id: 'root-node',
+ message: null,
+ parent: null,
+ children: ['msg-with-null-time'],
+ },
+ 'msg-with-null-time': {
+ id: 'msg-with-null-time',
+ message: {
+ id: 'msg-with-null-time',
+ author: { role: 'user' },
+ create_time: null, // Null timestamp
+ content: { content_type: 'text', parts: ['Message with null time'] },
+ metadata: {},
+ },
+ parent: 'root-node',
+ children: ['msg-with-valid-time'],
+ },
+ 'msg-with-valid-time': {
+ id: 'msg-with-valid-time',
+ message: {
+ id: 'msg-with-valid-time',
+ author: { role: 'assistant' },
+ create_time: convCreateTime + 10, // Valid timestamp
+ content: { content_type: 'text', parts: ['Message with valid time'] },
+ metadata: {},
+ },
+ parent: 'msg-with-null-time',
+ children: [],
+ },
+ },
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ const savedMessages = importBatchBuilder.messages;
+ const nullTimeMsg = savedMessages.find((msg) => msg.text === 'Message with null time');
+ const validTimeMsg = savedMessages.find((msg) => msg.text === 'Message with valid time');
+
+ expect(nullTimeMsg).toBeDefined();
+ expect(validTimeMsg).toBeDefined();
+
+ // Null timestamp should fall back to conv.create_time
+ expect(nullTimeMsg.createdAt).toEqual(new Date(convCreateTime * 1000));
+
+ // Child should still be after parent (timestamp adjustment)
+ expect(new Date(validTimeMsg.createdAt).getTime()).toBeGreaterThan(
+ new Date(nullTimeMsg.createdAt).getTime(),
+ );
+ });
+
+ test('should terminate on cyclic parent relationships and break cycles before saving', async () => {
+ const warnSpy = jest.spyOn(logger, 'warn');
+ const jsonData = [
+ {
+ title: 'Cycle Test',
+ create_time: 1700000000,
+ mapping: {
+ 'root-node': {
+ id: 'root-node',
+ message: null,
+ parent: null,
+ children: ['message-a'],
+ },
+ 'message-a': {
+ id: 'message-a',
+ message: {
+ id: 'message-a',
+ author: { role: 'user' },
+ create_time: 1700000000,
+ content: { content_type: 'text', parts: ['Message A'] },
+ metadata: {},
+ },
+ parent: 'message-b',
+ children: ['message-b'],
+ },
+ 'message-b': {
+ id: 'message-b',
+ message: {
+ id: 'message-b',
+ author: { role: 'assistant' },
+ create_time: 1700000000,
+ content: { content_type: 'text', parts: ['Message B'] },
+ metadata: {},
+ },
+ parent: 'message-a',
+ children: ['message-a'],
+ },
+ },
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ const { messages } = importBatchBuilder;
+ expect(messages).toHaveLength(2);
+
+ const msgA = messages.find((m) => m.text === 'Message A');
+ const msgB = messages.find((m) => m.text === 'Message B');
+ expect(msgA).toBeDefined();
+ expect(msgB).toBeDefined();
+
+ const roots = messages.filter((m) => m.parentMessageId === Constants.NO_PARENT);
+ expect(roots).toHaveLength(1);
+
+ const [root] = roots;
+ const nonRoot = messages.find((m) => m.parentMessageId !== Constants.NO_PARENT);
+ expect(nonRoot.parentMessageId).toBe(root.messageId);
+
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('cyclic parent relationships'));
+ warnSpy.mockRestore();
+ });
+
+ test('should not hang when findValidParent encounters a skippable-message cycle', async () => {
+ const jsonData = [
+ {
+ title: 'Skippable Cycle Test',
+ create_time: 1700000000,
+ mapping: {
+ 'root-node': {
+ id: 'root-node',
+ message: null,
+ parent: null,
+ children: ['real-msg'],
+ },
+ 'sys-a': {
+ id: 'sys-a',
+ message: {
+ id: 'sys-a',
+ author: { role: 'system' },
+ create_time: 1700000000,
+ content: { content_type: 'text', parts: ['system a'] },
+ metadata: {},
+ },
+ parent: 'sys-b',
+ children: ['real-msg'],
+ },
+ 'sys-b': {
+ id: 'sys-b',
+ message: {
+ id: 'sys-b',
+ author: { role: 'system' },
+ create_time: 1700000000,
+ content: { content_type: 'text', parts: ['system b'] },
+ metadata: {},
+ },
+ parent: 'sys-a',
+ children: [],
+ },
+ 'real-msg': {
+ id: 'real-msg',
+ message: {
+ id: 'real-msg',
+ author: { role: 'user' },
+ create_time: 1700000001,
+ content: { content_type: 'text', parts: ['Hello'] },
+ metadata: {},
+ },
+ parent: 'sys-a',
+ children: [],
+ },
+ },
+ },
+ ];
+
+ const importBatchBuilder = new ImportBatchBuilder('user-123');
+ const importer = getImporter(jsonData);
+ await importer(jsonData, 'user-123', () => importBatchBuilder);
+
+ const realMsg = importBatchBuilder.messages.find((m) => m.text === 'Hello');
+ expect(realMsg).toBeDefined();
+ expect(realMsg.parentMessageId).toBe(Constants.NO_PARENT);
+ });
+ });
+
+ describe('Comparison with Fork Functionality', () => {
+ test('fork functionality correctly handles timestamp issues (for comparison)', async () => {
+ const { cloneMessagesWithTimestamps } = require('./fork');
+
+ const messagesToClone = [
+ {
+ messageId: 'parent',
+ parentMessageId: Constants.NO_PARENT,
+ text: 'Parent Message',
+ createdAt: '2023-01-01T00:02:00Z', // Parent created AFTER child
+ },
+ {
+ messageId: 'child',
+ parentMessageId: 'parent',
+ text: 'Child Message',
+ createdAt: '2023-01-01T00:01:00Z', // Child created BEFORE parent
+ },
+ ];
+
+ const importBatchBuilder = new ImportBatchBuilder('user-123');
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
+
+ const savedMessages = importBatchBuilder.messages;
+ const parent = savedMessages.find((msg) => msg.text === 'Parent Message');
+ const child = savedMessages.find((msg) => msg.text === 'Child Message');
+
+ // Fork functionality DOES correct the timestamps
+ expect(new Date(child.createdAt).getTime()).toBeGreaterThan(
+ new Date(parent.createdAt).getTime(),
+ );
+ });
+ });
+});
diff --git a/api/server/utils/import/importers.js b/api/server/utils/import/importers.js
new file mode 100644
index 0000000..435572d
--- /dev/null
+++ b/api/server/utils/import/importers.js
@@ -0,0 +1,699 @@
+const { v4: uuidv4 } = require('uuid');
+const { logger, getTenantId } = require('@librechat/data-schemas');
+const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider');
+const { getEndpointsConfig } = require('~/server/services/Config');
+const { createImportBatchBuilder } = require('./importBatchBuilder');
+const { resolveImportDefaultModel } = require('./defaults');
+const { cloneMessagesWithTimestamps } = require('./fork');
+
+/**
+ * Returns the appropriate importer function based on the provided JSON data.
+ *
+ * @param {Object} jsonData - The JSON data to import.
+ * @returns {Function} - The importer function.
+ * @throws {Error} - If the import type is not supported.
+ */
+function getImporter(jsonData) {
+ // For array-based formats (ChatGPT or Claude)
+ if (Array.isArray(jsonData)) {
+ // Claude format has chat_messages array in each conversation
+ if (jsonData.length > 0 && jsonData[0]?.chat_messages) {
+ logger.info('Importing Claude conversation');
+ return importClaudeConvo;
+ }
+ // ChatGPT format has mapping object in each conversation
+ if (jsonData.length === 0 || jsonData[0]?.mapping) {
+ logger.info('Importing ChatGPT conversation');
+ return importChatGptConvo;
+ }
+ throw new Error('Unsupported import type');
+ }
+
+ // For ChatbotUI
+ if (jsonData.version && Array.isArray(jsonData.history)) {
+ logger.info('Importing ChatbotUI conversation');
+ return importChatBotUiConvo;
+ }
+
+ // For LibreChat
+ if (jsonData.conversationId && (jsonData.messagesTree || jsonData.messages)) {
+ logger.info('Importing LibreChat conversation');
+ return importLibreChatConvo;
+ }
+
+ throw new Error('Unsupported import type');
+}
+
+/**
+ * Imports a chatbot-ui V1 conversation from a JSON file and saves it to the database.
+ *
+ * @param {Object} jsonData - The JSON data containing the chatbot conversation.
+ * @param {string} requestUserId - The ID of the user making the import request.
+ * @param {Function} [builderFactory=createImportBatchBuilder] - The factory function to create an import batch builder.
+ * @returns {Promise} - A promise that resolves when the import is complete.
+ * @throws {Error} - If there is an error creating the conversation from the JSON file.
+ */
+async function importChatBotUiConvo(
+ jsonData,
+ requestUserId,
+ builderFactory = createImportBatchBuilder,
+ userRole,
+) {
+ // this have been tested with chatbot-ui V1 export https://github.com/mckaywrigley/chatbot-ui/tree/b865b0555f53957e96727bc0bbb369c9eaecd83b#legacy-code
+ try {
+ /** @type {ImportBatchBuilder} */
+ const importBatchBuilder = builderFactory(requestUserId);
+ const defaultModel = await resolveImportDefaultModel({
+ endpoint: EModelEndpoint.openAI,
+ requestUserId,
+ userRole,
+ });
+
+ for (const historyItem of jsonData.history) {
+ importBatchBuilder.startConversation(EModelEndpoint.openAI);
+ for (const message of historyItem.messages) {
+ if (message.role === 'assistant') {
+ importBatchBuilder.addGptMessage(message.content, historyItem.model.id);
+ } else if (message.role === 'user') {
+ importBatchBuilder.addUserMessage(message.content);
+ }
+ }
+ importBatchBuilder.finishConversation(historyItem.name, new Date(), {}, defaultModel);
+ }
+ await importBatchBuilder.saveBatch();
+ logger.info(`user: ${requestUserId} | ChatbotUI conversation imported`);
+ } catch (error) {
+ logger.error(`user: ${requestUserId} | Error creating conversation from ChatbotUI file`, error);
+ throw error;
+ }
+}
+
+/**
+ * Extracts text and thinking content from a Claude message.
+ * @param {Object} msg - Claude message object with content array and optional text field.
+ * @returns {{textContent: string, thinkingContent: string}} Extracted text and thinking content.
+ */
+function extractClaudeContent(msg) {
+ let textContent = '';
+ let thinkingContent = '';
+
+ for (const part of msg.content || []) {
+ if (part.type === 'text' && part.text) {
+ textContent += part.text;
+ } else if (part.type === 'thinking' && part.thinking) {
+ thinkingContent += part.thinking;
+ }
+ }
+
+ // Use the text field as fallback if content array is empty
+ if (!textContent && msg.text) {
+ textContent = msg.text;
+ }
+
+ return { textContent, thinkingContent };
+}
+
+/**
+ * Imports Claude conversations from provided JSON data.
+ * Claude export format: array of conversations with chat_messages array.
+ *
+ * @param {Array} jsonData - Array of Claude conversation objects to be imported.
+ * @param {string} requestUserId - The ID of the user who initiated the import process.
+ * @param {Function} builderFactory - Factory function to create a new import batch builder instance.
+ * @returns {Promise} Promise that resolves when all conversations have been imported.
+ */
+async function importClaudeConvo(
+ jsonData,
+ requestUserId,
+ builderFactory = createImportBatchBuilder,
+ userRole,
+) {
+ try {
+ const importBatchBuilder = builderFactory(requestUserId);
+ const defaultModel = await resolveImportDefaultModel({
+ endpoint: EModelEndpoint.anthropic,
+ requestUserId,
+ userRole,
+ });
+
+ for (const conv of jsonData) {
+ importBatchBuilder.startConversation(EModelEndpoint.anthropic);
+
+ let lastMessageId = Constants.NO_PARENT;
+ let lastTimestamp = null;
+
+ for (const msg of conv.chat_messages || []) {
+ const isCreatedByUser = msg.sender === 'human';
+ const messageId = uuidv4();
+
+ const { textContent, thinkingContent } = extractClaudeContent(msg);
+
+ // Skip empty messages
+ if (!textContent && !thinkingContent) {
+ continue;
+ }
+
+ // Parse timestamp, fallback to conversation create_time or current time
+ const messageTime = msg.created_at || conv.created_at;
+ let createdAt = messageTime ? new Date(messageTime) : new Date();
+
+ // Ensure timestamp is after the previous message.
+ // Messages are sorted by createdAt and buildTree expects parents to appear before children.
+ // This guards against any potential ordering issues in exports.
+ if (lastTimestamp && createdAt <= lastTimestamp) {
+ createdAt = new Date(lastTimestamp.getTime() + 1);
+ }
+ lastTimestamp = createdAt;
+
+ const message = {
+ messageId,
+ parentMessageId: lastMessageId,
+ text: textContent,
+ sender: isCreatedByUser ? 'user' : 'Claude',
+ isCreatedByUser,
+ user: requestUserId,
+ endpoint: EModelEndpoint.anthropic,
+ createdAt,
+ };
+
+ // Add content array with thinking if present
+ if (thinkingContent && !isCreatedByUser) {
+ message.content = [
+ { type: 'think', think: thinkingContent },
+ { type: 'text', text: textContent },
+ ];
+ }
+
+ importBatchBuilder.saveMessage(message);
+ lastMessageId = messageId;
+ }
+
+ const createdAt = conv.created_at ? new Date(conv.created_at) : new Date();
+ importBatchBuilder.finishConversation(
+ conv.name || 'Imported Claude Chat',
+ createdAt,
+ {},
+ defaultModel,
+ );
+ }
+
+ await importBatchBuilder.saveBatch();
+ logger.info(`user: ${requestUserId} | Claude conversation imported`);
+ } catch (error) {
+ logger.error(`user: ${requestUserId} | Error creating conversation from Claude file`, error);
+ throw error;
+ }
+}
+
+/**
+ * Imports a LibreChat conversation from JSON.
+ *
+ * @param {Object} jsonData - The JSON data representing the conversation.
+ * @param {string} requestUserId - The ID of the user making the import request.
+ * @param {Function} [builderFactory=createImportBatchBuilder] - The factory function to create an import batch builder.
+ * @returns {Promise} - A promise that resolves when the import is complete.
+ */
+async function importLibreChatConvo(
+ jsonData,
+ requestUserId,
+ builderFactory = createImportBatchBuilder,
+ userRole,
+) {
+ try {
+ /** @type {ImportBatchBuilder} */
+ const importBatchBuilder = builderFactory(requestUserId);
+ const options = jsonData.options || {};
+
+ /* Endpoint configuration */
+ let endpoint = jsonData.endpoint ?? options.endpoint ?? EModelEndpoint.openAI;
+ const endpointsConfig = await getEndpointsConfig({
+ user: { id: requestUserId, role: userRole, tenantId: getTenantId() },
+ });
+ const endpointConfig = endpointsConfig?.[endpoint];
+ if (!endpointConfig && endpointsConfig) {
+ endpoint = Object.keys(endpointsConfig)[0];
+ } else if (!endpointConfig) {
+ endpoint = EModelEndpoint.openAI;
+ }
+
+ importBatchBuilder.startConversation(endpoint);
+
+ const defaultModel = await resolveImportDefaultModel({
+ endpoint,
+ requestUserId,
+ userRole,
+ });
+
+ let firstMessageDate = null;
+
+ const messagesToImport = jsonData.messagesTree || jsonData.messages;
+
+ if (jsonData.recursive) {
+ /**
+ * Flatten the recursive message tree into a flat array
+ * @param {TMessage[]} messages
+ * @param {string} parentMessageId
+ * @param {TMessage[]} flatMessages
+ */
+ const flattenMessages = (
+ messages,
+ parentMessageId = Constants.NO_PARENT,
+ flatMessages = [],
+ ) => {
+ for (const message of messages) {
+ if (!message.text && !message.content) {
+ continue;
+ }
+
+ const flatMessage = {
+ ...message,
+ parentMessageId: parentMessageId,
+ children: undefined, // Remove children from flat structure
+ };
+ flatMessages.push(flatMessage);
+
+ if (!firstMessageDate && message.createdAt) {
+ firstMessageDate = new Date(message.createdAt);
+ }
+
+ if (message.children && message.children.length > 0) {
+ flattenMessages(message.children, message.messageId, flatMessages);
+ }
+ }
+ return flatMessages;
+ };
+
+ const flatMessages = flattenMessages(messagesToImport);
+ cloneMessagesWithTimestamps(flatMessages, importBatchBuilder);
+ } else if (messagesToImport) {
+ cloneMessagesWithTimestamps(messagesToImport, importBatchBuilder);
+ for (const message of messagesToImport) {
+ if (!firstMessageDate && message.createdAt) {
+ firstMessageDate = new Date(message.createdAt);
+ }
+ }
+ } else {
+ throw new Error('Invalid LibreChat file format');
+ }
+
+ if (firstMessageDate === 'Invalid Date') {
+ firstMessageDate = null;
+ }
+
+ importBatchBuilder.finishConversation(
+ jsonData.title,
+ firstMessageDate ?? new Date(),
+ options,
+ defaultModel,
+ );
+ await importBatchBuilder.saveBatch();
+ logger.debug(`user: ${requestUserId} | Conversation "${jsonData.title}" imported`);
+ } catch (error) {
+ logger.error(`user: ${requestUserId} | Error creating conversation from LibreChat file`, error);
+ throw error;
+ }
+}
+
+/**
+ * Imports ChatGPT conversations from provided JSON data.
+ * Initializes the import process by creating a batch builder and processing each conversation in the data.
+ *
+ * @param {ChatGPTConvo[]} jsonData - Array of conversation objects to be imported.
+ * @param {string} requestUserId - The ID of the user who initiated the import process.
+ * @param {Function} builderFactory - Factory function to create a new import batch builder instance, defaults to createImportBatchBuilder.
+ * @returns {Promise} Promise that resolves when all conversations have been imported.
+ */
+async function importChatGptConvo(
+ jsonData,
+ requestUserId,
+ builderFactory = createImportBatchBuilder,
+ userRole,
+) {
+ try {
+ const importBatchBuilder = builderFactory(requestUserId);
+ const defaultModel = await resolveImportDefaultModel({
+ endpoint: EModelEndpoint.openAI,
+ requestUserId,
+ userRole,
+ });
+ for (const conv of jsonData) {
+ processConversation(conv, importBatchBuilder, requestUserId, defaultModel);
+ }
+ await importBatchBuilder.saveBatch();
+ } catch (error) {
+ logger.error(`user: ${requestUserId} | Error creating conversation from imported file`, error);
+ throw error;
+ }
+}
+
+/**
+ * Processes a single conversation, adding messages to the batch builder based on author roles and handling text content.
+ * It directly manages the addition of messages for different roles and handles citations for assistant messages.
+ *
+ * @param {ChatGPTConvo} conv - A single conversation object that contains multiple messages and other details.
+ * @param {ImportBatchBuilder} importBatchBuilder - The batch builder instance used to manage and batch conversation data.
+ * @param {string} requestUserId - The ID of the user who initiated the import process.
+ * @param {string} [defaultModel] - Resolved default model for the openAI endpoint.
+ * @returns {void}
+ */
+function processConversation(conv, importBatchBuilder, requestUserId, defaultModel) {
+ importBatchBuilder.startConversation(EModelEndpoint.openAI);
+
+ // Map all message IDs to new UUIDs
+ const messageMap = new Map();
+ for (const [id, mapping] of Object.entries(conv.mapping)) {
+ if (mapping.message?.content?.content_type) {
+ const newMessageId = uuidv4();
+ messageMap.set(id, newMessageId);
+ }
+ }
+
+ /**
+ * Finds the nearest valid parent by traversing up through skippable messages
+ * (system, reasoning_recap, thoughts). Uses iterative traversal to avoid
+ * stack overflow on deep chains of skippable messages.
+ *
+ * @param {string} startId - The ID of the starting parent message.
+ * @returns {string} The ID of the nearest valid parent message.
+ */
+ const findValidParent = (startId) => {
+ const visited = new Set();
+ let parentId = startId;
+
+ while (parentId) {
+ if (!messageMap.has(parentId) || visited.has(parentId)) {
+ return Constants.NO_PARENT;
+ }
+ visited.add(parentId);
+
+ const parentMapping = conv.mapping[parentId];
+ if (!parentMapping?.message) {
+ return Constants.NO_PARENT;
+ }
+
+ const contentType = parentMapping.message.content?.content_type;
+ const shouldSkip =
+ parentMapping.message.author?.role === 'system' ||
+ contentType === 'reasoning_recap' ||
+ contentType === 'thoughts';
+
+ if (!shouldSkip) {
+ return messageMap.get(parentId);
+ }
+
+ parentId = parentMapping.parent;
+ }
+
+ return Constants.NO_PARENT;
+ };
+
+ /**
+ * Helper function to find thinking content from parent chain (thoughts messages)
+ * @param {string} parentId - The ID of the parent message.
+ * @param {Set} visited - Set of already-visited IDs to prevent cycles.
+ * @returns {Array} The thinking content array (empty if not found).
+ */
+ const findThinkingContent = (parentId, visited = new Set()) => {
+ // Guard against circular references in malformed imports
+ if (!parentId || visited.has(parentId)) {
+ return [];
+ }
+ visited.add(parentId);
+
+ const parentMapping = conv.mapping[parentId];
+ if (!parentMapping?.message) {
+ return [];
+ }
+
+ const contentType = parentMapping.message.content?.content_type;
+
+ // If this is a thoughts message, extract the thinking content
+ if (contentType === 'thoughts') {
+ const thoughts = parentMapping.message.content.thoughts || [];
+ const thinkingText = thoughts
+ .map((t) => t.content || t.summary || '')
+ .filter(Boolean)
+ .join('\n\n');
+
+ if (thinkingText) {
+ return [{ type: 'think', think: thinkingText }];
+ }
+ return [];
+ }
+
+ // If this is reasoning_recap, look at its parent for thoughts
+ if (contentType === 'reasoning_recap') {
+ return findThinkingContent(parentMapping.parent, visited);
+ }
+
+ return [];
+ };
+
+ // Create and save messages using the mapped IDs
+ const messages = [];
+ for (const [id, mapping] of Object.entries(conv.mapping)) {
+ const role = mapping.message?.author?.role;
+ if (!mapping.message) {
+ messageMap.delete(id);
+ continue;
+ } else if (role === 'system') {
+ // Skip system messages but keep their ID in messageMap for parent references
+ continue;
+ }
+
+ const contentType = mapping.message.content?.content_type;
+
+ // Skip thoughts messages - they will be merged into the response message
+ if (contentType === 'thoughts') {
+ continue;
+ }
+
+ // Skip reasoning_recap messages (just summaries like "Thought for 44s")
+ if (contentType === 'reasoning_recap') {
+ continue;
+ }
+
+ const newMessageId = messageMap.get(id);
+ if (!newMessageId) {
+ continue;
+ }
+ const parentMessageId = findValidParent(mapping.parent);
+
+ const messageText = formatMessageText(mapping.message);
+
+ const isCreatedByUser = role === 'user';
+ let sender = isCreatedByUser ? 'user' : 'assistant';
+ const model =
+ mapping.message.metadata?.model_slug || defaultModel || openAISettings.model.default;
+
+ if (!isCreatedByUser) {
+ /** Extracted model name from model slug */
+ const gptMatch = model.match(/gpt-(.+)/i);
+ if (gptMatch) {
+ sender = `GPT-${gptMatch[1]}`;
+ } else {
+ sender = model || 'assistant';
+ }
+ }
+
+ // Use create_time from ChatGPT export to ensure proper message ordering
+ // For null timestamps, use the conversation's create_time as fallback, or current time as last resort
+ const messageTime = mapping.message.create_time || conv.create_time;
+ const createdAt = messageTime ? new Date(messageTime * 1000) : new Date();
+
+ const message = {
+ messageId: newMessageId,
+ parentMessageId,
+ text: messageText,
+ sender,
+ isCreatedByUser,
+ model,
+ user: requestUserId,
+ endpoint: EModelEndpoint.openAI,
+ createdAt,
+ };
+
+ // For assistant messages, check if there's thinking content in the parent chain
+ if (!isCreatedByUser) {
+ const thinkingContent = findThinkingContent(mapping.parent);
+ if (thinkingContent.length > 0) {
+ // Combine thinking content with the text response
+ message.content = [...thinkingContent, { type: 'text', text: messageText }];
+ }
+ }
+
+ messages.push(message);
+ }
+
+ const cycleDetected = adjustTimestampsForOrdering(messages);
+ if (cycleDetected) {
+ breakParentCycles(messages);
+ }
+
+ for (const message of messages) {
+ importBatchBuilder.saveMessage(message);
+ }
+
+ importBatchBuilder.finishConversation(
+ conv.title,
+ new Date(conv.create_time * 1000),
+ {},
+ defaultModel,
+ );
+}
+
+/**
+ * Processes text content of messages authored by an assistant, inserting citation links as required.
+ * Uses citation start and end indices to place links at the correct positions.
+ *
+ * @param {ChatGPTMessage} messageData - The message data containing metadata about citations.
+ * @param {string} messageText - The original text of the message which may be altered by inserting citation links.
+ * @returns {string} - The updated message text after processing for citations.
+ */
+function processAssistantMessage(messageData, messageText) {
+ if (!messageText) {
+ return messageText;
+ }
+
+ const citations = messageData.metadata?.citations ?? [];
+
+ const sortedCitations = [...citations].sort((a, b) => b.start_ix - a.start_ix);
+
+ let result = messageText;
+ for (const citation of sortedCitations) {
+ if (
+ !citation.metadata?.type ||
+ citation.metadata.type !== 'webpage' ||
+ typeof citation.start_ix !== 'number' ||
+ typeof citation.end_ix !== 'number' ||
+ citation.start_ix >= citation.end_ix
+ ) {
+ continue;
+ }
+
+ const replacement = ` ([${citation.metadata.title}](${citation.metadata.url}))`;
+
+ result = result.slice(0, citation.start_ix) + replacement + result.slice(citation.end_ix);
+ }
+
+ return result;
+}
+
+/**
+ * Formats the text content of a message based on its content type and author role.
+ * @param {ChatGPTMessage} messageData - The message data.
+ * @returns {string} - The formatted message text.
+ */
+function formatMessageText(messageData) {
+ const contentType = messageData.content.content_type;
+ const isText = contentType === 'text';
+ let messageText = '';
+
+ if (isText && messageData.content.parts) {
+ messageText = messageData.content.parts.join(' ');
+ } else if (contentType === 'code') {
+ messageText = `\`\`\`${messageData.content.language}\n${messageData.content.text}\n\`\`\``;
+ } else if (contentType === 'execution_output') {
+ messageText = `Execution Output:\n> ${messageData.content.text}`;
+ } else if (messageData.content.parts) {
+ for (const part of messageData.content.parts) {
+ if (typeof part === 'string') {
+ messageText += part + ' ';
+ } else if (typeof part === 'object') {
+ messageText = `\`\`\`json\n${JSON.stringify(part, null, 2)}\n\`\`\`\n`;
+ }
+ }
+ messageText = messageText.trim();
+ } else {
+ messageText = `\`\`\`json\n${JSON.stringify(messageData.content, null, 2)}\n\`\`\``;
+ }
+
+ if (isText && messageData.author?.role !== 'user') {
+ messageText = processAssistantMessage(messageData, messageText);
+ }
+
+ return messageText;
+}
+
+/**
+ * Adjusts message timestamps to ensure children always come after parents.
+ * Messages are sorted by createdAt and buildTree expects parents to appear before children.
+ * ChatGPT exports can have slight timestamp inversions (e.g., tool call results
+ * arriving a few ms before their parent). Uses multiple passes to handle cascading adjustments.
+ * Capped at N passes (where N = message count) to guarantee termination on cyclic graphs.
+ *
+ * @param {Array} messages - Array of message objects with messageId, parentMessageId, and createdAt.
+ * @returns {boolean} True if cyclic parent relationships were detected.
+ */
+function adjustTimestampsForOrdering(messages) {
+ if (messages.length === 0) {
+ return false;
+ }
+
+ const timestampMap = new Map();
+ for (const msg of messages) {
+ timestampMap.set(msg.messageId, msg.createdAt);
+ }
+
+ let hasChanges = true;
+ let remainingPasses = messages.length;
+ while (hasChanges && remainingPasses > 0) {
+ hasChanges = false;
+ remainingPasses--;
+ for (const message of messages) {
+ if (message.parentMessageId && message.parentMessageId !== Constants.NO_PARENT) {
+ const parentTimestamp = timestampMap.get(message.parentMessageId);
+ if (parentTimestamp && message.createdAt <= parentTimestamp) {
+ message.createdAt = new Date(parentTimestamp.getTime() + 1);
+ timestampMap.set(message.messageId, message.createdAt);
+ hasChanges = true;
+ }
+ }
+ }
+ }
+
+ const cycleDetected = remainingPasses === 0 && hasChanges;
+ if (cycleDetected) {
+ logger.warn(
+ '[importers] Detected cyclic parent relationships while adjusting import timestamps',
+ );
+ }
+ return cycleDetected;
+}
+
+/**
+ * Severs cyclic parentMessageId back-edges so saved messages form a valid tree.
+ * Walks each message's parent chain; if a message is visited twice, its parentMessageId
+ * is set to NO_PARENT to break the cycle.
+ *
+ * @param {Array} messages - Array of message objects with messageId and parentMessageId.
+ */
+function breakParentCycles(messages) {
+ const parentLookup = new Map();
+ for (const msg of messages) {
+ parentLookup.set(msg.messageId, msg);
+ }
+
+ const settled = new Set();
+ for (const message of messages) {
+ const chain = new Set();
+ let current = message;
+ while (current && !settled.has(current.messageId)) {
+ if (chain.has(current.messageId)) {
+ current.parentMessageId = Constants.NO_PARENT;
+ break;
+ }
+ chain.add(current.messageId);
+ const parentId = current.parentMessageId;
+ if (!parentId || parentId === Constants.NO_PARENT) {
+ break;
+ }
+ current = parentLookup.get(parentId);
+ }
+ for (const id of chain) {
+ settled.add(id);
+ }
+ }
+}
+
+module.exports = { getImporter, processAssistantMessage };
diff --git a/api/server/utils/import/importers.spec.js b/api/server/utils/import/importers.spec.js
new file mode 100644
index 0000000..a9bd679
--- /dev/null
+++ b/api/server/utils/import/importers.spec.js
@@ -0,0 +1,1889 @@
+const fs = require('fs');
+const path = require('path');
+const {
+ EModelEndpoint,
+ Constants,
+ RetentionMode,
+ openAISettings,
+ anthropicSettings,
+} = require('librechat-data-provider');
+const { getImporter, processAssistantMessage } = require('./importers');
+const { ImportBatchBuilder } = require('./importBatchBuilder');
+const { bulkSaveMessages, bulkSaveConvos: _bulkSaveConvos } = require('~/models');
+
+const mockGetEndpointsConfig = jest.fn().mockResolvedValue({
+ [EModelEndpoint.openAI]: { userProvide: false },
+});
+
+const mockGetModelsConfig = jest.fn().mockResolvedValue({});
+
+jest.mock('~/server/services/Config', () => ({
+ getEndpointsConfig: (...args) => mockGetEndpointsConfig(...args),
+}));
+
+jest.mock('~/server/controllers/ModelController', () => ({
+ getModelsConfig: (...args) => mockGetModelsConfig(...args),
+}));
+
+// Mock the database methods
+jest.mock('~/models', () => ({
+ bulkSaveConvos: jest.fn(),
+ bulkSaveMessages: jest.fn(),
+ bulkIncrementTagCounts: jest.fn(),
+}));
+
+afterEach(() => {
+ jest.clearAllMocks();
+});
+
+describe('importChatGptConvo', () => {
+ it('should import conversation correctly', async () => {
+ const expectedNumberOfMessages = 19;
+ const jsonData = JSON.parse(
+ fs.readFileSync(path.join(__dirname, '__data__', 'chatgpt-export.json'), 'utf8'),
+ );
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+
+ // Spy on instance methods
+ jest.spyOn(importBatchBuilder, 'startConversation');
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+ jest.spyOn(importBatchBuilder, 'finishConversation');
+ jest.spyOn(importBatchBuilder, 'saveBatch');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ expect(importBatchBuilder.startConversation).toHaveBeenCalledWith(EModelEndpoint.openAI);
+ expect(importBatchBuilder.saveMessage).toHaveBeenCalledTimes(expectedNumberOfMessages);
+ expect(importBatchBuilder.finishConversation).toHaveBeenCalledTimes(jsonData.length);
+ expect(importBatchBuilder.saveBatch).toHaveBeenCalled();
+ });
+
+ it('should maintain correct message hierarchy (tree parent/children relationship)', async () => {
+ const jsonData = JSON.parse(
+ fs.readFileSync(path.join(__dirname, '__data__', 'chatgpt-tree.json'), 'utf8'),
+ );
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+ jest.spyOn(importBatchBuilder, 'saveBatch');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ const entries = Object.keys(jsonData[0].mapping);
+ const messageEntries = entries.filter(
+ (id) =>
+ jsonData[0].mapping[id].message &&
+ jsonData[0].mapping[id].message.author.role !== 'system' &&
+ jsonData[0].mapping[id].message.content,
+ );
+
+ expect(importBatchBuilder.saveMessage).toHaveBeenCalledTimes(messageEntries.length);
+
+ const idToUUIDMap = new Map();
+ importBatchBuilder.saveMessage.mock.calls.forEach((call, index) => {
+ const originalId = messageEntries[index];
+ idToUUIDMap.set(originalId, call[0].messageId);
+ });
+
+ expect(idToUUIDMap.size).toBe(messageEntries.length);
+
+ messageEntries.forEach((id) => {
+ const { parent } = jsonData[0].mapping[id];
+
+ const expectedParentId = parent
+ ? (idToUUIDMap.get(parent) ?? Constants.NO_PARENT)
+ : Constants.NO_PARENT;
+
+ const actualMessageId = idToUUIDMap.get(id);
+ const actualParentId = actualMessageId
+ ? importBatchBuilder.saveMessage.mock.calls.find(
+ (call) => call[0].messageId === actualMessageId,
+ )[0].parentMessageId
+ : Constants.NO_PARENT;
+
+ expect(actualParentId).toBe(expectedParentId);
+ });
+
+ expect(importBatchBuilder.saveBatch).toHaveBeenCalled();
+ });
+
+ it('should handle system messages without breaking parent-child relationships', async () => {
+ /**
+ * Test data that reproduces message graph "breaking" when it encounters a system message
+ */
+ const testData = [
+ {
+ title: 'System Message Parent Test',
+ create_time: 1714585031.148505,
+ update_time: 1714585060.879308,
+ mapping: {
+ 'root-node': {
+ id: 'root-node',
+ message: null,
+ parent: null,
+ children: ['user-msg-1'],
+ },
+ 'user-msg-1': {
+ id: 'user-msg-1',
+ message: {
+ id: 'user-msg-1',
+ author: { role: 'user' },
+ create_time: 1714585031.150442,
+ content: { content_type: 'text', parts: ['First user message'] },
+ metadata: { model_slug: 'gpt-4' },
+ },
+ parent: 'root-node',
+ children: ['assistant-msg-1'],
+ },
+ 'assistant-msg-1': {
+ id: 'assistant-msg-1',
+ message: {
+ id: 'assistant-msg-1',
+ author: { role: 'assistant' },
+ create_time: 1714585032.150442,
+ content: { content_type: 'text', parts: ['First assistant response'] },
+ metadata: { model_slug: 'gpt-4' },
+ },
+ parent: 'user-msg-1',
+ children: ['system-msg'],
+ },
+ 'system-msg': {
+ id: 'system-msg',
+ message: {
+ id: 'system-msg',
+ author: { role: 'system' },
+ create_time: 1714585033.150442,
+ content: { content_type: 'text', parts: ['System message in middle'] },
+ metadata: { model_slug: 'gpt-4' },
+ },
+ parent: 'assistant-msg-1',
+ children: ['user-msg-2'],
+ },
+ 'user-msg-2': {
+ id: 'user-msg-2',
+ message: {
+ id: 'user-msg-2',
+ author: { role: 'user' },
+ create_time: 1714585034.150442,
+ content: { content_type: 'text', parts: ['Second user message'] },
+ metadata: { model_slug: 'gpt-4' },
+ },
+ parent: 'system-msg',
+ children: ['assistant-msg-2'],
+ },
+ 'assistant-msg-2': {
+ id: 'assistant-msg-2',
+ message: {
+ id: 'assistant-msg-2',
+ author: { role: 'assistant' },
+ create_time: 1714585035.150442,
+ content: { content_type: 'text', parts: ['Second assistant response'] },
+ metadata: { model_slug: 'gpt-4' },
+ },
+ parent: 'user-msg-2',
+ children: [],
+ },
+ },
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ const importer = getImporter(testData);
+ await importer(testData, requestUserId, () => importBatchBuilder);
+
+ /** 2 user messages + 2 assistant messages (system message should be skipped) */
+ const expectedMessages = 4;
+ expect(importBatchBuilder.saveMessage).toHaveBeenCalledTimes(expectedMessages);
+
+ const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
+
+ const messageMap = new Map();
+ savedMessages.forEach((msg) => {
+ messageMap.set(msg.text, msg);
+ });
+
+ const firstUser = messageMap.get('First user message');
+ const firstAssistant = messageMap.get('First assistant response');
+ const secondUser = messageMap.get('Second user message');
+ const secondAssistant = messageMap.get('Second assistant response');
+
+ expect(firstUser).toBeDefined();
+ expect(firstAssistant).toBeDefined();
+ expect(secondUser).toBeDefined();
+ expect(secondAssistant).toBeDefined();
+ expect(firstUser.parentMessageId).toBe(Constants.NO_PARENT);
+ expect(firstAssistant.parentMessageId).toBe(firstUser.messageId);
+
+ // This is the key test: second user message should have first assistant as parent
+ // (not NO_PARENT which would indicate the system message broke the chain)
+ expect(secondUser.parentMessageId).toBe(firstAssistant.messageId);
+ expect(secondAssistant.parentMessageId).toBe(secondUser.messageId);
+ });
+
+ it('should maintain correct sender for user messages regardless of GPT-4 model', async () => {
+ /**
+ * Test data with GPT-4 model to ensure user messages keep 'user' sender
+ */
+ const testData = [
+ {
+ title: 'GPT-4 Sender Test',
+ create_time: 1714585031.148505,
+ update_time: 1714585060.879308,
+ mapping: {
+ 'root-node': {
+ id: 'root-node',
+ message: null,
+ parent: null,
+ children: ['user-msg-1'],
+ },
+ 'user-msg-1': {
+ id: 'user-msg-1',
+ message: {
+ id: 'user-msg-1',
+ author: { role: 'user' },
+ create_time: 1714585031.150442,
+ content: { content_type: 'text', parts: ['User message with GPT-4'] },
+ metadata: { model_slug: 'gpt-4' },
+ },
+ parent: 'root-node',
+ children: ['assistant-msg-1'],
+ },
+ 'assistant-msg-1': {
+ id: 'assistant-msg-1',
+ message: {
+ id: 'assistant-msg-1',
+ author: { role: 'assistant' },
+ create_time: 1714585032.150442,
+ content: { content_type: 'text', parts: ['Assistant response with GPT-4'] },
+ metadata: { model_slug: 'gpt-4' },
+ },
+ parent: 'user-msg-1',
+ children: ['user-msg-2'],
+ },
+ 'user-msg-2': {
+ id: 'user-msg-2',
+ message: {
+ id: 'user-msg-2',
+ author: { role: 'user' },
+ create_time: 1714585033.150442,
+ content: { content_type: 'text', parts: ['Another user message with GPT-4o-mini'] },
+ metadata: { model_slug: 'gpt-4o-mini' },
+ },
+ parent: 'assistant-msg-1',
+ children: ['assistant-msg-2'],
+ },
+ 'assistant-msg-2': {
+ id: 'assistant-msg-2',
+ message: {
+ id: 'assistant-msg-2',
+ author: { role: 'assistant' },
+ create_time: 1714585034.150442,
+ content: { content_type: 'text', parts: ['Assistant response with GPT-3.5'] },
+ metadata: { model_slug: 'gpt-3.5-turbo' },
+ },
+ parent: 'user-msg-2',
+ children: [],
+ },
+ },
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ const importer = getImporter(testData);
+ await importer(testData, requestUserId, () => importBatchBuilder);
+
+ const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
+
+ const userMsg1 = savedMessages.find((msg) => msg.text === 'User message with GPT-4');
+ const assistantMsg1 = savedMessages.find((msg) => msg.text === 'Assistant response with GPT-4');
+ const userMsg2 = savedMessages.find(
+ (msg) => msg.text === 'Another user message with GPT-4o-mini',
+ );
+ const assistantMsg2 = savedMessages.find(
+ (msg) => msg.text === 'Assistant response with GPT-3.5',
+ );
+
+ expect(userMsg1.sender).toBe('user');
+ expect(userMsg1.isCreatedByUser).toBe(true);
+ expect(userMsg1.model).toBe('gpt-4');
+
+ expect(userMsg2.sender).toBe('user');
+ expect(userMsg2.isCreatedByUser).toBe(true);
+ expect(userMsg2.model).toBe('gpt-4o-mini');
+
+ expect(assistantMsg1.sender).toBe('GPT-4');
+ expect(assistantMsg1.isCreatedByUser).toBe(false);
+ expect(assistantMsg1.model).toBe('gpt-4');
+
+ expect(assistantMsg2.sender).toBe('GPT-3.5-turbo');
+ expect(assistantMsg2.isCreatedByUser).toBe(false);
+ expect(assistantMsg2.model).toBe('gpt-3.5-turbo');
+ });
+
+ it('should correctly extract and format model names from various model slugs', async () => {
+ /**
+ * Test data with various model slugs to test dynamic model identifier extraction
+ */
+ const testData = [
+ {
+ title: 'Dynamic Model Identifier Test',
+ create_time: 1714585031.148505,
+ update_time: 1714585060.879308,
+ mapping: {
+ 'root-node': {
+ id: 'root-node',
+ message: null,
+ parent: null,
+ children: ['msg-1'],
+ },
+ 'msg-1': {
+ id: 'msg-1',
+ message: {
+ id: 'msg-1',
+ author: { role: 'user' },
+ create_time: 1714585031.150442,
+ content: { content_type: 'text', parts: ['Test message'] },
+ metadata: {},
+ },
+ parent: 'root-node',
+ children: ['msg-2', 'msg-3', 'msg-4', 'msg-5', 'msg-6', 'msg-7', 'msg-8', 'msg-9'],
+ },
+ 'msg-2': {
+ id: 'msg-2',
+ message: {
+ id: 'msg-2',
+ author: { role: 'assistant' },
+ create_time: 1714585032.150442,
+ content: { content_type: 'text', parts: ['GPT-4 response'] },
+ metadata: { model_slug: 'gpt-4' },
+ },
+ parent: 'msg-1',
+ children: [],
+ },
+ 'msg-3': {
+ id: 'msg-3',
+ message: {
+ id: 'msg-3',
+ author: { role: 'assistant' },
+ create_time: 1714585033.150442,
+ content: { content_type: 'text', parts: ['GPT-4o response'] },
+ metadata: { model_slug: 'gpt-4o' },
+ },
+ parent: 'msg-1',
+ children: [],
+ },
+ 'msg-4': {
+ id: 'msg-4',
+ message: {
+ id: 'msg-4',
+ author: { role: 'assistant' },
+ create_time: 1714585034.150442,
+ content: { content_type: 'text', parts: ['GPT-4o-mini response'] },
+ metadata: { model_slug: 'gpt-4o-mini' },
+ },
+ parent: 'msg-1',
+ children: [],
+ },
+ 'msg-5': {
+ id: 'msg-5',
+ message: {
+ id: 'msg-5',
+ author: { role: 'assistant' },
+ create_time: 1714585035.150442,
+ content: { content_type: 'text', parts: ['GPT-3.5-turbo response'] },
+ metadata: { model_slug: 'gpt-3.5-turbo' },
+ },
+ parent: 'msg-1',
+ children: [],
+ },
+ 'msg-6': {
+ id: 'msg-6',
+ message: {
+ id: 'msg-6',
+ author: { role: 'assistant' },
+ create_time: 1714585036.150442,
+ content: { content_type: 'text', parts: ['GPT-4-turbo response'] },
+ metadata: { model_slug: 'gpt-4-turbo' },
+ },
+ parent: 'msg-1',
+ children: [],
+ },
+ 'msg-7': {
+ id: 'msg-7',
+ message: {
+ id: 'msg-7',
+ author: { role: 'assistant' },
+ create_time: 1714585037.150442,
+ content: { content_type: 'text', parts: ['GPT-4-1106-preview response'] },
+ metadata: { model_slug: 'gpt-4-1106-preview' },
+ },
+ parent: 'msg-1',
+ children: [],
+ },
+ 'msg-8': {
+ id: 'msg-8',
+ message: {
+ id: 'msg-8',
+ author: { role: 'assistant' },
+ create_time: 1714585038.150442,
+ content: { content_type: 'text', parts: ['Claude response'] },
+ metadata: { model_slug: 'claude-3-opus' },
+ },
+ parent: 'msg-1',
+ children: [],
+ },
+ 'msg-9': {
+ id: 'msg-9',
+ message: {
+ id: 'msg-9',
+ author: { role: 'assistant' },
+ create_time: 1714585039.150442,
+ content: { content_type: 'text', parts: ['No model slug response'] },
+ metadata: {},
+ },
+ parent: 'msg-1',
+ children: [],
+ },
+ },
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ const importer = getImporter(testData);
+ await importer(testData, requestUserId, () => importBatchBuilder);
+
+ const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
+
+ // Test various GPT model slug formats
+ const gpt4 = savedMessages.find((msg) => msg.text === 'GPT-4 response');
+ expect(gpt4.sender).toBe('GPT-4');
+ expect(gpt4.model).toBe('gpt-4');
+
+ const gpt4o = savedMessages.find((msg) => msg.text === 'GPT-4o response');
+ expect(gpt4o.sender).toBe('GPT-4o');
+ expect(gpt4o.model).toBe('gpt-4o');
+
+ const gpt4oMini = savedMessages.find((msg) => msg.text === 'GPT-4o-mini response');
+ expect(gpt4oMini.sender).toBe('GPT-4o-mini');
+ expect(gpt4oMini.model).toBe('gpt-4o-mini');
+
+ const gpt35Turbo = savedMessages.find((msg) => msg.text === 'GPT-3.5-turbo response');
+ expect(gpt35Turbo.sender).toBe('GPT-3.5-turbo');
+ expect(gpt35Turbo.model).toBe('gpt-3.5-turbo');
+
+ const gpt4Turbo = savedMessages.find((msg) => msg.text === 'GPT-4-turbo response');
+ expect(gpt4Turbo.sender).toBe('GPT-4-turbo');
+ expect(gpt4Turbo.model).toBe('gpt-4-turbo');
+
+ const gpt4Preview = savedMessages.find((msg) => msg.text === 'GPT-4-1106-preview response');
+ expect(gpt4Preview.sender).toBe('GPT-4-1106-preview');
+ expect(gpt4Preview.model).toBe('gpt-4-1106-preview');
+
+ // Test non-GPT model (should use the model slug as sender)
+ const claude = savedMessages.find((msg) => msg.text === 'Claude response');
+ expect(claude.sender).toBe('claude-3-opus');
+ expect(claude.model).toBe('claude-3-opus');
+
+ // Test missing model slug (should default to openAISettings.model.default)
+ const noModel = savedMessages.find((msg) => msg.text === 'No model slug response');
+ // When no model slug is provided, it defaults to gpt-4o-mini which gets formatted to GPT-4o-mini
+ expect(noModel.sender).toBe('GPT-4o-mini');
+ expect(noModel.model).toBe(openAISettings.model.default);
+
+ // Verify user message is unaffected
+ const userMsg = savedMessages.find((msg) => msg.text === 'Test message');
+ expect(userMsg.sender).toBe('user');
+ expect(userMsg.isCreatedByUser).toBe(true);
+ });
+
+ it('should merge thinking content into assistant message', async () => {
+ const testData = [
+ {
+ title: 'Thinking Content Test',
+ create_time: 1000,
+ update_time: 2000,
+ mapping: {
+ 'root-node': {
+ id: 'root-node',
+ message: null,
+ parent: null,
+ children: ['user-msg-1'],
+ },
+ 'user-msg-1': {
+ id: 'user-msg-1',
+ message: {
+ id: 'user-msg-1',
+ author: { role: 'user' },
+ create_time: 1,
+ content: { content_type: 'text', parts: ['What is 2+2?'] },
+ metadata: {},
+ },
+ parent: 'root-node',
+ children: ['thoughts-msg'],
+ },
+ 'thoughts-msg': {
+ id: 'thoughts-msg',
+ message: {
+ id: 'thoughts-msg',
+ author: { role: 'assistant' },
+ create_time: 2,
+ content: {
+ content_type: 'thoughts',
+ thoughts: [
+ { content: 'Let me think about this math problem.' },
+ { content: 'Adding 2 and 2 together gives 4.' },
+ ],
+ },
+ metadata: {},
+ },
+ parent: 'user-msg-1',
+ children: ['reasoning-recap-msg'],
+ },
+ 'reasoning-recap-msg': {
+ id: 'reasoning-recap-msg',
+ message: {
+ id: 'reasoning-recap-msg',
+ author: { role: 'assistant' },
+ create_time: 3,
+ content: {
+ content_type: 'reasoning_recap',
+ recap_text: 'Thought for 2 seconds',
+ },
+ metadata: {},
+ },
+ parent: 'thoughts-msg',
+ children: ['assistant-msg-1'],
+ },
+ 'assistant-msg-1': {
+ id: 'assistant-msg-1',
+ message: {
+ id: 'assistant-msg-1',
+ author: { role: 'assistant' },
+ create_time: 4,
+ content: { content_type: 'text', parts: ['The answer is 4.'] },
+ metadata: {},
+ },
+ parent: 'reasoning-recap-msg',
+ children: [],
+ },
+ },
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ const importer = getImporter(testData);
+ await importer(testData, requestUserId, () => importBatchBuilder);
+
+ const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
+
+ // Should only have 2 messages: user message and assistant response
+ // (thoughts and reasoning_recap should be merged/skipped)
+ expect(savedMessages).toHaveLength(2);
+
+ const userMsg = savedMessages.find((msg) => msg.text === 'What is 2+2?');
+ const assistantMsg = savedMessages.find((msg) => msg.text === 'The answer is 4.');
+
+ expect(userMsg).toBeDefined();
+ expect(assistantMsg).toBeDefined();
+
+ // Assistant message should have content array with thinking block
+ expect(assistantMsg.content).toBeDefined();
+ expect(assistantMsg.content).toHaveLength(2);
+ expect(assistantMsg.content[0].type).toBe('think');
+ expect(assistantMsg.content[0].think).toContain('Let me think about this math problem.');
+ expect(assistantMsg.content[0].think).toContain('Adding 2 and 2 together gives 4.');
+ expect(assistantMsg.content[1].type).toBe('text');
+ expect(assistantMsg.content[1].text).toBe('The answer is 4.');
+
+ // Verify parent-child relationship is correct (skips thoughts and reasoning_recap)
+ expect(assistantMsg.parentMessageId).toBe(userMsg.messageId);
+ });
+
+ it('should skip reasoning_recap and thoughts messages as separate entries', async () => {
+ const testData = [
+ {
+ title: 'Skip Thinking Messages Test',
+ create_time: 1000,
+ update_time: 2000,
+ mapping: {
+ 'root-node': {
+ id: 'root-node',
+ message: null,
+ parent: null,
+ children: ['user-msg-1'],
+ },
+ 'user-msg-1': {
+ id: 'user-msg-1',
+ message: {
+ id: 'user-msg-1',
+ author: { role: 'user' },
+ create_time: 1,
+ content: { content_type: 'text', parts: ['Hello'] },
+ metadata: {},
+ },
+ parent: 'root-node',
+ children: ['thoughts-msg'],
+ },
+ 'thoughts-msg': {
+ id: 'thoughts-msg',
+ message: {
+ id: 'thoughts-msg',
+ author: { role: 'assistant' },
+ create_time: 2,
+ content: {
+ content_type: 'thoughts',
+ thoughts: [{ content: 'Thinking...' }],
+ },
+ metadata: {},
+ },
+ parent: 'user-msg-1',
+ children: ['reasoning-recap-msg'],
+ },
+ 'reasoning-recap-msg': {
+ id: 'reasoning-recap-msg',
+ message: {
+ id: 'reasoning-recap-msg',
+ author: { role: 'assistant' },
+ create_time: 3,
+ content: {
+ content_type: 'reasoning_recap',
+ recap_text: 'Thought for 1 second',
+ },
+ metadata: {},
+ },
+ parent: 'thoughts-msg',
+ children: ['assistant-msg-1'],
+ },
+ 'assistant-msg-1': {
+ id: 'assistant-msg-1',
+ message: {
+ id: 'assistant-msg-1',
+ author: { role: 'assistant' },
+ create_time: 4,
+ content: { content_type: 'text', parts: ['Hi there!'] },
+ metadata: {},
+ },
+ parent: 'reasoning-recap-msg',
+ children: [],
+ },
+ },
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ const importer = getImporter(testData);
+ await importer(testData, requestUserId, () => importBatchBuilder);
+
+ const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
+
+ // Verify no messages have thoughts or reasoning_recap content types
+ const thoughtsMessages = savedMessages.filter(
+ (msg) =>
+ msg.text === '' || msg.text?.includes('Thinking...') || msg.text?.includes('Thought for'),
+ );
+ expect(thoughtsMessages).toHaveLength(0);
+
+ // Only user and assistant text messages should be saved
+ expect(savedMessages).toHaveLength(2);
+ expect(savedMessages.map((m) => m.text).sort()).toEqual(['Hello', 'Hi there!'].sort());
+ });
+
+ it('should set createdAt from ChatGPT create_time', async () => {
+ const testData = [
+ {
+ title: 'Timestamp Test',
+ create_time: 1000,
+ update_time: 2000,
+ mapping: {
+ 'root-node': {
+ id: 'root-node',
+ message: null,
+ parent: null,
+ children: ['user-msg-1'],
+ },
+ 'user-msg-1': {
+ id: 'user-msg-1',
+ message: {
+ id: 'user-msg-1',
+ author: { role: 'user' },
+ create_time: 1000,
+ content: { content_type: 'text', parts: ['Test message'] },
+ metadata: {},
+ },
+ parent: 'root-node',
+ children: ['assistant-msg-1'],
+ },
+ 'assistant-msg-1': {
+ id: 'assistant-msg-1',
+ message: {
+ id: 'assistant-msg-1',
+ author: { role: 'assistant' },
+ create_time: 2000,
+ content: { content_type: 'text', parts: ['Response'] },
+ metadata: {},
+ },
+ parent: 'user-msg-1',
+ children: [],
+ },
+ },
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ const importer = getImporter(testData);
+ await importer(testData, requestUserId, () => importBatchBuilder);
+
+ const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
+
+ const userMsg = savedMessages.find((msg) => msg.text === 'Test message');
+ const assistantMsg = savedMessages.find((msg) => msg.text === 'Response');
+
+ // Verify createdAt is set from create_time (converted from Unix timestamp)
+ expect(userMsg.createdAt).toEqual(new Date(1000 * 1000));
+ expect(assistantMsg.createdAt).toEqual(new Date(2000 * 1000));
+ });
+
+ it('should import messages missing metadata without failing (newer ChatGPT exports)', async () => {
+ const testData = [
+ {
+ title: 'Missing Metadata Test',
+ create_time: 1714585031.148505,
+ update_time: 1714585060.879308,
+ mapping: {
+ 'root-node': {
+ id: 'root-node',
+ message: null,
+ parent: null,
+ children: ['user-msg-1'],
+ },
+ 'user-msg-1': {
+ id: 'user-msg-1',
+ message: {
+ id: 'user-msg-1',
+ author: { role: 'user' },
+ create_time: 1714585031.150442,
+ content: { content_type: 'text', parts: ['User message without metadata'] },
+ },
+ parent: 'root-node',
+ children: ['assistant-msg-1'],
+ },
+ 'assistant-msg-1': {
+ id: 'assistant-msg-1',
+ message: {
+ id: 'assistant-msg-1',
+ author: { role: 'assistant' },
+ create_time: 1714585032.150442,
+ content: { content_type: 'text', parts: ['Assistant response without metadata'] },
+ },
+ parent: 'user-msg-1',
+ children: ['no-content-msg'],
+ },
+ 'no-content-msg': {
+ id: 'no-content-msg',
+ message: {
+ id: 'no-content-msg',
+ author: { role: 'tool' },
+ create_time: 1714585033.150442,
+ },
+ parent: 'assistant-msg-1',
+ children: [],
+ },
+ },
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ const importer = getImporter(testData);
+ await importer(testData, requestUserId, () => importBatchBuilder);
+
+ const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
+ expect(savedMessages).toHaveLength(2);
+
+ const userMessage = savedMessages.find((msg) => msg.isCreatedByUser);
+ const assistantMessage = savedMessages.find((msg) => !msg.isCreatedByUser);
+ expect(userMessage.model).toBe(openAISettings.model.default);
+ expect(assistantMessage.model).toBe(openAISettings.model.default);
+ expect(assistantMessage.parentMessageId).toBe(userMessage.messageId);
+ });
+
+ it('should rethrow errors so failed imports are not reported as successful', async () => {
+ const jsonData = JSON.parse(
+ fs.readFileSync(path.join(__dirname, '__data__', 'chatgpt-export.json'), 'utf8'),
+ );
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveBatch').mockRejectedValue(new Error('db unavailable'));
+
+ const importer = getImporter(jsonData);
+ await expect(importer(jsonData, requestUserId, () => importBatchBuilder)).rejects.toThrow(
+ 'db unavailable',
+ );
+ });
+});
+
+describe('importLibreChatConvo', () => {
+ const jsonDataNonRecursiveBranches = JSON.parse(
+ fs.readFileSync(path.join(__dirname, '__data__', 'librechat-opts-nonr-branches.json'), 'utf8'),
+ );
+
+ it('should import conversation correctly', async () => {
+ mockGetEndpointsConfig.mockResolvedValue({
+ [EModelEndpoint.openAI]: {},
+ });
+ const expectedNumberOfMessages = 6;
+ const jsonData = JSON.parse(
+ fs.readFileSync(path.join(__dirname, '__data__', 'librechat-export.json'), 'utf8'),
+ );
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+
+ // Spy on instance methods
+ jest.spyOn(importBatchBuilder, 'startConversation');
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+ jest.spyOn(importBatchBuilder, 'finishConversation');
+ jest.spyOn(importBatchBuilder, 'saveBatch');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ expect(importBatchBuilder.startConversation).toHaveBeenCalledWith(EModelEndpoint.openAI);
+ expect(importBatchBuilder.saveMessage).toHaveBeenCalledTimes(expectedNumberOfMessages);
+ expect(importBatchBuilder.finishConversation).toHaveBeenCalledTimes(1);
+ expect(importBatchBuilder.saveBatch).toHaveBeenCalled();
+ });
+
+ it('should import linear, non-recursive thread correctly with correct endpoint', async () => {
+ mockGetEndpointsConfig.mockResolvedValue({
+ [EModelEndpoint.azureOpenAI]: {},
+ });
+
+ const jsonData = JSON.parse(
+ fs.readFileSync(path.join(__dirname, '__data__', 'librechat-linear.json'), 'utf8'),
+ );
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+
+ jest.spyOn(importBatchBuilder, 'startConversation');
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+ jest.spyOn(importBatchBuilder, 'finishConversation');
+ jest.spyOn(importBatchBuilder, 'saveBatch');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ expect(bulkSaveMessages).toHaveBeenCalledTimes(1);
+
+ const messages = bulkSaveMessages.mock.calls[0][0];
+ let lastMessageId = Constants.NO_PARENT;
+ for (const message of messages) {
+ expect(message.parentMessageId).toBe(lastMessageId);
+ lastMessageId = message.messageId;
+ }
+
+ expect(importBatchBuilder.startConversation).toHaveBeenCalledWith(EModelEndpoint.azureOpenAI);
+ expect(importBatchBuilder.saveMessage).toHaveBeenCalledTimes(jsonData.messages.length);
+ expect(importBatchBuilder.finishConversation).toHaveBeenCalled();
+ expect(importBatchBuilder.saveBatch).toHaveBeenCalled();
+ });
+
+ it('should maintain correct message hierarchy (tree parent/children relationship)', async () => {
+ const jsonData = JSON.parse(
+ fs.readFileSync(path.join(__dirname, '__data__', 'librechat-tree.json'), 'utf8'),
+ );
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+ jest.spyOn(importBatchBuilder, 'saveBatch');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ // Get the imported messages
+ const messages = importBatchBuilder.messages;
+ expect(messages.length).toBeGreaterThan(0);
+
+ // Build maps for verification
+ const textToMessageMap = new Map();
+ const messageIdToMessage = new Map();
+ messages.forEach((msg) => {
+ if (msg.text) {
+ // For recursive imports, text might be very long, so just use the first 100 chars as key
+ const textKey = msg.text.substring(0, 100);
+ textToMessageMap.set(textKey, msg);
+ }
+ messageIdToMessage.set(msg.messageId, msg);
+ });
+
+ // Count expected messages from the tree
+ const countMessagesInTree = (nodes) => {
+ let count = 0;
+ nodes.forEach((node) => {
+ if (node.text || node.content) {
+ count++;
+ }
+ if (node.children && node.children.length > 0) {
+ count += countMessagesInTree(node.children);
+ }
+ });
+ return count;
+ };
+
+ const expectedMessageCount = countMessagesInTree(jsonData.messages);
+ expect(messages.length).toBe(expectedMessageCount);
+
+ // Verify all messages have valid parent relationships
+ messages.forEach((msg) => {
+ if (msg.parentMessageId !== Constants.NO_PARENT) {
+ const parent = messageIdToMessage.get(msg.parentMessageId);
+ expect(parent).toBeDefined();
+
+ // Verify timestamp ordering
+ if (msg.createdAt && parent.createdAt) {
+ expect(new Date(msg.createdAt).getTime()).toBeGreaterThanOrEqual(
+ new Date(parent.createdAt).getTime(),
+ );
+ }
+ }
+ });
+
+ // Verify at least one root message exists
+ const rootMessages = messages.filter((msg) => msg.parentMessageId === Constants.NO_PARENT);
+ expect(rootMessages.length).toBeGreaterThan(0);
+
+ expect(importBatchBuilder.saveBatch).toHaveBeenCalled();
+ });
+
+ it('should maintain correct message hierarchy (non-recursive)', async () => {
+ const jsonData = jsonDataNonRecursiveBranches;
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+ jest.spyOn(importBatchBuilder, 'saveBatch');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ const textToMessageMap = new Map();
+ importBatchBuilder.saveMessage.mock.calls.forEach((call) => {
+ const message = call[0];
+ textToMessageMap.set(message.text, message);
+ });
+
+ const relationships = {
+ 'tell me a long story': [
+ 'Of course! Settle in for a tale of adventure across time and space.\n\n---\n\nOnce upon a time in the small, sleepy village of Eldoria, there was a young woman named Elara who longed for adventure. Eldoria was a place of routine and simplicity, nestled between rolling hills and dense forests, but Elara always felt that there was more to the world than the boundaries',
+ 'Sure, I can craft a long story for you. Here it goes:\n\n### The Chronicles of Elenor: The Luminary of Anduril\n\nIn an age long forgotten by men, in a world kissed by the glow of dual suns, the Kingdom of Anduril flourished. Verdant valleys graced its land, majestic mountains shielded',
+ ],
+ 'tell me a long long story': [
+ 'Of course! Here’s a detailed and engaging story:\n\n---\n\n### The Legend of Eldoria\n\nNestled between towering mountains and dense, ancient forests was the enigmatic kingdom of Eldoria. This realm, clo aked in perpetual twilight, was the stuff of legends. It was said that the land was blessed by the gods and guarded by mythical creatures. Eldoria was a place where magic and realism intertwined seamlessly, creating a land of beauty, wonder, and peril.\n\nIn the heart of this kingdom lay the grand city of Lumina, known',
+ ],
+ };
+
+ Object.keys(relationships).forEach((parentText) => {
+ const parentMessage = textToMessageMap.get(parentText);
+ const childrenTexts = relationships[parentText];
+
+ childrenTexts.forEach((childText) => {
+ const childMessage = textToMessageMap.get(childText);
+ expect(childMessage.parentMessageId).toBe(parentMessage.messageId);
+ });
+ });
+
+ expect(importBatchBuilder.saveBatch).toHaveBeenCalled();
+ });
+
+ it('should retain properties from the original conversation as well as new settings', async () => {
+ mockGetEndpointsConfig.mockResolvedValue({
+ [EModelEndpoint.azureOpenAI]: {},
+ });
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'finishConversation');
+
+ const importer = getImporter(jsonDataNonRecursiveBranches);
+ await importer(jsonDataNonRecursiveBranches, requestUserId, () => importBatchBuilder);
+
+ expect(importBatchBuilder.finishConversation).toHaveBeenCalledTimes(1);
+
+ const [_title, createdAt, originalConvo] = importBatchBuilder.finishConversation.mock.calls[0];
+ const convo = importBatchBuilder.conversations[0];
+
+ expect(convo).toEqual({
+ ...jsonDataNonRecursiveBranches.options,
+ user: requestUserId,
+ conversationId: importBatchBuilder.conversationId,
+ title: originalConvo.title || 'Imported Chat',
+ createdAt: createdAt,
+ updatedAt: createdAt,
+ overrideTimestamp: true,
+ endpoint: importBatchBuilder.endpoint,
+ model: originalConvo.model || openAISettings.model.default,
+ });
+
+ expect(convo.title).toBe('Original');
+ expect(convo.createdAt).toBeInstanceOf(Date);
+ expect(convo.endpoint).toBe(EModelEndpoint.azureOpenAI);
+ expect(convo.model).toBe('gpt-4o');
+ });
+
+ describe('finishConversation', () => {
+ it('should retain properties from the original conversation as well as update with new settings', () => {
+ const requestUserId = 'user-123';
+ const builder = new ImportBatchBuilder(requestUserId);
+ builder.conversationId = 'conv-id-123';
+ builder.messages = [{ text: 'Hello, world!' }];
+
+ const originalConvo = {
+ _id: 'old-convo-id',
+ model: 'custom-model',
+ };
+
+ builder.endpoint = 'test-endpoint';
+
+ const title = 'New Chat Title';
+ const createdAt = new Date('2023-10-01T00:00:00Z');
+
+ const result = builder.finishConversation(title, createdAt, originalConvo);
+
+ expect(result).toEqual({
+ conversation: {
+ user: requestUserId,
+ conversationId: builder.conversationId,
+ title: 'New Chat Title',
+ createdAt: createdAt,
+ updatedAt: createdAt,
+ overrideTimestamp: true,
+ endpoint: 'test-endpoint',
+ model: 'custom-model',
+ },
+ messages: builder.messages,
+ });
+
+ expect(builder.conversations).toContainEqual({
+ user: requestUserId,
+ conversationId: builder.conversationId,
+ title: 'New Chat Title',
+ createdAt: createdAt,
+ updatedAt: createdAt,
+ overrideTimestamp: true,
+ endpoint: 'test-endpoint',
+ model: 'custom-model',
+ });
+ });
+
+ it('should use default values if not provided in the original conversation or as parameters', () => {
+ const requestUserId = 'user-123';
+ const builder = new ImportBatchBuilder(requestUserId);
+ builder.conversationId = 'conv-id-123';
+ builder.messages = [{ text: 'Hello, world!' }];
+ builder.endpoint = 'test-endpoint';
+ const result = builder.finishConversation();
+ expect(result.conversation.title).toBe('Imported Chat');
+ expect(result.conversation.model).toBe(openAISettings.model.default);
+ });
+
+ it('should default to the anthropic model for anthropic-endpoint conversations', () => {
+ const requestUserId = 'user-123';
+ const builder = new ImportBatchBuilder(requestUserId);
+ builder.conversationId = 'conv-id-123';
+ builder.messages = [{ text: 'Hello, world!' }];
+ builder.endpoint = EModelEndpoint.anthropic;
+ const result = builder.finishConversation();
+ expect(result.conversation.endpoint).toBe(EModelEndpoint.anthropic);
+ expect(result.conversation.model).toBe(anthropicSettings.model.default);
+ });
+
+ it('should default to the openAI model for openAI-endpoint conversations', () => {
+ const requestUserId = 'user-123';
+ const builder = new ImportBatchBuilder(requestUserId);
+ builder.conversationId = 'conv-id-123';
+ builder.messages = [{ text: 'Hello, world!' }];
+ builder.endpoint = EModelEndpoint.openAI;
+ const result = builder.finishConversation();
+ expect(result.conversation.endpoint).toBe(EModelEndpoint.openAI);
+ expect(result.conversation.model).toBe(openAISettings.model.default);
+ });
+
+ it('applies all-data retention to imported conversations and messages', () => {
+ const requestUserId = 'user-123';
+ const builder = new ImportBatchBuilder(requestUserId, {
+ retentionMode: RetentionMode.ALL,
+ temporaryChatRetention: 24,
+ });
+ builder.startConversation(EModelEndpoint.openAI);
+ const message = builder.addUserMessage('Retained import');
+ const result = builder.finishConversation('Imported retained chat');
+
+ expect(message.isTemporary).toBe(false);
+ expect(message.expiredAt).toBeInstanceOf(Date);
+ expect(result.conversation.isTemporary).toBe(false);
+ expect(result.conversation.expiredAt).toBeInstanceOf(Date);
+ expect(result.conversation.expiredAt).toBe(message.expiredAt);
+ });
+ });
+});
+
+describe('importChatBotUiConvo', () => {
+ it('should import custom conversation correctly', async () => {
+ const jsonData = JSON.parse(
+ fs.readFileSync(path.join(__dirname, '__data__', 'chatbotui-export.json'), 'utf8'),
+ );
+ const requestUserId = 'custom-user-456';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+
+ // Spy on instance methods
+ jest.spyOn(importBatchBuilder, 'startConversation');
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+ jest.spyOn(importBatchBuilder, 'addUserMessage');
+ jest.spyOn(importBatchBuilder, 'addGptMessage');
+ jest.spyOn(importBatchBuilder, 'finishConversation');
+ jest.spyOn(importBatchBuilder, 'saveBatch');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ expect(importBatchBuilder.startConversation).toHaveBeenCalledWith(EModelEndpoint.openAI);
+ expect(importBatchBuilder.addUserMessage).toHaveBeenCalledTimes(3);
+ expect(importBatchBuilder.addUserMessage).toHaveBeenNthCalledWith(
+ 1,
+ 'Hello what are you able to do?',
+ );
+ expect(importBatchBuilder.addUserMessage).toHaveBeenNthCalledWith(
+ 3,
+ 'Give me the code that inverts binary tree in COBOL',
+ );
+
+ expect(importBatchBuilder.addGptMessage).toHaveBeenCalledTimes(3);
+ expect(importBatchBuilder.addGptMessage).toHaveBeenNthCalledWith(
+ 1,
+ expect.stringMatching(/^Hello! As an AI developed by OpenAI/),
+ 'gpt-4-1106-preview',
+ );
+ expect(importBatchBuilder.addGptMessage).toHaveBeenNthCalledWith(
+ 3,
+ expect.stringContaining('```cobol'),
+ 'gpt-3.5-turbo',
+ );
+
+ expect(importBatchBuilder.finishConversation).toHaveBeenCalledTimes(2);
+ expect(importBatchBuilder.finishConversation).toHaveBeenNthCalledWith(
+ 1,
+ 'Hello what are you able to do?',
+ expect.any(Date),
+ {},
+ expect.any(String),
+ );
+ expect(importBatchBuilder.finishConversation).toHaveBeenNthCalledWith(
+ 2,
+ 'Give me the code that inverts ...',
+ expect.any(Date),
+ {},
+ expect.any(String),
+ );
+
+ expect(importBatchBuilder.saveBatch).toHaveBeenCalled();
+ });
+});
+
+describe('getImporter', () => {
+ it('should throw an error if the import type is not supported', () => {
+ const jsonData = { unsupported: 'data' };
+ expect(() => getImporter(jsonData)).toThrow('Unsupported import type');
+ });
+
+ it('should throw for array-based files that are not ChatGPT or Claude exports', () => {
+ const openWebUiExport = [
+ { id: 'abc', title: 'Open WebUI Chat', chat: { history: { messages: {} } } },
+ ];
+ expect(() => getImporter(openWebUiExport)).toThrow('Unsupported import type');
+ });
+
+ it('should route empty arrays to the ChatGPT importer without throwing', () => {
+ expect(() => getImporter([])).not.toThrow();
+ });
+});
+
+describe('processAssistantMessage', () => {
+ const testMessage = 'This is a test citation 【3:0†source】【3:1†source】';
+
+ const messageData = {
+ metadata: {
+ citations: [
+ {
+ start_ix: 23, // Position of first "【3:0†source】"
+ end_ix: 36, // End of first citation (including closing bracket)
+ citation_format_type: 'tether_og',
+ metadata: {
+ type: 'webpage',
+ title: 'Signal Sciences - Crunchbase Company Profile & Funding',
+ url: 'https://www.crunchbase.com/organization/signal-sciences',
+ text: '',
+ pub_date: null,
+ extra: {
+ evidence_text: 'source',
+ cited_message_idx: 3,
+ search_result_idx: 0,
+ },
+ },
+ },
+ {
+ start_ix: 36, // Position of second "【3:1†source】"
+ end_ix: 49, // End of second citation (including closing bracket)
+ citation_format_type: 'tether_og',
+ metadata: {
+ type: 'webpage',
+ title: 'Demand More from Your WAF - Signal Sciences now part of Fastly',
+ url: 'https://www.signalsciences.com/',
+ text: '',
+ pub_date: null,
+ extra: {
+ evidence_text: 'source',
+ cited_message_idx: 3,
+ search_result_idx: 1,
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ const messageText = testMessage;
+ const expectedOutput =
+ 'This is a test citation ([Signal Sciences - Crunchbase Company Profile & Funding](https://www.crunchbase.com/organization/signal-sciences)) ([Demand More from Your WAF - Signal Sciences now part of Fastly](https://www.signalsciences.com/))';
+
+ test('should correctly process citations and replace them with markdown links', () => {
+ const result = processAssistantMessage(messageData, messageText);
+ expect(result).toBe(expectedOutput);
+ });
+
+ test('should handle message with no citations', () => {
+ const messageWithNoCitations = {
+ metadata: {},
+ };
+ const result = processAssistantMessage(messageWithNoCitations, messageText);
+ expect(result).toBe(messageText);
+ });
+
+ test('should handle citations with missing metadata', () => {
+ const messageWithBadCitation = {
+ metadata: {
+ citations: [
+ {
+ start_ix: 85,
+ end_ix: 97,
+ },
+ ],
+ },
+ };
+ const result = processAssistantMessage(messageWithBadCitation, messageText);
+ expect(result).toBe(messageText);
+ });
+
+ test('should handle citations with non-webpage type', () => {
+ const messageWithNonWebpage = {
+ metadata: {
+ citations: [
+ {
+ start_ix: 85,
+ end_ix: 97,
+ metadata: {
+ type: 'other',
+ title: 'Test',
+ url: 'http://test.com',
+ },
+ },
+ ],
+ },
+ };
+ const result = processAssistantMessage(messageWithNonWebpage, messageText);
+ expect(result).toBe(messageText);
+ });
+
+ test('should handle empty message text', () => {
+ const result = processAssistantMessage(messageData, '');
+ expect(result).toBe('');
+ });
+
+ test('should handle undefined message text', () => {
+ const result = processAssistantMessage(messageData, undefined);
+ expect(result).toBe(undefined);
+ });
+
+ test('should handle invalid citation indices', () => {
+ const messageWithBadIndices = {
+ metadata: {
+ citations: [
+ {
+ start_ix: 100,
+ end_ix: 90, // end before start
+ metadata: {
+ type: 'webpage',
+ title: 'Test',
+ url: 'http://test.com',
+ },
+ },
+ ],
+ },
+ };
+ const result = processAssistantMessage(messageWithBadIndices, messageText);
+ expect(result).toBe(messageText);
+ });
+
+ test('should correctly process citations from real ChatGPT data', () => {
+ const jsonData = JSON.parse(
+ fs.readFileSync(path.join(__dirname, '__data__', 'chatgpt-citations.json'), 'utf8'),
+ );
+
+ // Get the message containing citations from the JSON data
+ const assistantMessage = jsonData[0].mapping['4b3aec6b-5146-4bad-ae8e-204fdb6accda'].message;
+
+ const messageText = assistantMessage.content.parts[0];
+ const citations = assistantMessage.metadata.citations;
+
+ // Expected output should have all citations replaced with markdown links
+ const expectedOutput =
+ "Signal Sciences is a web application security company that was founded on March 10, 2014, by Andrew Peterson, Nick Galbreath, and Zane Lackey. It operates as a for-profit company with its legal name being Signal Sciences Corp. The company has achieved significant growth and is recognized as the fastest-growing web application security company in the world. Signal Sciences developed a next-gen web application firewall (NGWAF) and runtime application self-protection (RASP) technologies designed to increase security and maintain reliability without compromising the performance of modern web applications distributed across cloud, on-premise, edge, or hybrid environments ([Signal Sciences - Crunchbase Company Profile & Funding](https://www.crunchbase.com/organization/signal-sciences)) ([Demand More from Your WAF - Signal Sciences now part of Fastly](https://www.signalsciences.com/)).\n\nIn a major development, Fastly, Inc., a provider of an edge cloud platform, announced the completion of its acquisition of Signal Sciences on October 1, 2020. This acquisition was valued at approximately $775 million in cash and stock. By integrating Signal Sciences' powerful web application and API security solutions with Fastly's edge cloud platform and existing security offerings, they aimed to form a unified suite of security solutions. The merger was aimed at expanding Fastly's security portfolio, particularly at a time when digital security has become paramount for businesses operating online ([Fastly Completes Acquisition of Signal Sciences | Fastly](https://www.fastly.com/press/press-releases/fastly-completes-acquisition-signal-sciences)) ([Fastly Agrees to Acquire Signal Sciences for $775 Million - Cooley](https://www.cooley.com/news/coverage/2020/2020-08-27-fastly-agrees-to-acquire-signal-sciences-for-775-million)).";
+
+ const result = processAssistantMessage(assistantMessage, messageText);
+ expect(result).toBe(expectedOutput);
+
+ // Additional checks to verify citation processing
+ citations.forEach((citation) => {
+ // Verify each citation was replaced
+ const markdownLink = `([${citation.metadata.title}](${citation.metadata.url}))`;
+ expect(result).toContain(markdownLink);
+
+ // Verify original citation format is not present
+ const originalCitation = messageText.slice(citation.start_ix, citation.end_ix);
+ expect(result).not.toContain(originalCitation);
+ });
+ });
+
+ test('should handle potential ReDoS attack payloads', () => {
+ // Test with increasing input sizes to check for exponential behavior
+ const sizes = [32, 33, 34]; // Adding more sizes would increase test time
+ const regExp = '(a+)+';
+ const results = [];
+
+ sizes.forEach((size) => {
+ const startTime = process.hrtime();
+
+ const maliciousMessageData = {
+ metadata: {
+ citations: [
+ {
+ start_ix: 0,
+ end_ix: size,
+ citation_format_type: 'tether_og',
+ metadata: {
+ type: 'webpage',
+ title: 'Test',
+ url: 'http://test.com',
+ extra: {
+ cited_message_idx: regExp,
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ const maliciousText = '【' + 'a'.repeat(size) + '】';
+
+ processAssistantMessage(maliciousMessageData, maliciousText);
+
+ const endTime = process.hrtime(startTime);
+ const duration = endTime[0] * 1000 + endTime[1] / 1000000; // Convert to milliseconds
+ results.push(duration);
+ });
+
+ // Each size should complete well under 100ms; a ReDoS would cause exponential blowup
+ for (let i = 0; i < results.length; i++) {
+ expect(results[i]).toBeLessThan(100);
+ }
+
+ // Also test with the exact payload from the security report
+ const maliciousPayload = {
+ metadata: {
+ citations: [
+ {
+ metadata: {
+ extra: {
+ cited_message_idx: '(a+)+',
+ },
+ type: 'webpage',
+ title: '1',
+ url: '2',
+ },
+ },
+ ],
+ },
+ };
+
+ const text = '【' + 'a'.repeat(32);
+ const startTime = process.hrtime();
+ processAssistantMessage(maliciousPayload, text);
+ const endTime = process.hrtime(startTime);
+ const duration = endTime[0] * 1000 + endTime[1] / 1000000;
+
+ // The processing should complete quickly (under 100ms)
+ expect(duration).toBeLessThan(100);
+ });
+});
+
+describe('importClaudeConvo', () => {
+ it('should import basic Claude conversation correctly', async () => {
+ const jsonData = [
+ {
+ uuid: 'conv-123',
+ name: 'Test Conversation',
+ created_at: '2025-01-15T10:00:00.000Z',
+ chat_messages: [
+ {
+ uuid: 'msg-1',
+ sender: 'human',
+ created_at: '2025-01-15T10:00:01.000Z',
+ content: [{ type: 'text', text: 'Hello Claude' }],
+ },
+ {
+ uuid: 'msg-2',
+ sender: 'assistant',
+ created_at: '2025-01-15T10:00:02.000Z',
+ content: [{ type: 'text', text: 'Hello! How can I help you?' }],
+ },
+ ],
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+ jest.spyOn(importBatchBuilder, 'startConversation');
+ jest.spyOn(importBatchBuilder, 'finishConversation');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ expect(importBatchBuilder.startConversation).toHaveBeenCalledWith(EModelEndpoint.anthropic);
+ expect(importBatchBuilder.saveMessage).toHaveBeenCalledTimes(2);
+ expect(importBatchBuilder.finishConversation).toHaveBeenCalledWith(
+ 'Test Conversation',
+ expect.any(Date),
+ {},
+ expect.any(String),
+ );
+
+ const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
+
+ // Check user message
+ const userMsg = savedMessages.find((msg) => msg.text === 'Hello Claude');
+ expect(userMsg.isCreatedByUser).toBe(true);
+ expect(userMsg.sender).toBe('user');
+ expect(userMsg.endpoint).toBe(EModelEndpoint.anthropic);
+
+ // Check assistant message
+ const assistantMsg = savedMessages.find((msg) => msg.text === 'Hello! How can I help you?');
+ expect(assistantMsg.isCreatedByUser).toBe(false);
+ expect(assistantMsg.sender).toBe('Claude');
+ expect(assistantMsg.parentMessageId).toBe(userMsg.messageId);
+ });
+
+ it('should merge thinking content into assistant message', async () => {
+ const jsonData = [
+ {
+ uuid: 'conv-123',
+ name: 'Thinking Test',
+ created_at: '2025-01-15T10:00:00.000Z',
+ chat_messages: [
+ {
+ uuid: 'msg-1',
+ sender: 'human',
+ created_at: '2025-01-15T10:00:01.000Z',
+ content: [{ type: 'text', text: 'What is 2+2?' }],
+ },
+ {
+ uuid: 'msg-2',
+ sender: 'assistant',
+ created_at: '2025-01-15T10:00:02.000Z',
+ content: [
+ { type: 'thinking', thinking: 'Let me calculate this simple math problem.' },
+ { type: 'text', text: 'The answer is 4.' },
+ ],
+ },
+ ],
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
+ const assistantMsg = savedMessages.find((msg) => msg.text === 'The answer is 4.');
+
+ expect(assistantMsg.content).toBeDefined();
+ expect(assistantMsg.content).toHaveLength(2);
+ expect(assistantMsg.content[0].type).toBe('think');
+ expect(assistantMsg.content[0].think).toBe('Let me calculate this simple math problem.');
+ expect(assistantMsg.content[1].type).toBe('text');
+ expect(assistantMsg.content[1].text).toBe('The answer is 4.');
+ });
+
+ it('should not include model field (Claude exports do not contain model info)', async () => {
+ const jsonData = [
+ {
+ uuid: 'conv-123',
+ name: 'No Model Test',
+ created_at: '2025-01-15T10:00:00.000Z',
+ chat_messages: [
+ {
+ uuid: 'msg-1',
+ sender: 'human',
+ created_at: '2025-01-15T10:00:01.000Z',
+ content: [{ type: 'text', text: 'Hello' }],
+ },
+ ],
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
+ // Model should not be explicitly set (will use ImportBatchBuilder default)
+ expect(savedMessages[0]).not.toHaveProperty('model');
+ });
+
+ it('should set the conversation endpoint and a Claude model so the chat UI loads correctly without a refresh', async () => {
+ const jsonData = [
+ {
+ uuid: 'conv-123',
+ name: 'Claude Conversation',
+ created_at: '2025-01-15T10:00:00.000Z',
+ chat_messages: [
+ {
+ uuid: 'msg-1',
+ sender: 'human',
+ created_at: '2025-01-15T10:00:01.000Z',
+ content: [{ type: 'text', text: 'Hello' }],
+ },
+ {
+ uuid: 'msg-2',
+ sender: 'assistant',
+ created_at: '2025-01-15T10:00:02.000Z',
+ content: [{ type: 'text', text: 'Hi there!' }],
+ },
+ ],
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ expect(importBatchBuilder.conversations).toHaveLength(1);
+ const convo = importBatchBuilder.conversations[0];
+ expect(convo.endpoint).toBe(EModelEndpoint.anthropic);
+ expect(convo.model).toBe(anthropicSettings.model.default);
+ expect(convo.model).not.toBe(openAISettings.model.default);
+ });
+
+ it('should prefer the first runtime-configured anthropic model over the hardcoded default', async () => {
+ mockGetModelsConfig.mockResolvedValueOnce({
+ [EModelEndpoint.anthropic]: ['claude-opus-4-7', 'claude-3-5-sonnet-latest'],
+ });
+
+ const jsonData = [
+ {
+ uuid: 'conv-456',
+ name: 'Configured Claude Conversation',
+ created_at: '2025-01-15T10:00:00.000Z',
+ chat_messages: [
+ {
+ uuid: 'msg-1',
+ sender: 'human',
+ created_at: '2025-01-15T10:00:01.000Z',
+ content: [{ type: 'text', text: 'Hello' }],
+ },
+ ],
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ const convo = importBatchBuilder.conversations[0];
+ expect(convo.endpoint).toBe(EModelEndpoint.anthropic);
+ expect(convo.model).toBe('claude-opus-4-7');
+ });
+
+ it('should fall back to the anthropic hardcoded default when modelsConfig has no anthropic models', async () => {
+ mockGetModelsConfig.mockResolvedValueOnce({
+ [EModelEndpoint.anthropic]: [],
+ });
+
+ const jsonData = [
+ {
+ uuid: 'conv-789',
+ name: 'Empty modelsConfig Conversation',
+ created_at: '2025-01-15T10:00:00.000Z',
+ chat_messages: [
+ {
+ uuid: 'msg-1',
+ sender: 'human',
+ created_at: '2025-01-15T10:00:01.000Z',
+ content: [{ type: 'text', text: 'Hello' }],
+ },
+ ],
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ const convo = importBatchBuilder.conversations[0];
+ expect(convo.endpoint).toBe(EModelEndpoint.anthropic);
+ expect(convo.model).toBe(anthropicSettings.model.default);
+ });
+
+ it('should fall back to the anthropic hardcoded default when getModelsConfig throws', async () => {
+ mockGetModelsConfig.mockRejectedValueOnce(new Error('boom'));
+
+ const jsonData = [
+ {
+ uuid: 'conv-fail',
+ name: 'modelsConfig failure',
+ created_at: '2025-01-15T10:00:00.000Z',
+ chat_messages: [
+ {
+ uuid: 'msg-1',
+ sender: 'human',
+ created_at: '2025-01-15T10:00:01.000Z',
+ content: [{ type: 'text', text: 'Hello' }],
+ },
+ ],
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ const convo = importBatchBuilder.conversations[0];
+ expect(convo.endpoint).toBe(EModelEndpoint.anthropic);
+ expect(convo.model).toBe(anthropicSettings.model.default);
+ });
+
+ it('should correct timestamp inversions (child before parent)', async () => {
+ const jsonData = [
+ {
+ uuid: 'conv-123',
+ name: 'Timestamp Inversion Test',
+ created_at: '2025-01-15T10:00:00.000Z',
+ chat_messages: [
+ {
+ uuid: 'msg-1',
+ sender: 'human',
+ created_at: '2025-01-15T10:00:05.000Z', // Later timestamp
+ content: [{ type: 'text', text: 'First message' }],
+ },
+ {
+ uuid: 'msg-2',
+ sender: 'assistant',
+ created_at: '2025-01-15T10:00:02.000Z', // Earlier timestamp (inverted)
+ content: [{ type: 'text', text: 'Second message' }],
+ },
+ ],
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
+ const firstMsg = savedMessages.find((msg) => msg.text === 'First message');
+ const secondMsg = savedMessages.find((msg) => msg.text === 'Second message');
+
+ // Second message should have timestamp adjusted to be after first
+ expect(new Date(secondMsg.createdAt).getTime()).toBeGreaterThan(
+ new Date(firstMsg.createdAt).getTime(),
+ );
+ });
+
+ it('should use conversation create_time for null message timestamps', async () => {
+ const convCreateTime = '2025-01-15T10:00:00.000Z';
+ const jsonData = [
+ {
+ uuid: 'conv-123',
+ name: 'Null Timestamp Test',
+ created_at: convCreateTime,
+ chat_messages: [
+ {
+ uuid: 'msg-1',
+ sender: 'human',
+ created_at: null, // Null timestamp
+ content: [{ type: 'text', text: 'Message with null time' }],
+ },
+ ],
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
+ expect(savedMessages[0].createdAt).toEqual(new Date(convCreateTime));
+ });
+
+ it('should use text field as fallback when content array is empty', async () => {
+ const jsonData = [
+ {
+ uuid: 'conv-123',
+ name: 'Text Fallback Test',
+ created_at: '2025-01-15T10:00:00.000Z',
+ chat_messages: [
+ {
+ uuid: 'msg-1',
+ sender: 'human',
+ created_at: '2025-01-15T10:00:01.000Z',
+ text: 'Fallback text content',
+ content: [], // Empty content array
+ },
+ ],
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
+ expect(savedMessages[0].text).toBe('Fallback text content');
+ });
+
+ it('should skip empty messages', async () => {
+ const jsonData = [
+ {
+ uuid: 'conv-123',
+ name: 'Skip Empty Test',
+ created_at: '2025-01-15T10:00:00.000Z',
+ chat_messages: [
+ {
+ uuid: 'msg-1',
+ sender: 'human',
+ created_at: '2025-01-15T10:00:01.000Z',
+ content: [{ type: 'text', text: 'Valid message' }],
+ },
+ {
+ uuid: 'msg-2',
+ sender: 'assistant',
+ created_at: '2025-01-15T10:00:02.000Z',
+ content: [], // Empty content
+ text: '', // Empty text
+ },
+ {
+ uuid: 'msg-3',
+ sender: 'human',
+ created_at: '2025-01-15T10:00:03.000Z',
+ content: [{ type: 'text', text: 'Another valid message' }],
+ },
+ ],
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'saveMessage');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ // Should only save 2 messages (empty one skipped)
+ expect(importBatchBuilder.saveMessage).toHaveBeenCalledTimes(2);
+ });
+
+ it('should use default name for unnamed conversations', async () => {
+ const jsonData = [
+ {
+ uuid: 'conv-123',
+ name: '', // Empty name
+ created_at: '2025-01-15T10:00:00.000Z',
+ chat_messages: [
+ {
+ uuid: 'msg-1',
+ sender: 'human',
+ created_at: '2025-01-15T10:00:01.000Z',
+ content: [{ type: 'text', text: 'Hello' }],
+ },
+ ],
+ },
+ ];
+
+ const requestUserId = 'user-123';
+ const importBatchBuilder = new ImportBatchBuilder(requestUserId);
+ jest.spyOn(importBatchBuilder, 'finishConversation');
+
+ const importer = getImporter(jsonData);
+ await importer(jsonData, requestUserId, () => importBatchBuilder);
+
+ expect(importBatchBuilder.finishConversation).toHaveBeenCalledWith(
+ 'Imported Claude Chat',
+ expect.any(Date),
+ {},
+ expect.any(String),
+ );
+ });
+});
diff --git a/api/server/utils/import/index.js b/api/server/utils/import/index.js
new file mode 100644
index 0000000..f1bca86
--- /dev/null
+++ b/api/server/utils/import/index.js
@@ -0,0 +1,7 @@
+const importers = require('./importers');
+const importConversations = require('./importConversations');
+
+module.exports = {
+ ...importers,
+ importConversations,
+};
diff --git a/api/server/utils/index.js b/api/server/utils/index.js
new file mode 100644
index 0000000..59cb716
--- /dev/null
+++ b/api/server/utils/index.js
@@ -0,0 +1,11 @@
+const handleText = require('./handleText');
+const sendEmail = require('./sendEmail');
+const queue = require('./queue');
+const files = require('./files');
+
+module.exports = {
+ ...handleText,
+ sendEmail,
+ ...files,
+ ...queue,
+};
diff --git a/api/server/utils/queue.js b/api/server/utils/queue.js
new file mode 100644
index 0000000..73d8192
--- /dev/null
+++ b/api/server/utils/queue.js
@@ -0,0 +1,58 @@
+/**
+ * A leaky bucket queue structure to manage API requests.
+ * @type {{queue: Array, interval: NodeJS.Timer | null}}
+ */
+const _LB = {
+ queue: [],
+ interval: null,
+};
+
+/**
+ * Interval in milliseconds to control the rate of API requests.
+ * Adjust the interval according to your rate limit needs.
+ */
+const _LB_INTERVAL_MS = Math.ceil(1000 / 60); // 60 req/s
+
+/**
+ * Executes the next function in the leaky bucket queue.
+ * This function is called at regular intervals defined by _LB_INTERVAL_MS.
+ */
+const _LB_EXEC_NEXT = async () => {
+ if (_LB.queue.length === 0) {
+ clearInterval(_LB.interval);
+ _LB.interval = null;
+ return;
+ }
+
+ const next = _LB.queue.shift();
+ if (!next) {
+ return;
+ }
+
+ const { asyncFunc, args, callback } = next;
+
+ try {
+ const data = await asyncFunc(...args);
+ callback(null, data);
+ } catch (e) {
+ callback(e);
+ }
+};
+
+/**
+ * Adds an async function call to the leaky bucket queue.
+ * @param {Function} asyncFunc - The async function to be executed.
+ * @param {Array} args - Arguments to pass to the async function.
+ * @param {Function} callback - Callback function for handling the result or error.
+ */
+function LB_QueueAsyncCall(asyncFunc, args, callback) {
+ _LB.queue.push({ asyncFunc, args, callback });
+
+ if (_LB.interval === null) {
+ _LB.interval = setInterval(_LB_EXEC_NEXT, _LB_INTERVAL_MS);
+ }
+}
+
+module.exports = {
+ LB_QueueAsyncCall,
+};
diff --git a/api/server/utils/sendEmail.js b/api/server/utils/sendEmail.js
new file mode 100644
index 0000000..3fa3e6f
--- /dev/null
+++ b/api/server/utils/sendEmail.js
@@ -0,0 +1,179 @@
+const path = require('path');
+const axios = require('axios');
+const FormData = require('form-data');
+const nodemailer = require('nodemailer');
+const handlebars = require('handlebars');
+const { logger } = require('@librechat/data-schemas');
+const { logAxiosError, isEnabled, readFileAsString } = require('@librechat/api');
+
+/**
+ * Sends an email using Mailgun API.
+ *
+ * @async
+ * @function sendEmailViaMailgun
+ * @param {Object} params - The parameters for sending the email.
+ * @param {string} params.to - The recipient's email address.
+ * @param {string} params.from - The sender's email address.
+ * @param {string} params.subject - The subject of the email.
+ * @param {string} params.html - The HTML content of the email.
+ * @returns {Promise} - A promise that resolves to the response from Mailgun API.
+ */
+const sendEmailViaMailgun = async ({ to, from, subject, html }) => {
+ const mailgunApiKey = process.env.MAILGUN_API_KEY;
+ const mailgunDomain = process.env.MAILGUN_DOMAIN;
+ const mailgunHost = process.env.MAILGUN_HOST || 'https://api.mailgun.net';
+
+ if (!mailgunApiKey || !mailgunDomain) {
+ throw new Error('Mailgun API key and domain are required');
+ }
+
+ const formData = new FormData();
+ formData.append('from', from);
+ formData.append('to', to);
+ formData.append('subject', subject);
+ formData.append('html', html);
+ formData.append('o:tracking-clicks', 'no');
+
+ try {
+ const response = await axios.post(`${mailgunHost}/v3/${mailgunDomain}/messages`, formData, {
+ headers: {
+ ...formData.getHeaders(),
+ Authorization: `Basic ${Buffer.from(`api:${mailgunApiKey}`).toString('base64')}`,
+ },
+ });
+
+ return response.data;
+ } catch (error) {
+ throw new Error(logAxiosError({ error, message: 'Failed to send email via Mailgun' }));
+ }
+};
+
+/**
+ * Sends an email using SMTP via Nodemailer.
+ *
+ * @async
+ * @function sendEmailViaSMTP
+ * @param {Object} params - The parameters for sending the email.
+ * @param {Object} params.transporterOptions - The transporter configuration options.
+ * @param {Object} params.mailOptions - The email options.
+ * @returns {Promise} - A promise that resolves to the info object of the sent email.
+ */
+const sendEmailViaSMTP = async ({ transporterOptions, mailOptions }) => {
+ const transporter = nodemailer.createTransport(transporterOptions);
+ return await transporter.sendMail(mailOptions);
+};
+
+/**
+ * Sends an email using the specified template, subject, and payload.
+ *
+ * @async
+ * @function sendEmail
+ * @param {Object} params - The parameters for sending the email.
+ * @param {string} params.email - The recipient's email address.
+ * @param {string} params.subject - The subject of the email.
+ * @param {Record} params.payload - The data to be used in the email template.
+ * @param {string} params.template - The filename of the email template.
+ * @param {boolean} [throwError=true] - Whether to throw an error if the email sending process fails.
+ * @returns {Promise} - A promise that resolves to the info object of the sent email or the error if sending the email fails.
+ *
+ * @example
+ * const emailData = {
+ * email: 'recipient@example.com',
+ * subject: 'Welcome!',
+ * payload: { name: 'Recipient' },
+ * template: 'welcome.html'
+ * };
+ *
+ * sendEmail(emailData)
+ * .then(info => console.log('Email sent:', info))
+ * .catch(error => console.error('Error sending email:', error));
+ *
+ * @throws Will throw an error if the email sending process fails and throwError is `true`.
+ */
+const sendEmail = async ({ email, subject, payload, template, throwError = true }) => {
+ try {
+ const { content: source } = await readFileAsString(path.join(__dirname, 'emails', template));
+ const compiledTemplate = handlebars.compile(source);
+ const html = compiledTemplate(payload);
+
+ // Prepare common email data
+ const fromName = process.env.EMAIL_FROM_NAME || process.env.APP_TITLE;
+ const fromEmail = process.env.EMAIL_FROM;
+ const fromAddress = `"${fromName}" <${fromEmail}>`;
+ const toAddress = `"${payload.name}" <${email}>`;
+
+ // Check if Mailgun is configured
+ if (process.env.MAILGUN_API_KEY && process.env.MAILGUN_DOMAIN) {
+ logger.debug('[sendEmail] Using Mailgun provider');
+ return await sendEmailViaMailgun({
+ from: fromAddress,
+ to: toAddress,
+ subject: subject,
+ html: html,
+ });
+ }
+
+ // Default to SMTP
+ logger.debug('[sendEmail] Using SMTP provider');
+ const transporterOptions = {
+ // Use STARTTLS by default instead of obligatory TLS
+ secure: process.env.EMAIL_ENCRYPTION === 'tls',
+ // If explicit STARTTLS is set, require it when connecting
+ requireTls: process.env.EMAIL_ENCRYPTION === 'starttls',
+ tls: {
+ // Whether to accept unsigned certificates
+ rejectUnauthorized: !isEnabled(process.env.EMAIL_ALLOW_SELFSIGNED),
+ },
+ };
+
+ const hasUsername = !!process.env.EMAIL_USERNAME;
+ const hasPassword = !!process.env.EMAIL_PASSWORD;
+ if (hasUsername && hasPassword) {
+ transporterOptions.auth = {
+ user: process.env.EMAIL_USERNAME,
+ pass: process.env.EMAIL_PASSWORD,
+ };
+ } else if (hasUsername !== hasPassword) {
+ logger.warn(
+ '[sendEmail] EMAIL_USERNAME and EMAIL_PASSWORD must both be set for authenticated SMTP, or both omitted for unauthenticated SMTP. Proceeding without authentication.',
+ );
+ }
+
+ if (process.env.EMAIL_ENCRYPTION_HOSTNAME) {
+ // Check the certificate against this name explicitly
+ transporterOptions.tls.servername = process.env.EMAIL_ENCRYPTION_HOSTNAME;
+ }
+
+ // Mailer service definition has precedence
+ if (process.env.EMAIL_SERVICE) {
+ transporterOptions.service = process.env.EMAIL_SERVICE;
+ } else {
+ transporterOptions.host = process.env.EMAIL_HOST;
+ transporterOptions.port = process.env.EMAIL_PORT ?? 25;
+ }
+
+ const mailOptions = {
+ // Header address should contain name-addr
+ from: fromAddress,
+ to: toAddress,
+ envelope: {
+ // Envelope from should contain addr-spec
+ // Mistake in the Nodemailer documentation?
+ from: fromEmail,
+ to: email,
+ },
+ subject: subject,
+ html: html,
+ };
+
+ return await sendEmailViaSMTP({ transporterOptions, mailOptions });
+ } catch (error) {
+ if (throwError) {
+ throw error;
+ }
+ logger.error('[sendEmail]', error);
+ return error;
+ }
+};
+
+module.exports = sendEmail;
diff --git a/api/server/utils/staticCache.js b/api/server/utils/staticCache.js
new file mode 100644
index 0000000..a16830a
--- /dev/null
+++ b/api/server/utils/staticCache.js
@@ -0,0 +1,65 @@
+const path = require('path');
+const express = require('express');
+const expressStaticGzip = require('express-static-gzip');
+
+const oneDayInSeconds = 24 * 60 * 60;
+
+const sMaxAge = process.env.STATIC_CACHE_S_MAX_AGE || oneDayInSeconds;
+const maxAge = process.env.STATIC_CACHE_MAX_AGE || oneDayInSeconds * 2;
+const isEnabled = (value) => value === true || String(value).toLowerCase() === 'true';
+
+/**
+ * Creates an Express static middleware with optional precompressed asset serving and configurable caching
+ *
+ * @param {string} staticPath - The file system path to serve static files from
+ * @param {Object} [options={}] - Configuration options
+ * @param {boolean} [options.noCache=false] - If true, disables caching entirely for all files
+ * @param {boolean} [options.skipGzipScan=false] - If true, skips expressStaticGzip middleware
+ * @returns {ReturnType|ReturnType} Express middleware function for serving static files
+ */
+function staticCache(staticPath, options = {}) {
+ const { noCache = false, skipGzipScan = false } = options;
+ const enableBrotli = isEnabled(process.env.ENABLE_STATIC_ASSET_BROTLI);
+
+ const setHeaders = (res, filePath) => {
+ if (process.env.NODE_ENV?.toLowerCase() !== 'production') {
+ return;
+ }
+ if (noCache) {
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
+ return;
+ }
+ if (filePath && filePath.includes('/dist/images/')) {
+ return;
+ }
+ const fileName = filePath ? path.basename(filePath) : '';
+
+ if (
+ fileName === 'index.html' ||
+ fileName.endsWith('.webmanifest') ||
+ fileName === 'manifest.json' ||
+ fileName === 'sw.js' ||
+ fileName === 'sw-heal.js'
+ ) {
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
+ } else {
+ res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=${sMaxAge}`);
+ }
+ };
+
+ if (skipGzipScan) {
+ return express.static(staticPath, {
+ setHeaders,
+ index: false,
+ });
+ } else {
+ return expressStaticGzip(staticPath, {
+ enableBrotli,
+ orderPreference: enableBrotli ? ['br', 'gz'] : ['gz'],
+ setHeaders,
+ index: false,
+ });
+ }
+}
+
+module.exports = staticCache;
diff --git a/api/strategies/appleStrategy.js b/api/strategies/appleStrategy.js
new file mode 100644
index 0000000..6eace87
--- /dev/null
+++ b/api/strategies/appleStrategy.js
@@ -0,0 +1,61 @@
+const jwt = require('jsonwebtoken');
+const { logger } = require('@librechat/data-schemas');
+const { Strategy: AppleStrategy } = require('passport-apple');
+const socialLogin = require('./socialLogin');
+
+/**
+ * Extract profile details from the decoded idToken
+ * @param {Object} params - Parameters from the verify callback
+ * @param {string} params.idToken - The ID token received from Apple
+ * @param {Object} params.profile - The profile object (may contain partial info)
+ * @returns {Object} - The extracted user profile details
+ */
+const getProfileDetails = ({ idToken, profile }) => {
+ if (!idToken) {
+ logger.error('idToken is missing');
+ throw new Error('idToken is missing');
+ }
+
+ const decoded = jwt.decode(idToken);
+
+ logger.debug(`Decoded Apple JWT: ${JSON.stringify(decoded, null, 2)}`);
+
+ return {
+ email: decoded.email,
+ id: decoded.sub,
+ avatarUrl: null, // Apple does not provide an avatar URL
+ username: decoded.email ? decoded.email.split('@')[0].toLowerCase() : `user_${decoded.sub}`,
+ name: decoded.name
+ ? `${decoded.name.firstName} ${decoded.name.lastName}`
+ : profile.displayName || null,
+ emailVerified: true, // Apple verifies the email
+ };
+};
+
+// Initialize the social login handler for Apple
+const appleLogin = socialLogin('apple', getProfileDetails);
+const appleAdminLogin = socialLogin('apple', getProfileDetails, { existingUsersOnly: true });
+
+const getAppleConfig = (callbackURL) => ({
+ clientID: process.env.APPLE_CLIENT_ID,
+ teamID: process.env.APPLE_TEAM_ID,
+ callbackURL,
+ keyID: process.env.APPLE_KEY_ID,
+ privateKeyLocation: process.env.APPLE_PRIVATE_KEY_PATH,
+ passReqToCallback: false,
+});
+
+const appleStrategy = () =>
+ new AppleStrategy(
+ getAppleConfig(`${process.env.DOMAIN_SERVER}${process.env.APPLE_CALLBACK_URL}`),
+ appleLogin,
+ );
+
+const appleAdminStrategy = () =>
+ new AppleStrategy(
+ getAppleConfig(`${process.env.DOMAIN_SERVER}/api/admin/oauth/apple/callback`),
+ appleAdminLogin,
+ );
+
+module.exports = appleStrategy;
+module.exports.appleAdminLogin = appleAdminStrategy;
diff --git a/api/strategies/appleStrategy.test.js b/api/strategies/appleStrategy.test.js
new file mode 100644
index 0000000..d142d27
--- /dev/null
+++ b/api/strategies/appleStrategy.test.js
@@ -0,0 +1,395 @@
+const jwt = require('jsonwebtoken');
+const mongoose = require('mongoose');
+const { isEnabled } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const { Strategy: AppleStrategy } = require('passport-apple');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { createSocialUser, handleExistingUser } = require('./process');
+const socialLogin = require('./socialLogin');
+const { findUser } = require('~/models');
+const { User } = require('~/db/models');
+
+jest.mock('jsonwebtoken');
+jest.mock('@librechat/data-schemas', () => {
+ const actualModule = jest.requireActual('@librechat/data-schemas');
+ return {
+ ...actualModule,
+ logger: {
+ error: jest.fn(),
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ },
+ };
+});
+jest.mock('./process', () => ({
+ createSocialUser: jest.fn(),
+ handleExistingUser: jest.fn(),
+}));
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ isEnabled: jest.fn(),
+}));
+jest.mock('~/models', () => ({
+ findUser: jest.fn(),
+}));
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn().mockResolvedValue({
+ fileStrategy: 'local',
+ balance: { enabled: false },
+ }),
+}));
+
+describe('Apple Login Strategy', () => {
+ let mongoServer;
+ let appleStrategyInstance;
+ const OLD_ENV = process.env;
+ let getProfileDetails;
+
+ // Start and stop in-memory MongoDB
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ process.env = OLD_ENV;
+ });
+
+ beforeEach(async () => {
+ // Reset environment variables
+ process.env = { ...OLD_ENV };
+ process.env.APPLE_CLIENT_ID = 'fake_client_id';
+ process.env.APPLE_TEAM_ID = 'fake_team_id';
+ process.env.APPLE_CALLBACK_URL = '/auth/apple/callback';
+ process.env.DOMAIN_SERVER = 'https://example.com';
+ process.env.APPLE_KEY_ID = 'fake_key_id';
+ process.env.APPLE_PRIVATE_KEY_PATH = '/path/to/fake/private/key';
+ process.env.ALLOW_SOCIAL_REGISTRATION = 'true';
+
+ // Clear mocks and database
+ jest.clearAllMocks();
+ await User.deleteMany({});
+
+ // Define getProfileDetails within the test scope
+ getProfileDetails = ({ idToken, profile }) => {
+ if (!idToken) {
+ logger.error('idToken is missing');
+ throw new Error('idToken is missing');
+ }
+
+ const decoded = jwt.decode(idToken);
+ if (!decoded) {
+ logger.error('Failed to decode idToken');
+ throw new Error('idToken is invalid');
+ }
+
+ console.log('Decoded token:', decoded);
+
+ logger.debug(`Decoded Apple JWT: ${JSON.stringify(decoded, null, 2)}`);
+
+ return {
+ email: decoded.email,
+ id: decoded.sub,
+ avatarUrl: null, // Apple does not provide an avatar URL
+ username: decoded.email ? decoded.email.split('@')[0].toLowerCase() : `user_${decoded.sub}`,
+ name: decoded.name
+ ? `${decoded.name.firstName} ${decoded.name.lastName}`
+ : profile.displayName || null,
+ emailVerified: true, // Apple verifies the email
+ };
+ };
+
+ // Mock isEnabled based on environment variable
+ isEnabled.mockImplementation((flag) => {
+ if (flag === 'true') {
+ return true;
+ }
+ if (flag === 'false') {
+ return false;
+ }
+ return false;
+ });
+
+ // Initialize the strategy with the mocked getProfileDetails
+ const appleLogin = socialLogin('apple', getProfileDetails);
+ appleStrategyInstance = new AppleStrategy(
+ {
+ clientID: process.env.APPLE_CLIENT_ID,
+ teamID: process.env.APPLE_TEAM_ID,
+ callbackURL: `${process.env.DOMAIN_SERVER}${process.env.APPLE_CALLBACK_URL}`,
+ keyID: process.env.APPLE_KEY_ID,
+ privateKeyLocation: process.env.APPLE_PRIVATE_KEY_PATH,
+ passReqToCallback: false,
+ },
+ appleLogin,
+ );
+ });
+
+ const mockProfile = {
+ displayName: 'John Doe',
+ };
+
+ describe('getProfileDetails', () => {
+ it('should throw an error if idToken is missing', () => {
+ expect(() => {
+ getProfileDetails({ idToken: null, profile: mockProfile });
+ }).toThrow('idToken is missing');
+ expect(logger.error).toHaveBeenCalledWith('idToken is missing');
+ });
+
+ it('should throw an error if idToken cannot be decoded', () => {
+ jwt.decode.mockReturnValue(null);
+ expect(() => {
+ getProfileDetails({ idToken: 'invalid_id_token', profile: mockProfile });
+ }).toThrow('idToken is invalid');
+ expect(logger.error).toHaveBeenCalledWith('Failed to decode idToken');
+ });
+
+ it('should extract user details correctly from idToken', () => {
+ const fakeDecodedToken = {
+ email: 'john.doe@example.com',
+ sub: 'apple-sub-1234',
+ name: {
+ firstName: 'John',
+ lastName: 'Doe',
+ },
+ };
+
+ jwt.decode.mockReturnValue(fakeDecodedToken);
+
+ const profileDetails = getProfileDetails({
+ idToken: 'fake_id_token',
+ profile: mockProfile,
+ });
+
+ expect(jwt.decode).toHaveBeenCalledWith('fake_id_token');
+ expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('Decoded Apple JWT'));
+ expect(profileDetails).toEqual({
+ email: 'john.doe@example.com',
+ id: 'apple-sub-1234',
+ avatarUrl: null,
+ username: 'john.doe',
+ name: 'John Doe',
+ emailVerified: true,
+ });
+ });
+
+ it('should handle missing email and use sub for username', () => {
+ const fakeDecodedToken = {
+ sub: 'apple-sub-5678',
+ };
+
+ jwt.decode.mockReturnValue(fakeDecodedToken);
+
+ const profileDetails = getProfileDetails({
+ idToken: 'fake_id_token',
+ profile: mockProfile,
+ });
+
+ expect(profileDetails).toEqual({
+ email: undefined,
+ id: 'apple-sub-5678',
+ avatarUrl: null,
+ username: 'user_apple-sub-5678',
+ name: 'John Doe',
+ emailVerified: true,
+ });
+ });
+ });
+
+ describe('Strategy verify callback', () => {
+ const tokenset = {
+ id_token: 'fake_id_token',
+ };
+
+ const decodedToken = {
+ email: 'jane.doe@example.com',
+ sub: 'apple-sub-9012',
+ name: {
+ firstName: 'Jane',
+ lastName: 'Doe',
+ },
+ };
+
+ const fakeAccessToken = 'fake_access_token';
+ const fakeRefreshToken = 'fake_refresh_token';
+
+ beforeEach(() => {
+ jwt.decode.mockReturnValue(decodedToken);
+ findUser.mockResolvedValue(null);
+ });
+
+ it('should create a new user if one does not exist and registration is allowed', async () => {
+ // Mock findUser to return null (user does not exist)
+ findUser.mockResolvedValue(null);
+
+ // Mock createSocialUser to create a user
+ createSocialUser.mockImplementation(async (userData) => {
+ const user = new User(userData);
+ await user.save();
+ return user;
+ });
+
+ const mockVerifyCallback = jest.fn();
+
+ // Invoke the verify callback with correct arguments
+ await new Promise((resolve) => {
+ appleStrategyInstance._verify(
+ fakeAccessToken,
+ fakeRefreshToken,
+ tokenset.id_token,
+ mockProfile,
+ (err, user) => {
+ mockVerifyCallback(err, user);
+ resolve();
+ },
+ );
+ });
+
+ expect(mockVerifyCallback).toHaveBeenCalledWith(null, expect.any(User));
+ const user = mockVerifyCallback.mock.calls[0][1];
+ expect(user.email).toBe('jane.doe@example.com');
+ expect(user.username).toBe('jane.doe');
+ expect(user.name).toBe('Jane Doe');
+ expect(user.provider).toBe('apple');
+ });
+
+ it('should handle existing user and update avatarUrl', async () => {
+ // Create an existing user without saving to database
+ const existingUser = new User({
+ email: 'jane.doe@example.com',
+ username: 'jane.doe',
+ name: 'Jane Doe',
+ provider: 'apple',
+ providerId: 'apple-sub-9012',
+ avatarUrl: 'old_avatar.png',
+ });
+
+ // Mock findUser to return the existing user
+ findUser.mockResolvedValue(existingUser);
+
+ // Mock handleExistingUser to update avatarUrl without saving to database
+ handleExistingUser.mockImplementation(async (user, avatarUrl) => {
+ user.avatarUrl = avatarUrl;
+ // Don't call save() to avoid database operations
+ return user;
+ });
+
+ const mockVerifyCallback = jest.fn();
+
+ // Invoke the verify callback with correct arguments
+ await new Promise((resolve) => {
+ appleStrategyInstance._verify(
+ fakeAccessToken,
+ fakeRefreshToken,
+ tokenset.id_token,
+ mockProfile,
+ (err, user) => {
+ mockVerifyCallback(err, user);
+ resolve();
+ },
+ );
+ });
+
+ expect(mockVerifyCallback).toHaveBeenCalledWith(null, existingUser);
+ expect(existingUser.avatarUrl).toBeNull(); // As per getProfileDetails
+ expect(handleExistingUser).toHaveBeenCalledWith(
+ existingUser,
+ null,
+ expect.objectContaining({
+ fileStrategy: 'local',
+ balance: { enabled: false },
+ }),
+ 'jane.doe@example.com',
+ );
+ });
+
+ it('should handle missing idToken gracefully', async () => {
+ const mockVerifyCallback = jest.fn();
+
+ // Invoke the verify callback with missing id_token
+ await new Promise((resolve) => {
+ appleStrategyInstance._verify(
+ fakeAccessToken,
+ fakeRefreshToken,
+ null, // idToken is missing
+ mockProfile,
+ (err, user) => {
+ mockVerifyCallback(err, user);
+ resolve();
+ },
+ );
+ });
+
+ expect(mockVerifyCallback).toHaveBeenCalledWith(expect.any(Error), undefined);
+ expect(mockVerifyCallback.mock.calls[0][0].message).toBe('idToken is missing');
+ // Ensure createSocialUser and handleExistingUser were not called
+ expect(createSocialUser).not.toHaveBeenCalled();
+ expect(handleExistingUser).not.toHaveBeenCalled();
+ });
+
+ it('should handle decoding errors gracefully', async () => {
+ // Simulate decoding failure by returning null
+ jwt.decode.mockReturnValue(null);
+
+ const mockVerifyCallback = jest.fn();
+
+ // Invoke the verify callback with correct arguments
+ await new Promise((resolve) => {
+ appleStrategyInstance._verify(
+ fakeAccessToken,
+ fakeRefreshToken,
+ tokenset.id_token,
+ mockProfile,
+ (err, user) => {
+ mockVerifyCallback(err, user);
+ resolve();
+ },
+ );
+ });
+
+ expect(mockVerifyCallback).toHaveBeenCalledWith(expect.any(Error), undefined);
+ expect(mockVerifyCallback.mock.calls[0][0].message).toBe('idToken is invalid');
+ // Ensure createSocialUser and handleExistingUser were not called
+ expect(createSocialUser).not.toHaveBeenCalled();
+ expect(handleExistingUser).not.toHaveBeenCalled();
+ // Ensure logger.error was called
+ expect(logger.error).toHaveBeenCalledWith('Failed to decode idToken');
+ });
+
+ it('should handle errors during user creation', async () => {
+ // Mock findUser to return null (user does not exist)
+ findUser.mockResolvedValue(null);
+
+ // Mock createSocialUser to throw an error
+ createSocialUser.mockImplementation(() => {
+ throw new Error('Database error');
+ });
+
+ const mockVerifyCallback = jest.fn();
+
+ // Invoke the verify callback with correct arguments
+ await new Promise((resolve) => {
+ appleStrategyInstance._verify(
+ fakeAccessToken,
+ fakeRefreshToken,
+ tokenset.id_token,
+ mockProfile,
+ (err, user) => {
+ mockVerifyCallback(err, user);
+ resolve();
+ },
+ );
+ });
+
+ expect(mockVerifyCallback).toHaveBeenCalledWith(expect.any(Error), undefined);
+ expect(mockVerifyCallback.mock.calls[0][0].message).toBe('Database error');
+ // Ensure logger.error was called
+ expect(logger.error).toHaveBeenCalledWith('[appleLogin]', expect.any(Error));
+ });
+ });
+});
diff --git a/api/strategies/discordStrategy.js b/api/strategies/discordStrategy.js
new file mode 100644
index 0000000..7fb6828
--- /dev/null
+++ b/api/strategies/discordStrategy.js
@@ -0,0 +1,48 @@
+const { Strategy: DiscordStrategy } = require('passport-discord');
+const socialLogin = require('./socialLogin');
+
+const getProfileDetails = ({ profile }) => {
+ let avatarUrl;
+ if (profile.avatar) {
+ const format = profile.avatar.startsWith('a_') ? 'gif' : 'png';
+ avatarUrl = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`;
+ } else {
+ const defaultAvatarNum = Number(profile.discriminator) % 5;
+ avatarUrl = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNum}.png`;
+ }
+
+ return {
+ email: profile.email,
+ id: profile.id,
+ avatarUrl,
+ username: profile.username,
+ name: profile.global_name,
+ emailVerified: true,
+ };
+};
+
+const discordLogin = socialLogin('discord', getProfileDetails);
+const discordAdminLogin = socialLogin('discord', getProfileDetails, { existingUsersOnly: true });
+
+const getDiscordConfig = (callbackURL) => ({
+ clientID: process.env.DISCORD_CLIENT_ID,
+ clientSecret: process.env.DISCORD_CLIENT_SECRET,
+ callbackURL,
+ scope: ['identify', 'email'],
+ authorizationURL: 'https://discord.com/api/oauth2/authorize?prompt=none',
+});
+
+const discordStrategy = () =>
+ new DiscordStrategy(
+ getDiscordConfig(`${process.env.DOMAIN_SERVER}${process.env.DISCORD_CALLBACK_URL}`),
+ discordLogin,
+ );
+
+const discordAdminStrategy = () =>
+ new DiscordStrategy(
+ getDiscordConfig(`${process.env.DOMAIN_SERVER}/api/admin/oauth/discord/callback`),
+ discordAdminLogin,
+ );
+
+module.exports = discordStrategy;
+module.exports.discordAdminLogin = discordAdminStrategy;
diff --git a/api/strategies/facebookStrategy.js b/api/strategies/facebookStrategy.js
new file mode 100644
index 0000000..f638c3b
--- /dev/null
+++ b/api/strategies/facebookStrategy.js
@@ -0,0 +1,38 @@
+const FacebookStrategy = require('passport-facebook').Strategy;
+const socialLogin = require('./socialLogin');
+
+const getProfileDetails = ({ profile }) => ({
+ email: profile.emails[0]?.value,
+ id: profile.id,
+ avatarUrl: profile.photos[0]?.value,
+ username: profile.displayName,
+ name: profile.name?.givenName + ' ' + profile.name?.familyName,
+ emailVerified: true,
+});
+
+const facebookLogin = socialLogin('facebook', getProfileDetails);
+const facebookAdminLogin = socialLogin('facebook', getProfileDetails, { existingUsersOnly: true });
+
+const getFacebookConfig = (callbackURL) => ({
+ clientID: process.env.FACEBOOK_CLIENT_ID,
+ clientSecret: process.env.FACEBOOK_CLIENT_SECRET,
+ callbackURL,
+ proxy: true,
+ scope: ['public_profile'],
+ profileFields: ['id', 'email', 'name'],
+});
+
+const facebookStrategy = () =>
+ new FacebookStrategy(
+ getFacebookConfig(`${process.env.DOMAIN_SERVER}${process.env.FACEBOOK_CALLBACK_URL}`),
+ facebookLogin,
+ );
+
+const facebookAdminStrategy = () =>
+ new FacebookStrategy(
+ getFacebookConfig(`${process.env.DOMAIN_SERVER}/api/admin/oauth/facebook/callback`),
+ facebookAdminLogin,
+ );
+
+module.exports = facebookStrategy;
+module.exports.facebookAdminLogin = facebookAdminStrategy;
diff --git a/api/strategies/githubStrategy.js b/api/strategies/githubStrategy.js
new file mode 100644
index 0000000..363acbf
--- /dev/null
+++ b/api/strategies/githubStrategy.js
@@ -0,0 +1,46 @@
+const { Strategy: GitHubStrategy } = require('passport-github2');
+const socialLogin = require('./socialLogin');
+
+const getProfileDetails = ({ profile }) => ({
+ email: profile.emails[0].value,
+ id: profile.id,
+ avatarUrl: profile.photos[0].value,
+ username: profile.username,
+ name: profile.displayName,
+ emailVerified: profile.emails[0].verified,
+});
+
+const githubLogin = socialLogin('github', getProfileDetails);
+const githubAdminLogin = socialLogin('github', getProfileDetails, { existingUsersOnly: true });
+
+const getGitHubConfig = (callbackURL) => ({
+ clientID: process.env.GITHUB_CLIENT_ID,
+ clientSecret: process.env.GITHUB_CLIENT_SECRET,
+ callbackURL,
+ proxy: false,
+ scope: ['user:email'],
+ ...(process.env.GITHUB_ENTERPRISE_BASE_URL && {
+ authorizationURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/login/oauth/authorize`,
+ tokenURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/login/oauth/access_token`,
+ userProfileURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/api/v3/user`,
+ userEmailURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/api/v3/user/emails`,
+ ...(process.env.GITHUB_ENTERPRISE_USER_AGENT && {
+ userAgent: process.env.GITHUB_ENTERPRISE_USER_AGENT,
+ }),
+ }),
+});
+
+const githubStrategy = () =>
+ new GitHubStrategy(
+ getGitHubConfig(`${process.env.DOMAIN_SERVER}${process.env.GITHUB_CALLBACK_URL}`),
+ githubLogin,
+ );
+
+const githubAdminStrategy = () =>
+ new GitHubStrategy(
+ getGitHubConfig(`${process.env.DOMAIN_SERVER}/api/admin/oauth/github/callback`),
+ githubAdminLogin,
+ );
+
+module.exports = githubStrategy;
+module.exports.githubAdminLogin = githubAdminStrategy;
diff --git a/api/strategies/googleStrategy.js b/api/strategies/googleStrategy.js
new file mode 100644
index 0000000..bee9a06
--- /dev/null
+++ b/api/strategies/googleStrategy.js
@@ -0,0 +1,36 @@
+const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
+const socialLogin = require('./socialLogin');
+
+const getProfileDetails = ({ profile }) => ({
+ email: profile.emails[0].value,
+ id: profile.id,
+ avatarUrl: profile.photos[0].value,
+ username: profile.name.givenName,
+ name: `${profile.name.givenName}${profile.name.familyName ? ` ${profile.name.familyName}` : ''}`,
+ emailVerified: profile.emails[0].verified,
+});
+
+const googleLogin = socialLogin('google', getProfileDetails);
+const googleAdminLogin = socialLogin('google', getProfileDetails, { existingUsersOnly: true });
+
+const getGoogleConfig = (callbackURL) => ({
+ clientID: process.env.GOOGLE_CLIENT_ID,
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
+ callbackURL,
+ proxy: true,
+});
+
+const googleStrategy = () =>
+ new GoogleStrategy(
+ getGoogleConfig(`${process.env.DOMAIN_SERVER}${process.env.GOOGLE_CALLBACK_URL}`),
+ googleLogin,
+ );
+
+const googleAdminStrategy = () =>
+ new GoogleStrategy(
+ getGoogleConfig(`${process.env.DOMAIN_SERVER}/api/admin/oauth/google/callback`),
+ googleAdminLogin,
+ );
+
+module.exports = googleStrategy;
+module.exports.googleAdminLogin = googleAdminStrategy;
diff --git a/api/strategies/index.js b/api/strategies/index.js
new file mode 100644
index 0000000..c15bbc4
--- /dev/null
+++ b/api/strategies/index.js
@@ -0,0 +1,37 @@
+const { setupOpenId, getOpenIdConfig, getOpenIdEmail } = require('./openidStrategy');
+const openIdJwtLogin = require('./openIdJwtStrategy');
+const facebookLogin = require('./facebookStrategy');
+const { facebookAdminLogin } = facebookLogin;
+const discordLogin = require('./discordStrategy');
+const { discordAdminLogin } = discordLogin;
+const passportLogin = require('./localStrategy');
+const googleLogin = require('./googleStrategy');
+const { googleAdminLogin } = googleLogin;
+const githubLogin = require('./githubStrategy');
+const { githubAdminLogin } = githubLogin;
+const { setupSaml } = require('./samlStrategy');
+const appleLogin = require('./appleStrategy');
+const { appleAdminLogin } = appleLogin;
+const ldapLogin = require('./ldapStrategy');
+const jwtLogin = require('./jwtStrategy');
+
+module.exports = {
+ appleLogin,
+ appleAdminLogin,
+ passportLogin,
+ googleLogin,
+ googleAdminLogin,
+ githubLogin,
+ githubAdminLogin,
+ discordLogin,
+ discordAdminLogin,
+ jwtLogin,
+ facebookLogin,
+ facebookAdminLogin,
+ setupOpenId,
+ getOpenIdConfig,
+ getOpenIdEmail,
+ ldapLogin,
+ setupSaml,
+ openIdJwtLogin,
+};
diff --git a/api/strategies/jwtStrategy.js b/api/strategies/jwtStrategy.js
new file mode 100644
index 0000000..7a41f55
--- /dev/null
+++ b/api/strategies/jwtStrategy.js
@@ -0,0 +1,35 @@
+const { logger } = require('@librechat/data-schemas');
+const { SystemRoles } = require('librechat-data-provider');
+const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
+const { getUserById, updateUser } = require('~/models');
+
+// JWT strategy
+const jwtLogin = () =>
+ new JwtStrategy(
+ {
+ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
+ secretOrKey: process.env.JWT_SECRET,
+ },
+ async (payload, done) => {
+ try {
+ const user = await getUserById(payload?.id, '-password -__v -totpSecret -backupCodes');
+ if (user) {
+ user.id = user._id.toString();
+ /** Absent on the full doc means local user; null skips getUserPrincipals' fallback lookup */
+ user.idOnTheSource ??= null;
+ if (!user.role) {
+ user.role = SystemRoles.USER;
+ await updateUser(user.id, { role: user.role });
+ }
+ done(null, user);
+ } else {
+ logger.warn('[jwtLogin] JwtStrategy => no user found: ' + payload?.id);
+ done(null, false);
+ }
+ } catch (err) {
+ done(err, false);
+ }
+ },
+ );
+
+module.exports = jwtLogin;
diff --git a/api/strategies/jwtStrategy.spec.js b/api/strategies/jwtStrategy.spec.js
new file mode 100644
index 0000000..3a8b838
--- /dev/null
+++ b/api/strategies/jwtStrategy.spec.js
@@ -0,0 +1,75 @@
+const { SystemRoles } = require('librechat-data-provider');
+
+let capturedVerifyCallback;
+jest.mock('passport-jwt', () => ({
+ Strategy: jest.fn((opts, verifyCallback) => {
+ capturedVerifyCallback = verifyCallback;
+ return { name: 'jwt' };
+ }),
+ ExtractJwt: {
+ fromAuthHeaderAsBearerToken: jest.fn(() => 'mock-extractor'),
+ },
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
+}));
+
+jest.mock('~/models', () => ({
+ getUserById: jest.fn(),
+ updateUser: jest.fn(),
+}));
+
+const jwtLogin = require('./jwtStrategy');
+const { getUserById, updateUser } = require('~/models');
+
+function invokeVerify(payload) {
+ return new Promise((resolve, reject) => {
+ capturedVerifyCallback(payload, (err, user, info) => {
+ if (err) {
+ return reject(err);
+ }
+ resolve({ user, info });
+ });
+ });
+}
+
+describe('jwtStrategy', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ updateUser.mockResolvedValue({});
+ jwtLogin();
+ });
+
+ it('coerces missing idOnTheSource to null for local users', async () => {
+ getUserById.mockResolvedValue({
+ _id: { toString: () => 'user-1' },
+ role: SystemRoles.USER,
+ });
+
+ const { user } = await invokeVerify({ id: 'user-1' });
+
+ expect(user.id).toBe('user-1');
+ expect(user.idOnTheSource).toBeNull();
+ });
+
+ it('preserves a stored idOnTheSource for federated users', async () => {
+ getUserById.mockResolvedValue({
+ _id: { toString: () => 'user-2' },
+ role: SystemRoles.USER,
+ idOnTheSource: 'entra-oid-123',
+ });
+
+ const { user } = await invokeVerify({ id: 'user-2' });
+
+ expect(user.idOnTheSource).toBe('entra-oid-123');
+ });
+
+ it('returns false when no user is found', async () => {
+ getUserById.mockResolvedValue(null);
+
+ const { user } = await invokeVerify({ id: 'missing' });
+
+ expect(user).toBe(false);
+ });
+});
diff --git a/api/strategies/ldapStrategy.js b/api/strategies/ldapStrategy.js
new file mode 100644
index 0000000..9253f54
--- /dev/null
+++ b/api/strategies/ldapStrategy.js
@@ -0,0 +1,186 @@
+const fs = require('fs');
+const LdapStrategy = require('passport-ldapauth');
+const { logger } = require('@librechat/data-schemas');
+const { SystemRoles, ErrorTypes } = require('librechat-data-provider');
+const {
+ isEnabled,
+ getBalanceConfig,
+ isEmailDomainAllowed,
+ resolveAppConfigForUser,
+} = require('@librechat/api');
+const { createUser, findUser, updateUser, countUsers } = require('~/models');
+const { getAppConfig } = require('~/server/services/Config');
+
+const {
+ LDAP_URL,
+ LDAP_BIND_DN,
+ LDAP_BIND_CREDENTIALS,
+ LDAP_USER_SEARCH_BASE,
+ LDAP_SEARCH_FILTER,
+ LDAP_CA_CERT_PATH,
+ LDAP_FULL_NAME,
+ LDAP_ID,
+ LDAP_USERNAME,
+ LDAP_EMAIL,
+ LDAP_TLS_REJECT_UNAUTHORIZED,
+ LDAP_STARTTLS,
+} = process.env;
+
+// Check required environment variables
+if (!LDAP_URL || !LDAP_USER_SEARCH_BASE) {
+ module.exports = null;
+}
+
+const searchAttributes = [
+ 'displayName',
+ 'mail',
+ 'uid',
+ 'cn',
+ 'name',
+ 'commonname',
+ 'givenName',
+ 'sn',
+ 'sAMAccountName',
+];
+
+if (LDAP_FULL_NAME) {
+ searchAttributes.push(...LDAP_FULL_NAME.split(','));
+}
+if (LDAP_ID) {
+ searchAttributes.push(LDAP_ID);
+}
+if (LDAP_USERNAME) {
+ searchAttributes.push(LDAP_USERNAME);
+}
+if (LDAP_EMAIL) {
+ searchAttributes.push(LDAP_EMAIL);
+}
+const rejectUnauthorized = isEnabled(LDAP_TLS_REJECT_UNAUTHORIZED);
+const startTLS = isEnabled(LDAP_STARTTLS);
+
+const ldapOptions = {
+ server: {
+ url: LDAP_URL,
+ bindDN: LDAP_BIND_DN,
+ bindCredentials: LDAP_BIND_CREDENTIALS,
+ searchBase: LDAP_USER_SEARCH_BASE,
+ searchFilter: LDAP_SEARCH_FILTER || 'mail={{username}}',
+ searchAttributes: [...new Set(searchAttributes)],
+ ...(LDAP_CA_CERT_PATH && {
+ tlsOptions: {
+ rejectUnauthorized,
+ ca: (() => {
+ try {
+ return [fs.readFileSync(LDAP_CA_CERT_PATH)];
+ } catch (err) {
+ logger.error('[ldapStrategy]', 'Failed to read CA certificate', err);
+ throw err;
+ }
+ })(),
+ },
+ }),
+ ...(startTLS && { starttls: true }),
+ },
+ usernameField: 'email',
+ passwordField: 'password',
+};
+
+const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
+ if (!userinfo) {
+ return done(null, false, { message: 'Invalid credentials' });
+ }
+
+ try {
+ const ldapId =
+ (LDAP_ID && userinfo[LDAP_ID]) || userinfo.uid || userinfo.sAMAccountName || userinfo.mail;
+
+ const fullNameAttributes = LDAP_FULL_NAME && LDAP_FULL_NAME.split(',');
+ const fullName =
+ fullNameAttributes && fullNameAttributes.length > 0
+ ? fullNameAttributes.map((attr) => userinfo[attr]).join(' ')
+ : userinfo.cn || userinfo.name || userinfo.commonname || userinfo.displayName;
+
+ const username =
+ (LDAP_USERNAME && userinfo[LDAP_USERNAME]) || userinfo.givenName || userinfo.mail;
+
+ let mail = (LDAP_EMAIL && userinfo[LDAP_EMAIL]) || userinfo.mail || username + '@ldap.local';
+ mail = Array.isArray(mail) ? mail[0] : mail;
+
+ if (!userinfo.mail && !(LDAP_EMAIL && userinfo[LDAP_EMAIL])) {
+ logger.warn(
+ '[ldapStrategy]',
+ `No valid email attribute found in LDAP userinfo. Using fallback email: ${username}@ldap.local`,
+ `LDAP_EMAIL env var: ${LDAP_EMAIL || 'not set'}`,
+ `Available userinfo attributes: ${Object.keys(userinfo).join(', ')}`,
+ 'Full userinfo:',
+ JSON.stringify(userinfo, null, 2),
+ );
+ }
+
+ // Domain check before findUser for two-phase fast-fail (consistent with SAML/OpenID/social).
+ // This means cross-provider users from blocked domains get 'Email domain not allowed'
+ // instead of AUTH_FAILED — both deny access.
+ const baseConfig = await getAppConfig({ baseOnly: true });
+ if (!isEmailDomainAllowed(mail, baseConfig?.registration?.allowedDomains)) {
+ logger.error(
+ `[LDAP Strategy] Authentication blocked - email domain not allowed [Email: ${mail}]`,
+ );
+ return done(null, false, { message: 'Email domain not allowed' });
+ }
+
+ let user = await findUser({ ldapId });
+ if (user && user.provider !== 'ldap') {
+ logger.info(
+ `[ldapStrategy] User ${user.email} already exists with provider ${user.provider}`,
+ );
+ return done(null, false, {
+ message: ErrorTypes.AUTH_FAILED,
+ });
+ }
+
+ const appConfig = user?.tenantId
+ ? await resolveAppConfigForUser(getAppConfig, user)
+ : baseConfig;
+
+ if (!isEmailDomainAllowed(mail, appConfig?.registration?.allowedDomains)) {
+ logger.error(
+ `[LDAP Strategy] Authentication blocked - email domain not allowed [Email: ${mail}]`,
+ );
+ return done(null, false, { message: 'Email domain not allowed' });
+ }
+
+ if (!user) {
+ const isFirstRegisteredUser = (await countUsers()) === 0;
+ const role = isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER;
+
+ user = {
+ provider: 'ldap',
+ ldapId,
+ username,
+ email: mail,
+ emailVerified: true, // The ldap server administrator should verify the email
+ name: fullName,
+ role,
+ };
+ const balanceConfig = getBalanceConfig(appConfig);
+ const userId = await createUser(user, balanceConfig);
+ user._id = userId;
+ } else {
+ // Users registered in LDAP are assumed to have their user information managed in LDAP,
+ // so update the user information with the values registered in LDAP
+ user.provider = 'ldap';
+ user.ldapId = ldapId;
+ user.email = mail;
+ user.username = username;
+ user.name = fullName;
+ }
+
+ user = await updateUser(user._id, user);
+ done(null, user);
+ } catch (err) {
+ logger.error('[ldapStrategy]', err);
+ done(err);
+ }
+});
+
+module.exports = ldapLogin;
diff --git a/api/strategies/ldapStrategy.spec.js b/api/strategies/ldapStrategy.spec.js
new file mode 100644
index 0000000..876d70f
--- /dev/null
+++ b/api/strategies/ldapStrategy.spec.js
@@ -0,0 +1,246 @@
+// --- Mocks ---
+jest.mock('@librechat/data-schemas', () => ({
+ logger: {
+ info: jest.fn(),
+ warn: jest.fn(),
+ debug: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
+jest.mock('@librechat/api', () => ({
+ isEnabled: jest.fn(() => false),
+ isEmailDomainAllowed: jest.fn(() => true),
+ getBalanceConfig: jest.fn(() => ({ enabled: false })),
+ resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
+}));
+
+jest.mock('~/models', () => ({
+ findUser: jest.fn(),
+ createUser: jest.fn(),
+ updateUser: jest.fn(),
+ countUsers: jest.fn(),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn().mockResolvedValue({}),
+}));
+
+// Mock passport-ldapauth to capture verify callback
+let verifyCallback;
+jest.mock('passport-ldapauth', () => {
+ return jest.fn().mockImplementation((options, verify) => {
+ verifyCallback = verify;
+ return { name: 'ldap', options, verify };
+ });
+});
+
+const { ErrorTypes } = require('librechat-data-provider');
+const { isEmailDomainAllowed, resolveAppConfigForUser } = require('@librechat/api');
+const { findUser, createUser, updateUser, countUsers } = require('~/models');
+const { getAppConfig } = require('~/server/services/Config');
+
+// Helper to call the verify callback and wrap in a Promise for convenience
+const callVerify = (userinfo) =>
+ new Promise((resolve, reject) => {
+ verifyCallback(userinfo, (err, user, info) => {
+ if (err) return reject(err);
+ resolve({ user, info });
+ });
+ });
+
+describe('ldapStrategy', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // minimal required env for ldapStrategy module to export
+ process.env.LDAP_URL = 'ldap://example.com';
+ process.env.LDAP_USER_SEARCH_BASE = 'ou=users,dc=example,dc=com';
+
+ // Unset optional envs to exercise defaults
+ delete process.env.LDAP_CA_CERT_PATH;
+ delete process.env.LDAP_FULL_NAME;
+ delete process.env.LDAP_ID;
+ delete process.env.LDAP_USERNAME;
+ delete process.env.LDAP_EMAIL;
+ delete process.env.LDAP_TLS_REJECT_UNAUTHORIZED;
+ delete process.env.LDAP_STARTTLS;
+
+ // Default model/domain mocks
+ findUser.mockReset().mockResolvedValue(null);
+ createUser.mockReset().mockResolvedValue('newUserId');
+ updateUser.mockReset().mockImplementation(async (id, user) => ({ _id: id, ...user }));
+ countUsers.mockReset().mockResolvedValue(0);
+ isEmailDomainAllowed.mockReset().mockReturnValue(true);
+
+ // Ensure requiring the strategy sets up the verify callback
+ jest.isolateModules(() => {
+ require('./ldapStrategy');
+ });
+ });
+
+ it('uses the first email when LDAP returns multiple emails (array)', async () => {
+ const userinfo = {
+ uid: 'uid123',
+ givenName: 'Alice',
+ cn: 'Alice Doe',
+ mail: ['first@example.com', 'second@example.com'],
+ };
+
+ const { user } = await callVerify(userinfo);
+
+ expect(user.email).toBe('first@example.com');
+ expect(createUser).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: 'ldap',
+ ldapId: 'uid123',
+ username: 'Alice',
+ email: 'first@example.com',
+ emailVerified: true,
+ name: 'Alice Doe',
+ }),
+ expect.any(Object),
+ );
+ });
+
+ it('blocks login if an existing user has a different provider', async () => {
+ findUser.mockResolvedValue({ _id: 'u1', email: 'first@example.com', provider: 'google' });
+
+ const userinfo = {
+ uid: 'uid123',
+ mail: 'first@example.com',
+ givenName: 'Alice',
+ cn: 'Alice Doe',
+ };
+
+ const { user, info } = await callVerify(userinfo);
+
+ expect(user).toBe(false);
+ expect(info).toEqual({ message: ErrorTypes.AUTH_FAILED });
+ expect(createUser).not.toHaveBeenCalled();
+ expect(resolveAppConfigForUser).not.toHaveBeenCalled();
+ });
+
+ it('updates an existing ldap user with current LDAP info', async () => {
+ const existing = {
+ _id: 'u2',
+ provider: 'ldap',
+ email: 'old@example.com',
+ ldapId: 'uid123',
+ username: 'olduser',
+ name: 'Old Name',
+ };
+ findUser.mockResolvedValue(existing);
+
+ const userinfo = {
+ uid: 'uid123',
+ mail: 'new@example.com',
+ givenName: 'NewFirst',
+ cn: 'NewFirst NewLast',
+ };
+
+ const { user } = await callVerify(userinfo);
+
+ expect(createUser).not.toHaveBeenCalled();
+ expect(updateUser).toHaveBeenCalledWith(
+ 'u2',
+ expect.objectContaining({
+ provider: 'ldap',
+ ldapId: 'uid123',
+ email: 'new@example.com',
+ username: 'NewFirst',
+ name: 'NewFirst NewLast',
+ }),
+ );
+ expect(user.email).toBe('new@example.com');
+ });
+
+ it('falls back to username@ldap.local when no email attributes are present', async () => {
+ const userinfo = {
+ uid: 'uid999',
+ givenName: 'John',
+ cn: 'John Doe',
+ };
+
+ const { user } = await callVerify(userinfo);
+
+ expect(user.email).toBe('John@ldap.local');
+ });
+
+ it('denies login if email domain is not allowed', async () => {
+ isEmailDomainAllowed.mockReturnValue(false);
+
+ const userinfo = {
+ uid: 'uid123',
+ mail: 'notallowed@blocked.com',
+ givenName: 'Alice',
+ cn: 'Alice Doe',
+ };
+
+ const { user, info } = await callVerify(userinfo);
+ expect(user).toBe(false);
+ expect(info).toEqual({ message: 'Email domain not allowed' });
+ });
+
+ it('passes getAppConfig and found user to resolveAppConfigForUser', async () => {
+ const existing = {
+ _id: 'u3',
+ provider: 'ldap',
+ email: 'tenant@example.com',
+ ldapId: 'uid-tenant',
+ username: 'tenantuser',
+ name: 'Tenant User',
+ tenantId: 'tenant-a',
+ role: 'USER',
+ };
+ findUser.mockResolvedValue(existing);
+
+ const userinfo = {
+ uid: 'uid-tenant',
+ mail: 'tenant@example.com',
+ givenName: 'Tenant',
+ cn: 'Tenant User',
+ };
+
+ await callVerify(userinfo);
+
+ expect(resolveAppConfigForUser).toHaveBeenCalledWith(getAppConfig, existing);
+ });
+
+ it('uses baseConfig for new user without calling resolveAppConfigForUser', async () => {
+ findUser.mockResolvedValue(null);
+
+ const userinfo = {
+ uid: 'uid-new',
+ mail: 'newuser@example.com',
+ givenName: 'New',
+ cn: 'New User',
+ };
+
+ await callVerify(userinfo);
+
+ expect(resolveAppConfigForUser).not.toHaveBeenCalled();
+ expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true });
+ });
+
+ it('should block login when tenant config restricts the domain', async () => {
+ const existing = {
+ _id: 'u-blocked',
+ provider: 'ldap',
+ ldapId: 'uid-tenant',
+ tenantId: 'tenant-strict',
+ role: 'USER',
+ };
+ findUser.mockResolvedValue(existing);
+ resolveAppConfigForUser.mockResolvedValue({
+ registration: { allowedDomains: ['other.com'] },
+ });
+ isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false);
+
+ const userinfo = { uid: 'uid-tenant', mail: 'user@example.com', givenName: 'Test', cn: 'Test' };
+ const { user, info } = await callVerify(userinfo);
+
+ expect(user).toBe(false);
+ expect(info).toEqual({ message: 'Email domain not allowed' });
+ });
+});
diff --git a/api/strategies/localStrategy.js b/api/strategies/localStrategy.js
new file mode 100644
index 0000000..df0290a
--- /dev/null
+++ b/api/strategies/localStrategy.js
@@ -0,0 +1,90 @@
+const bcrypt = require('bcryptjs');
+const { logger } = require('@librechat/data-schemas');
+const { errorsToString } = require('librechat-data-provider');
+const { Strategy: PassportLocalStrategy } = require('passport-local');
+const { isEnabled, checkEmailConfig, comparePassword } = require('@librechat/api');
+const { findUser, updateUser } = require('~/models');
+const { loginSchema } = require('./validators');
+
+// Unix timestamp for 2024-06-07 15:20:18 Eastern Time
+const verificationEnabledTimestamp = 1717788018;
+
+async function validateLoginRequest(req) {
+ const { error } = loginSchema.safeParse(req.body);
+ return error ? errorsToString(error.errors) : null;
+}
+
+async function passportLogin(req, email, password, done) {
+ try {
+ const validationError = await validateLoginRequest(req);
+ if (validationError) {
+ logError('Passport Local Strategy - Validation Error', { email: req.body?.email });
+ logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
+ return done(null, false, { message: validationError });
+ }
+
+ const user = await findUser({ email: email.trim() }, '+password');
+ if (!user) {
+ logError('Passport Local Strategy - User Not Found', { email });
+ logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
+ return done(null, false, { message: 'Email does not exist.' });
+ }
+
+ if (!user.password) {
+ logError('Passport Local Strategy - User has no password', { email });
+ logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
+ return done(null, false, { message: 'Email does not exist.' });
+ }
+
+ const isMatch = await comparePassword(user, password, { compare: bcrypt.compare });
+ if (!isMatch) {
+ logError('Passport Local Strategy - Password does not match', { isMatch });
+ logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
+ return done(null, false, { message: 'Incorrect password.' });
+ }
+
+ const emailEnabled = checkEmailConfig();
+ const userCreatedAtTimestamp = Math.floor(new Date(user.createdAt).getTime() / 1000);
+
+ if (
+ !emailEnabled &&
+ !user.emailVerified &&
+ userCreatedAtTimestamp < verificationEnabledTimestamp
+ ) {
+ await updateUser(user._id, { emailVerified: true });
+ user.emailVerified = true;
+ }
+
+ const unverifiedAllowed = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN);
+ if (user.expiresAt && unverifiedAllowed) {
+ await updateUser(user._id, {});
+ }
+
+ if (!user.emailVerified && !unverifiedAllowed) {
+ logError('Passport Local Strategy - Email not verified', { email });
+ logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
+ return done(null, user, { message: 'Email not verified.' });
+ }
+
+ logger.info(`[Login] [Login successful] [Username: ${email}] [Request-IP: ${req.ip}]`);
+ return done(null, user);
+ } catch (err) {
+ return done(err);
+ }
+}
+
+function logError(title, parameters) {
+ const entries = Object.entries(parameters).map(([name, value]) => ({ name, value }));
+ logger.error(title, { parameters: entries });
+}
+
+module.exports = () =>
+ new PassportLocalStrategy(
+ {
+ usernameField: 'email',
+ passwordField: 'password',
+ session: false,
+ passReqToCallback: true,
+ },
+ passportLogin,
+ );
diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js
new file mode 100644
index 0000000..9b9c964
--- /dev/null
+++ b/api/strategies/openIdJwtStrategy.js
@@ -0,0 +1,178 @@
+const cookies = require('cookie');
+const jwksRsa = require('jwks-rsa');
+const { logger } = require('@librechat/data-schemas');
+const { SystemRoles } = require('librechat-data-provider');
+const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
+const {
+ isEnabled,
+ findOpenIDUser,
+ getOpenIdEmail,
+ getOpenIdIssuer,
+ normalizeOpenIdIssuer,
+ getHttpsProxyAgent,
+ math,
+} = require('@librechat/api');
+const { updateUser, findUser } = require('~/models');
+
+const getOpenIdJwtAudience = () => {
+ const parsedAudience = (process.env.OPENID_AUDIENCE ?? '')
+ .split(',')
+ .map((value) => value.trim())
+ .filter(Boolean);
+ const audiences = [process.env.OPENID_CLIENT_ID, ...parsedAudience].filter(Boolean);
+ const uniqueAudiences = [...new Set(audiences)];
+
+ return uniqueAudiences.length > 1 ? uniqueAudiences : uniqueAudiences[0];
+};
+
+const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+const issuerMatchesTemplate = (expectedIssuer, actualIssuer) => {
+ if (!expectedIssuer.includes('{tenantid}')) {
+ return false;
+ }
+
+ const escapedTemplate = expectedIssuer.split('{tenantid}').map(escapeRegExp).join('[^/]+');
+ return new RegExp(`^${escapedTemplate}$`).test(actualIssuer);
+};
+
+const isOpenIdIssuerAllowed = (payload, openIdConfig) => {
+ const actualIssuer = normalizeOpenIdIssuer(payload?.iss);
+ const expectedIssuer = normalizeOpenIdIssuer(openIdConfig.serverMetadata().issuer);
+
+ if (!actualIssuer || !expectedIssuer) {
+ return false;
+ }
+
+ return actualIssuer === expectedIssuer || issuerMatchesTemplate(expectedIssuer, actualIssuer);
+};
+
+/**
+ * @function openIdJwtLogin
+ * @param {import('openid-client').Configuration} openIdConfig - Configuration object for the JWT strategy.
+ * @returns {JwtStrategy}
+ * @description This function creates a JWT strategy for OpenID authentication.
+ * It uses the jwks-rsa library to retrieve the signing key from a JWKS endpoint.
+ * The strategy extracts the JWT from the Authorization header as a Bearer token.
+ * The JWT is then verified using the signing key, and the user is retrieved from the database.
+ *
+ * Includes email fallback mechanism:
+ * 1. Primary lookup: Search user by openidId (sub claim)
+ * 2. Fallback lookup: If not found, search by email claim
+ * 3. User migration: If found by email without openidId, migrate the user by adding openidId
+ * 4. Provider validation: Ensures users registered with other providers cannot use OpenID
+ *
+ * This enables seamless migration for existing users when SharePoint integration is enabled.
+ */
+const openIdJwtLogin = (openIdConfig) => {
+ let jwksRsaOptions = {
+ cache: process.env.OPENID_JWKS_URL_CACHE_ENABLED
+ ? isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED)
+ : true,
+ cacheMaxAge: math(process.env.OPENID_JWKS_URL_CACHE_TIME, 60000),
+ jwksUri: openIdConfig.serverMetadata().jwks_uri,
+ };
+
+ const requestAgent = getHttpsProxyAgent(jwksRsaOptions.jwksUri);
+ if (requestAgent) {
+ jwksRsaOptions.requestAgent = requestAgent;
+ }
+
+ return new JwtStrategy(
+ {
+ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
+ secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions),
+ audience: getOpenIdJwtAudience(),
+ passReqToCallback: true,
+ },
+ /**
+ * @param {import('@librechat/api').ServerRequest} req
+ * @param {import('openid-client').IDToken} payload
+ * @param {import('passport-jwt').VerifyCallback} done
+ */
+ async (req, payload, done) => {
+ try {
+ if (!isOpenIdIssuerAllowed(payload, openIdConfig)) {
+ done(null, false, { message: 'Invalid issuer' });
+ return;
+ }
+
+ const authHeader = req.headers.authorization;
+ const rawToken = authHeader?.replace('Bearer ', '');
+ const openidIssuer = getOpenIdIssuer(payload, openIdConfig);
+
+ const { user, error, migration } = await findOpenIDUser({
+ findUser,
+ email: payload ? getOpenIdEmail(payload) : undefined,
+ openidId: payload?.sub,
+ openidIssuer,
+ idOnTheSource: payload?.oid,
+ strategyName: 'openIdJwtLogin',
+ });
+
+ if (error) {
+ done(null, false, { message: error });
+ return;
+ }
+
+ if (user) {
+ user.id = user._id.toString();
+ /** Absent on the full doc means local user; null skips getUserPrincipals' fallback lookup */
+ user.idOnTheSource ??= null;
+
+ const updateData = {};
+ if (migration) {
+ updateData.provider = 'openid';
+ updateData.openidId = payload?.sub;
+ if (openidIssuer) {
+ updateData.openidIssuer = openidIssuer;
+ }
+ }
+ if (!user.role) {
+ user.role = SystemRoles.USER;
+ updateData.role = user.role;
+ }
+
+ if (Object.keys(updateData).length > 0) {
+ await updateUser(user.id, updateData);
+ }
+
+ /** Read tokens from session (server-side) to avoid large cookie issues */
+ const sessionTokens = req.session?.openidTokens;
+ let accessToken = sessionTokens?.accessToken;
+ let idToken = sessionTokens?.idToken;
+ let refreshToken = sessionTokens?.refreshToken;
+
+ /** Fallback to cookies for backward compatibility */
+ if (!accessToken || !refreshToken || !idToken) {
+ const cookieHeader = req.headers.cookie;
+ const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {};
+ accessToken = accessToken || parsedCookies.openid_access_token;
+ idToken = idToken || parsedCookies.openid_id_token;
+ refreshToken = refreshToken || parsedCookies.refreshToken;
+ }
+
+ user.federatedTokens = {
+ access_token: accessToken || rawToken,
+ id_token: idToken,
+ refresh_token: refreshToken,
+ expires_at: payload.exp,
+ };
+
+ done(null, user);
+ } else {
+ logger.warn(
+ '[openIdJwtLogin] openId JwtStrategy => no user found with the sub claims: ' +
+ payload?.sub +
+ (payload?.email ? ' or email: ' + payload.email : ''),
+ );
+ done(null, false);
+ }
+ } catch (err) {
+ done(err, false);
+ }
+ },
+ );
+};
+
+module.exports = openIdJwtLogin;
diff --git a/api/strategies/openIdJwtStrategy.spec.js b/api/strategies/openIdJwtStrategy.spec.js
new file mode 100644
index 0000000..4121d64
--- /dev/null
+++ b/api/strategies/openIdJwtStrategy.spec.js
@@ -0,0 +1,582 @@
+const { SystemRoles } = require('librechat-data-provider');
+
+// --- Capture JwtStrategy inputs ---
+let capturedStrategyOptions;
+let capturedVerifyCallback;
+jest.mock('passport-jwt', () => ({
+ Strategy: jest.fn((opts, verifyCallback) => {
+ capturedStrategyOptions = opts;
+ capturedVerifyCallback = verifyCallback;
+ return { name: 'jwt' };
+ }),
+ ExtractJwt: {
+ fromAuthHeaderAsBearerToken: jest.fn(() => 'mock-extractor'),
+ },
+}));
+jest.mock('jwks-rsa', () => ({
+ passportJwtSecret: jest.fn(() => 'mock-secret-provider'),
+}));
+jest.mock('https-proxy-agent', () => ({
+ HttpsProxyAgent: jest.fn(),
+}));
+jest.mock('@librechat/data-schemas', () => ({
+ logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
+}));
+jest.mock('@librechat/api', () => ({
+ isEnabled: jest.fn(() => false),
+ findOpenIDUser: jest.fn(),
+ getOpenIdEmail: jest.requireActual('@librechat/api').getOpenIdEmail,
+ getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'),
+ normalizeOpenIdIssuer: jest.requireActual('@librechat/api').normalizeOpenIdIssuer,
+ getHttpsProxyAgent: jest.fn(() => undefined),
+ math: jest.fn((val, fallback) => fallback),
+}));
+jest.mock('~/models', () => ({
+ findUser: jest.fn(),
+ updateUser: jest.fn(),
+}));
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn(() => ({
+ saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
+ })),
+}));
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn().mockResolvedValue({}),
+}));
+jest.mock('~/cache/getLogStores', () =>
+ jest.fn().mockReturnValue({ get: jest.fn(), set: jest.fn() }),
+);
+
+const { findOpenIDUser } = require('@librechat/api');
+const openIdJwtLogin = require('./openIdJwtStrategy');
+const { findUser, updateUser } = require('~/models');
+
+function withEnv(env, callback) {
+ const previous = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]));
+ Object.entries(env).forEach(([key, value]) => {
+ if (value === undefined) {
+ delete process.env[key];
+ return;
+ }
+ process.env[key] = value;
+ });
+ try {
+ callback();
+ } finally {
+ Object.entries(previous).forEach(([key, value]) => {
+ if (value === undefined) {
+ delete process.env[key];
+ return;
+ }
+ process.env[key] = value;
+ });
+ }
+}
+
+// Helper: build a mock openIdConfig
+const mockOpenIdConfig = {
+ serverMetadata: () => ({
+ issuer: 'https://issuer.example.com',
+ jwks_uri: 'https://example.com/.well-known/jwks.json',
+ }),
+};
+
+// Helper: invoke the captured verify callback
+async function invokeVerify(req, payload) {
+ return new Promise((resolve, reject) => {
+ capturedVerifyCallback(req, payload, (err, user, info) => {
+ if (err) {
+ return reject(err);
+ }
+ resolve({ user, info });
+ });
+ });
+}
+
+describe('openIdJwtStrategy – token validation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('requires OpenID JWTs to match the configured client audience and issuer', () => {
+ withEnv({ OPENID_CLIENT_ID: 'librechat-client-id', OPENID_AUDIENCE: undefined }, () => {
+ openIdJwtLogin(mockOpenIdConfig);
+ });
+
+ expect(capturedStrategyOptions).toMatchObject({
+ audience: 'librechat-client-id',
+ passReqToCallback: true,
+ });
+ expect(capturedStrategyOptions).not.toHaveProperty('issuer');
+ });
+
+ it('also accepts OPENID_AUDIENCE for providers that mint resource-bound JWTs', () => {
+ withEnv({ OPENID_CLIENT_ID: 'librechat-client-id', OPENID_AUDIENCE: 'api://librechat' }, () => {
+ openIdJwtLogin(mockOpenIdConfig);
+ });
+
+ expect(capturedStrategyOptions).toMatchObject({
+ audience: ['librechat-client-id', 'api://librechat'],
+ });
+ });
+
+ it('uses a single OPENID_AUDIENCE value when no client ID is configured', () => {
+ withEnv({ OPENID_CLIENT_ID: undefined, OPENID_AUDIENCE: 'librechat' }, () => {
+ openIdJwtLogin(mockOpenIdConfig);
+ });
+
+ expect(capturedStrategyOptions.audience).toBe('librechat');
+ });
+
+ it('splits comma-separated OPENID_AUDIENCE values into multiple accepted audiences', () => {
+ withEnv({ OPENID_CLIENT_ID: undefined, OPENID_AUDIENCE: 'librechat,control-plane-web' }, () => {
+ openIdJwtLogin(mockOpenIdConfig);
+ });
+
+ expect(capturedStrategyOptions.audience).toEqual(['librechat', 'control-plane-web']);
+ });
+
+ it('trims whitespace around comma-separated OPENID_AUDIENCE values', () => {
+ withEnv(
+ { OPENID_CLIENT_ID: undefined, OPENID_AUDIENCE: ' librechat , control-plane-web ' },
+ () => {
+ openIdJwtLogin(mockOpenIdConfig);
+ },
+ );
+
+ expect(capturedStrategyOptions.audience).toEqual(['librechat', 'control-plane-web']);
+ });
+
+ it('falls back to OPENID_CLIENT_ID when OPENID_AUDIENCE is empty', () => {
+ withEnv({ OPENID_CLIENT_ID: 'client-id-only', OPENID_AUDIENCE: '' }, () => {
+ openIdJwtLogin(mockOpenIdConfig);
+ });
+
+ expect(capturedStrategyOptions.audience).toBe('client-id-only');
+ });
+
+ it('combines OPENID_CLIENT_ID with comma-separated OPENID_AUDIENCE values and deduplicates', () => {
+ withEnv(
+ { OPENID_CLIENT_ID: 'librechat', OPENID_AUDIENCE: 'librechat,control-plane-web' },
+ () => {
+ openIdJwtLogin(mockOpenIdConfig);
+ },
+ );
+
+ expect(capturedStrategyOptions.audience).toEqual(['librechat', 'control-plane-web']);
+ });
+
+ it('rejects OpenID JWTs whose issuer does not match the configured issuer', async () => {
+ findOpenIDUser.mockResolvedValue({ user: null, error: null, migration: false });
+ openIdJwtLogin(mockOpenIdConfig);
+
+ const req = { headers: { authorization: 'Bearer tok' }, session: {} };
+ const { user, info } = await invokeVerify(req, {
+ sub: 'oidc-123',
+ email: 'test@example.com',
+ iss: 'https://other-issuer.example.com',
+ exp: 9999999999,
+ });
+
+ expect(user).toBe(false);
+ expect(info).toEqual({ message: 'Invalid issuer' });
+ expect(findOpenIDUser).not.toHaveBeenCalled();
+ });
+
+ it('allows Microsoft Entra tenant issuer values for tenant-independent metadata', async () => {
+ const entraConfig = {
+ serverMetadata: () => ({
+ issuer: 'https://login.microsoftonline.com/{tenantid}/v2.0',
+ jwks_uri: 'https://login.microsoftonline.com/common/discovery/v2.0/keys',
+ }),
+ };
+ const user = {
+ _id: { toString: () => 'user-abc' },
+ role: SystemRoles.USER,
+ provider: 'openid',
+ };
+ findOpenIDUser.mockResolvedValue({ user, error: null, migration: false });
+ updateUser.mockResolvedValue({});
+ openIdJwtLogin(entraConfig);
+
+ const req = { headers: { authorization: 'Bearer tok' }, session: {} };
+ const { user: result } = await invokeVerify(req, {
+ sub: 'oidc-123',
+ email: 'test@example.com',
+ iss: 'https://login.microsoftonline.com/11111111-2222-3333-4444-555555555555/v2.0',
+ exp: 9999999999,
+ });
+
+ expect(result).toBeTruthy();
+ expect(findOpenIDUser).toHaveBeenCalled();
+ });
+});
+
+describe('openIdJwtStrategy – token source handling', () => {
+ const baseUser = {
+ _id: { toString: () => 'user-abc' },
+ role: SystemRoles.USER,
+ provider: 'openid',
+ };
+
+ const payload = {
+ sub: 'oidc-123',
+ email: 'test@example.com',
+ iss: 'https://issuer.example.com',
+ exp: 9999999999,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ findOpenIDUser.mockResolvedValue({ user: { ...baseUser }, error: null, migration: false });
+ updateUser.mockResolvedValue({});
+
+ // Initialize the strategy so capturedVerifyCallback is set
+ openIdJwtLogin(mockOpenIdConfig);
+ });
+
+ it('should read all tokens from session when available', async () => {
+ const req = {
+ headers: { authorization: 'Bearer raw-bearer-token' },
+ session: {
+ openidTokens: {
+ accessToken: 'session-access',
+ idToken: 'session-id',
+ refreshToken: 'session-refresh',
+ },
+ },
+ };
+
+ const { user } = await invokeVerify(req, payload);
+
+ expect(user.federatedTokens).toEqual({
+ access_token: 'session-access',
+ id_token: 'session-id',
+ refresh_token: 'session-refresh',
+ expires_at: payload.exp,
+ });
+ });
+
+ it('should fall back to cookies when session is absent', async () => {
+ const req = {
+ headers: {
+ authorization: 'Bearer raw-bearer-token',
+ cookie:
+ 'openid_access_token=cookie-access; openid_id_token=cookie-id; refreshToken=cookie-refresh',
+ },
+ };
+
+ const { user } = await invokeVerify(req, payload);
+
+ expect(user.federatedTokens).toEqual({
+ access_token: 'cookie-access',
+ id_token: 'cookie-id',
+ refresh_token: 'cookie-refresh',
+ expires_at: payload.exp,
+ });
+ });
+
+ it('should fall back to cookie for idToken only when session lacks it', async () => {
+ const req = {
+ headers: {
+ authorization: 'Bearer raw-bearer-token',
+ cookie: 'openid_id_token=cookie-id',
+ },
+ session: {
+ openidTokens: {
+ accessToken: 'session-access',
+ // idToken intentionally missing
+ refreshToken: 'session-refresh',
+ },
+ },
+ };
+
+ const { user } = await invokeVerify(req, payload);
+
+ expect(user.federatedTokens).toEqual({
+ access_token: 'session-access',
+ id_token: 'cookie-id',
+ refresh_token: 'session-refresh',
+ expires_at: payload.exp,
+ });
+ });
+
+ it('should use raw Bearer token as access_token fallback when neither session nor cookie has one', async () => {
+ const req = {
+ headers: {
+ authorization: 'Bearer raw-bearer-token',
+ cookie: 'openid_id_token=cookie-id; refreshToken=cookie-refresh',
+ },
+ };
+
+ const { user } = await invokeVerify(req, payload);
+
+ expect(user.federatedTokens.access_token).toBe('raw-bearer-token');
+ expect(user.federatedTokens.id_token).toBe('cookie-id');
+ expect(user.federatedTokens.refresh_token).toBe('cookie-refresh');
+ });
+
+ it('should set id_token to undefined when not available in session or cookies', async () => {
+ const req = {
+ headers: {
+ authorization: 'Bearer raw-bearer-token',
+ cookie: 'openid_access_token=cookie-access; refreshToken=cookie-refresh',
+ },
+ };
+
+ const { user } = await invokeVerify(req, payload);
+
+ expect(user.federatedTokens.access_token).toBe('cookie-access');
+ expect(user.federatedTokens.id_token).toBeUndefined();
+ expect(user.federatedTokens.refresh_token).toBe('cookie-refresh');
+ });
+
+ it('should keep id_token and access_token as distinct values from cookies', async () => {
+ const req = {
+ headers: {
+ authorization: 'Bearer raw-bearer-token',
+ cookie:
+ 'openid_access_token=the-access-token; openid_id_token=the-id-token; refreshToken=the-refresh',
+ },
+ };
+
+ const { user } = await invokeVerify(req, payload);
+
+ expect(user.federatedTokens.access_token).toBe('the-access-token');
+ expect(user.federatedTokens.id_token).toBe('the-id-token');
+ expect(user.federatedTokens.access_token).not.toBe(user.federatedTokens.id_token);
+ });
+});
+
+describe('openIdJwtStrategy – idOnTheSource boundary coercion', () => {
+ const payload = {
+ sub: 'oidc-123',
+ email: 'test@example.com',
+ iss: 'https://issuer.example.com',
+ exp: 9999999999,
+ };
+ const req = { headers: { authorization: 'Bearer tok' }, session: {} };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ updateUser.mockResolvedValue({});
+ openIdJwtLogin(mockOpenIdConfig);
+ });
+
+ it('coerces missing idOnTheSource to null', async () => {
+ findOpenIDUser.mockResolvedValue({
+ user: { _id: { toString: () => 'user-abc' }, role: SystemRoles.USER, provider: 'openid' },
+ error: null,
+ migration: false,
+ });
+
+ const { user } = await invokeVerify(req, payload);
+
+ expect(user.idOnTheSource).toBeNull();
+ });
+
+ it('preserves a stored idOnTheSource', async () => {
+ findOpenIDUser.mockResolvedValue({
+ user: {
+ _id: { toString: () => 'user-abc' },
+ role: SystemRoles.USER,
+ provider: 'openid',
+ idOnTheSource: 'entra-oid-123',
+ },
+ error: null,
+ migration: false,
+ });
+
+ const { user } = await invokeVerify(req, payload);
+
+ expect(user.idOnTheSource).toBe('entra-oid-123');
+ });
+});
+
+describe('openIdJwtStrategy – OPENID_EMAIL_CLAIM', () => {
+ const payload = {
+ sub: 'oidc-123',
+ email: 'test@example.com',
+ preferred_username: 'testuser',
+ upn: 'test@corp.example.com',
+ iss: 'https://issuer.example.com',
+ exp: 9999999999,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ delete process.env.OPENID_EMAIL_CLAIM;
+
+ // Use real findOpenIDUser so it delegates to the findUser mock
+ const realFindOpenIDUser = jest.requireActual('@librechat/api').findOpenIDUser;
+ findOpenIDUser.mockImplementation(realFindOpenIDUser);
+
+ findUser.mockResolvedValue(null);
+ updateUser.mockResolvedValue({});
+
+ openIdJwtLogin(mockOpenIdConfig);
+ });
+
+ afterEach(() => {
+ delete process.env.OPENID_EMAIL_CLAIM;
+ });
+
+ it('should use the default email when OPENID_EMAIL_CLAIM is not set', async () => {
+ const existingUser = {
+ _id: 'user-id-1',
+ provider: 'openid',
+ openidId: payload.sub,
+ openidIssuer: 'https://issuer.example.com',
+ email: payload.email,
+ role: SystemRoles.USER,
+ };
+ findUser.mockImplementation(async (query) => {
+ if (query.openidId === payload.sub && query.openidIssuer === 'https://issuer.example.com') {
+ return existingUser;
+ }
+ return null;
+ });
+
+ const req = { headers: { authorization: 'Bearer tok' }, session: {} };
+ await invokeVerify(req, payload);
+
+ expect(findUser).toHaveBeenCalledWith({
+ openidId: payload.sub,
+ openidIssuer: 'https://issuer.example.com',
+ });
+ });
+
+ it('should use OPENID_EMAIL_CLAIM when set for email lookup', async () => {
+ process.env.OPENID_EMAIL_CLAIM = 'upn';
+ findUser.mockResolvedValue(null);
+
+ const req = { headers: { authorization: 'Bearer tok' }, session: {} };
+ const { user } = await invokeVerify(req, payload);
+
+ expect(findUser).toHaveBeenCalledTimes(2);
+ expect(findUser.mock.calls[0][0]).toEqual({
+ openidId: payload.sub,
+ openidIssuer: 'https://issuer.example.com',
+ });
+ expect(findUser.mock.calls[1][0]).toEqual({
+ email: 'test@corp.example.com',
+ });
+ expect(user).toBe(false);
+ });
+
+ it('should fall back to default chain when OPENID_EMAIL_CLAIM points to missing claim', async () => {
+ process.env.OPENID_EMAIL_CLAIM = 'nonexistent_claim';
+ findUser.mockResolvedValue(null);
+
+ const req = { headers: { authorization: 'Bearer tok' }, session: {} };
+ const { user } = await invokeVerify(req, payload);
+
+ expect(findUser).toHaveBeenCalledWith({ email: payload.email });
+ expect(user).toBe(false);
+ });
+
+ it('should reject login when email fallback finds user with mismatched openidId', async () => {
+ const emailMatchWithDifferentSub = {
+ _id: 'user-id-2',
+ provider: 'openid',
+ openidId: 'different-sub',
+ email: payload.email,
+ role: SystemRoles.USER,
+ };
+
+ findUser.mockImplementation(async (query) => {
+ if (query.$or) {
+ return null;
+ }
+ if (query.email === payload.email) {
+ return emailMatchWithDifferentSub;
+ }
+ return null;
+ });
+
+ const req = { headers: { authorization: 'Bearer tok' }, session: {} };
+ const { user, info } = await invokeVerify(req, payload);
+
+ expect(user).toBe(false);
+ expect(info).toEqual({ message: 'auth_failed' });
+ });
+
+ it('should trim whitespace from OPENID_EMAIL_CLAIM', async () => {
+ process.env.OPENID_EMAIL_CLAIM = ' upn ';
+ findUser.mockResolvedValue(null);
+
+ const req = { headers: { authorization: 'Bearer tok' }, session: {} };
+ await invokeVerify(req, payload);
+
+ expect(findUser).toHaveBeenCalledWith({ email: 'test@corp.example.com' });
+ });
+
+ it('should ignore empty string OPENID_EMAIL_CLAIM and use default fallback', async () => {
+ process.env.OPENID_EMAIL_CLAIM = '';
+ findUser.mockResolvedValue(null);
+
+ const req = { headers: { authorization: 'Bearer tok' }, session: {} };
+ await invokeVerify(req, payload);
+
+ expect(findUser).toHaveBeenCalledWith({ email: payload.email });
+ });
+
+ it('should ignore whitespace-only OPENID_EMAIL_CLAIM and use default fallback', async () => {
+ process.env.OPENID_EMAIL_CLAIM = ' ';
+ findUser.mockResolvedValue(null);
+
+ const req = { headers: { authorization: 'Bearer tok' }, session: {} };
+ await invokeVerify(req, payload);
+
+ expect(findUser).toHaveBeenCalledWith({ email: payload.email });
+ });
+
+ it('should resolve undefined email when payload is null', async () => {
+ const req = { headers: { authorization: 'Bearer tok' }, session: {} };
+ const { user } = await invokeVerify(req, null);
+
+ expect(user).toBe(false);
+ });
+
+ it('should attempt email lookup via preferred_username fallback when email claim is absent', async () => {
+ const payloadNoEmail = {
+ sub: 'oidc-new-sub',
+ preferred_username: 'legacy@corp.com',
+ upn: 'legacy@corp.com',
+ iss: 'https://issuer.example.com',
+ exp: 9999999999,
+ };
+
+ const legacyUser = {
+ _id: 'legacy-db-id',
+ email: 'legacy@corp.com',
+ openidId: null,
+ role: SystemRoles.USER,
+ };
+
+ findUser.mockImplementation(async (query) => {
+ if (query.$or) {
+ return null;
+ }
+ if (query.email === 'legacy@corp.com') {
+ return legacyUser;
+ }
+ return null;
+ });
+
+ const req = { headers: { authorization: 'Bearer tok' }, session: {} };
+ const { user } = await invokeVerify(req, payloadNoEmail);
+
+ expect(findUser).toHaveBeenCalledTimes(2);
+ expect(findUser.mock.calls[1][0]).toEqual({ email: 'legacy@corp.com' });
+ expect(user).toBeTruthy();
+ expect(updateUser).toHaveBeenCalledWith(
+ 'legacy-db-id',
+ expect.objectContaining({
+ provider: 'openid',
+ openidId: payloadNoEmail.sub,
+ openidIssuer: 'https://issuer.example.com',
+ }),
+ );
+ });
+});
diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js
new file mode 100644
index 0000000..d9c2684
--- /dev/null
+++ b/api/strategies/openidStrategy.js
@@ -0,0 +1,987 @@
+const undici = require('undici');
+const { get } = require('lodash');
+const passport = require('passport');
+const client = require('openid-client');
+const jwtDecode = require('jsonwebtoken/decode');
+const { hashToken, logger, tenantStorage } = require('@librechat/data-schemas');
+const { Strategy: OpenIDStrategy } = require('openid-client/passport');
+const { CacheKeys, ErrorTypes, SystemRoles } = require('librechat-data-provider');
+const {
+ isEnabled,
+ logHeaders,
+ safeStringify,
+ findOpenIDUser,
+ getOpenIdEmail,
+ getOpenIdIssuer,
+ getBalanceConfig,
+ selectOpenIdRole,
+ getAvatarSaveParams,
+ isEmailDomainAllowed,
+ getAvatarFileStrategy,
+ resolveAppConfigForUser,
+ getOpenIdProxyDispatcher,
+ getOpenIdRoleSyncOptions,
+ getOpenIdRolesForOpenIdSync,
+ getLibreChatRolesForOpenIdSync,
+} = require('@librechat/api');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { resizeAvatar } = require('~/server/services/Files/images/avatar');
+const { findUser, createUser, updateUser, findRolesByNames } = require('~/models');
+const { getAppConfig } = require('~/server/services/Config');
+const getLogStores = require('~/cache/getLogStores');
+
+/**
+ * @typedef {import('openid-client').ClientMetadata} ClientMetadata
+ * @typedef {import('openid-client').Configuration} Configuration
+ **/
+
+/**
+ * @param {string} url
+ * @param {client.CustomFetchOptions} options
+ */
+async function customFetch(url, options) {
+ const urlStr = url.toString();
+ logger.debug(`[openidStrategy] Request to: ${urlStr}`);
+ const debugOpenId = isEnabled(process.env.DEBUG_OPENID_REQUESTS);
+ if (debugOpenId) {
+ logger.debug(`[openidStrategy] Request method: ${options.method || 'GET'}`);
+ logger.debug(`[openidStrategy] Request headers: ${logHeaders(options.headers)}`);
+ if (options.body) {
+ let bodyForLogging = '';
+ if (options.body instanceof URLSearchParams) {
+ bodyForLogging = options.body.toString();
+ } else if (typeof options.body === 'string') {
+ bodyForLogging = options.body;
+ } else {
+ bodyForLogging = safeStringify(options.body);
+ }
+ logger.debug(`[openidStrategy] Request body: ${bodyForLogging}`);
+ }
+ }
+
+ try {
+ /** @type {undici.RequestInit} */
+ let fetchOptions = options;
+ const dispatcher = getOpenIdProxyDispatcher();
+ if (dispatcher) {
+ logger.info('[openidStrategy] proxy dispatcher configured');
+ fetchOptions = {
+ ...options,
+ dispatcher,
+ };
+ }
+
+ const response = await undici.fetch(url, fetchOptions);
+
+ if (debugOpenId) {
+ logger.debug(`[openidStrategy] Response status: ${response.status} ${response.statusText}`);
+ logger.debug(`[openidStrategy] Response headers: ${logHeaders(response.headers)}`);
+ }
+
+ if (response.status === 200 && response.headers.has('www-authenticate')) {
+ const wwwAuth = response.headers.get('www-authenticate');
+ logger.warn(`[openidStrategy] Non-standard WWW-Authenticate header found in successful response (200 OK): ${wwwAuth}.
+This violates RFC 7235 and may cause issues with strict OAuth clients. Removing header for compatibility.`);
+
+ /** Cloned response without the WWW-Authenticate header */
+ const responseBody = await response.arrayBuffer();
+ const newHeaders = new Headers();
+ for (const [key, value] of response.headers.entries()) {
+ if (key.toLowerCase() !== 'www-authenticate') {
+ newHeaders.append(key, value);
+ }
+ }
+
+ return new Response(responseBody, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: newHeaders,
+ });
+ }
+
+ return response;
+ } catch (error) {
+ logger.error(`[openidStrategy] Fetch error: ${error.message}`);
+ throw error;
+ }
+}
+
+/** @typedef {Configuration | null} */
+let openidConfig = null;
+
+const getOpenIdAuthorizationAudience = () =>
+ (process.env.OPENID_AUDIENCE ?? '')
+ .split(',')
+ .map((value) => value.trim())
+ .find(Boolean);
+
+/**
+ * Custom OpenID Strategy
+ *
+ * Note: Originally overrode currentUrl() to work around Express 4's req.host not including port.
+ * With Express 5, req.host now includes the port by default, but we continue to use DOMAIN_SERVER
+ * for consistency and explicit configuration control.
+ * More info: https://github.com/panva/openid-client/pull/713
+ */
+class CustomOpenIDStrategy extends OpenIDStrategy {
+ currentUrl(req) {
+ const hostAndProtocol = process.env.DOMAIN_SERVER;
+ return new URL(`${hostAndProtocol}${req.originalUrl ?? req.url}`);
+ }
+
+ authorizationRequestParams(req, options) {
+ const params = super.authorizationRequestParams(req, options);
+ if (options?.state && !params.has('state')) {
+ params.set('state', options.state);
+ }
+
+ const authorizationAudience = getOpenIdAuthorizationAudience();
+ if (authorizationAudience) {
+ params.set('audience', authorizationAudience);
+ logger.debug(
+ `[openidStrategy] Adding audience to authorization request: ${authorizationAudience}`,
+ );
+ }
+
+ /** Generate nonce for federated providers that require it */
+ const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE);
+ if (shouldGenerateNonce && !params.has('nonce') && this._sessionKey) {
+ const crypto = require('crypto');
+ const nonce = crypto.randomBytes(16).toString('hex');
+ params.set('nonce', nonce);
+ logger.debug('[openidStrategy] Generated nonce for federated provider:', nonce);
+ }
+
+ return params;
+ }
+}
+
+/**
+ * Exchange the access token for a new access token using the on-behalf-of flow if required.
+ * @param {Configuration} config
+ * @param {string} accessToken access token to be exchanged if necessary
+ * @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
+ * @param {boolean} fromCache - Indicates whether to use cached tokens.
+ * @returns {Promise} The new access token if exchanged, otherwise the original access token.
+ */
+const exchangeAccessTokenIfNeeded = async (config, accessToken, sub, fromCache = false) => {
+ const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
+ const onBehalfFlowRequired = isEnabled(process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED);
+ if (onBehalfFlowRequired) {
+ if (fromCache) {
+ const cachedToken = await tokensCache.get(sub);
+ if (cachedToken) {
+ return cachedToken.access_token;
+ }
+ }
+ const grantResponse = await client.genericGrantRequest(
+ config,
+ 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+ {
+ scope: process.env.OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE || 'user.read',
+ assertion: accessToken,
+ requested_token_use: 'on_behalf_of',
+ },
+ );
+ await tokensCache.set(
+ sub,
+ {
+ access_token: grantResponse.access_token,
+ },
+ grantResponse.expires_in * 1000,
+ );
+ return grantResponse.access_token;
+ }
+ return accessToken;
+};
+
+/**
+ * get user info from openid provider
+ * @param {Configuration} config
+ * @param {string} accessToken access token
+ * @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
+ * @returns {Promise}
+ */
+const getUserInfo = async (config, accessToken, sub) => {
+ try {
+ const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub);
+ return await client.fetchUserInfo(config, exchangedAccessToken, sub);
+ } catch (error) {
+ logger.error('[openidStrategy] getUserInfo: Error fetching user info:', error);
+ return null;
+ }
+};
+
+function getUrlOrigin(value) {
+ try {
+ return new URL(value).origin;
+ } catch {
+ return null;
+ }
+}
+
+function getOpenIDAvatarAuthorizedOrigins(config) {
+ const metadata = config?.serverMetadata?.() ?? {};
+ const metadataOrigins = [metadata.issuer, metadata.userinfo_endpoint]
+ .map(getUrlOrigin)
+ .filter(Boolean);
+ const configuredOrigins = (process.env.OPENID_AVATAR_AUTHORIZED_ORIGINS ?? '')
+ .split(/[\s,]+/)
+ .map(getUrlOrigin)
+ .filter(Boolean);
+
+ return new Set([...metadataOrigins, ...configuredOrigins]);
+}
+
+function shouldAuthorizeOpenIDAvatar(url, config) {
+ const origin = getUrlOrigin(url);
+ if (!origin) {
+ return false;
+ }
+
+ return getOpenIDAvatarAuthorizedOrigins(config).has(origin);
+}
+
+async function getOpenIDAvatarFetchOptions(url, config, accessToken, sub) {
+ if (!shouldAuthorizeOpenIDAvatar(url, config)) {
+ return undefined;
+ }
+
+ const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub, true);
+ return {
+ headers: {
+ Authorization: `Bearer ${exchangedAccessToken}`,
+ },
+ };
+}
+
+const resizeIdentityProviderAvatar = async (url, userId, config, accessToken, sub) => {
+ if (!url) {
+ return '';
+ }
+
+ try {
+ const fetchOptions = await getOpenIDAvatarFetchOptions(url, config, accessToken, sub);
+ const avatarParams = { userId, input: url };
+ if (fetchOptions) {
+ avatarParams.fetchOptions = fetchOptions;
+ }
+ return await resizeAvatar(avatarParams);
+ } catch (error) {
+ logger.error(
+ `[openidStrategy] resizeIdentityProviderAvatar: Error processing avatar at URL "${url}": ${error}`,
+ );
+ return '';
+ }
+};
+
+/**
+ * Determines the full name of a user based on OpenID userinfo and environment configuration.
+ *
+ * @param {Object} userinfo - The user information object from OpenID Connect
+ * @param {string} [userinfo.given_name] - The user's first name
+ * @param {string} [userinfo.family_name] - The user's last name
+ * @param {string} [userinfo.username] - The user's username
+ * @param {string} [userinfo.email] - The user's email address
+ * @returns {string} The determined full name of the user
+ */
+function getFullName(userinfo) {
+ if (process.env.OPENID_NAME_CLAIM) {
+ return userinfo[process.env.OPENID_NAME_CLAIM];
+ }
+
+ if (userinfo.given_name && userinfo.family_name) {
+ return `${userinfo.given_name} ${userinfo.family_name}`;
+ }
+
+ if (userinfo.given_name) {
+ return userinfo.given_name;
+ }
+
+ if (userinfo.family_name) {
+ return userinfo.family_name;
+ }
+
+ return userinfo.username || userinfo.email;
+}
+
+/**
+ * Converts an input into a string suitable for a username.
+ * If the input is a string, it will be returned as is.
+ * If the input is an array, elements will be joined with underscores.
+ * In case of undefined or other falsy values, a default value will be returned.
+ *
+ * @param {string | string[] | undefined} input - The input value to be converted into a username.
+ * @param {string} [defaultValue=''] - The default value to return if the input is falsy.
+ * @returns {string} The processed input as a string suitable for a username.
+ */
+function convertToUsername(input, defaultValue = '') {
+ if (typeof input === 'string') {
+ return input;
+ } else if (Array.isArray(input)) {
+ return input.join('_');
+ }
+
+ return defaultValue;
+}
+
+/**
+ * Exchange the access token for a Graph-scoped token using the On-Behalf-Of (OBO) flow.
+ *
+ * The original access token has the app's own audience (api://), which Microsoft Graph
+ * rejects. This exchange produces a token with audience https://graph.microsoft.com and the
+ * minimum delegated scope (User.Read) required by /me/getMemberObjects.
+ *
+ * Uses a dedicated cache key (`${sub}:overage`) to avoid collisions with other OBO exchanges
+ * in the codebase (userinfo, Graph principal search).
+ *
+ * @param {string} accessToken - The original access token from the OpenID tokenset
+ * @param {string} sub - The subject identifier for cache keying
+ * @returns {Promise} A Graph-scoped access token
+ * @see https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow
+ */
+async function exchangeTokenForOverage(accessToken, sub) {
+ if (!openidConfig) {
+ throw new Error('[openidStrategy] OpenID config not initialized; cannot exchange OBO token');
+ }
+
+ const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
+ const cacheKey = `${sub}:overage`;
+
+ const cached = await tokensCache.get(cacheKey);
+ if (cached?.access_token) {
+ logger.debug('[openidStrategy] Using cached Graph token for overage resolution');
+ return cached.access_token;
+ }
+
+ const grantResponse = await client.genericGrantRequest(
+ openidConfig,
+ 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+ {
+ scope: 'https://graph.microsoft.com/User.Read',
+ assertion: accessToken,
+ requested_token_use: 'on_behalf_of',
+ },
+ );
+
+ if (!grantResponse.access_token) {
+ throw new Error(
+ '[openidStrategy] OBO exchange succeeded but returned no access_token; cannot call Graph API',
+ );
+ }
+
+ const ttlMs =
+ Number.isFinite(grantResponse.expires_in) && grantResponse.expires_in > 0
+ ? grantResponse.expires_in * 1000
+ : 3600 * 1000;
+
+ await tokensCache.set(cacheKey, { access_token: grantResponse.access_token }, ttlMs);
+
+ return grantResponse.access_token;
+}
+
+/**
+ * Resolve Azure AD groups when group overage is in effect (groups moved to _claim_names/_claim_sources).
+ *
+ * NOTE: Microsoft recommends treating _claim_names/_claim_sources as a signal only and using Microsoft Graph
+ * to resolve group membership instead of calling the endpoint in _claim_sources directly.
+ *
+ * Before calling Graph, the access token is exchanged via the OBO flow to obtain a token with the
+ * correct audience (https://graph.microsoft.com) and User.Read scope.
+ *
+ * @param {string} accessToken - Access token from the OpenID tokenset (app audience)
+ * @param {string} sub - The subject identifier of the user (for OBO exchange and cache keying)
+ * @returns {Promise} Resolved group IDs or null on failure
+ * @see https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference#groups-overage-claim
+ * @see https://learn.microsoft.com/en-us/graph/api/directoryobject-getmemberobjects
+ */
+async function resolveGroupsFromOverage(accessToken, sub) {
+ try {
+ if (!accessToken) {
+ logger.error('[openidStrategy] Access token missing; cannot resolve group overage');
+ return null;
+ }
+
+ const graphToken = await exchangeTokenForOverage(accessToken, sub);
+
+ // Use /me/getMemberObjects so least-privileged delegated permission User.Read is sufficient
+ // when resolving the signed-in user's group membership.
+ const url = 'https://graph.microsoft.com/v1.0/me/getMemberObjects';
+
+ logger.debug(
+ `[openidStrategy] Detected group overage, resolving groups via Microsoft Graph getMemberObjects: ${url}`,
+ );
+
+ const fetchOptions = {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${graphToken}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ securityEnabledOnly: false }),
+ };
+
+ const dispatcher = getOpenIdProxyDispatcher();
+ if (dispatcher) {
+ fetchOptions.dispatcher = dispatcher;
+ }
+
+ const response = await undici.fetch(url, fetchOptions);
+ if (!response.ok) {
+ logger.error(
+ `[openidStrategy] Failed to resolve groups via Microsoft Graph getMemberObjects: HTTP ${response.status} ${response.statusText}`,
+ );
+ return null;
+ }
+
+ const data = await response.json();
+
+ const values = Array.isArray(data?.value) ? data.value : null;
+ if (!values) {
+ logger.error(
+ '[openidStrategy] Unexpected response format when resolving groups via Microsoft Graph getMemberObjects',
+ );
+ return null;
+ }
+ const groupIds = values.filter((id) => typeof id === 'string');
+
+ logger.debug(
+ `[openidStrategy] Successfully resolved ${groupIds.length} groups via Microsoft Graph getMemberObjects`,
+ );
+ return groupIds;
+ } catch (err) {
+ logger.error(
+ '[openidStrategy] Error resolving groups via Microsoft Graph getMemberObjects:',
+ err,
+ );
+ return null;
+ }
+}
+
+/**
+ * Resolve the source object (decoded token or userinfo) for a role check
+ * based on the configured token kind. Throws on invalid configuration so
+ * misconfiguration surfaces loudly instead of silently denying every login.
+ *
+ * @param {string} kind - One of 'access', 'id', or 'userinfo'
+ * @param {string} label - Human-readable label for error messages (e.g. 'required role')
+ * @param {Object} tokenset - The OpenID tokenset
+ * @param {Object} userinfo - Merged userinfo (id-token claims + UserInfo endpoint response)
+ */
+function getRoleSource(kind, label, tokenset, userinfo) {
+ if (kind === 'access') {
+ return jwtDecode(tokenset.access_token);
+ }
+ if (kind === 'id') {
+ return jwtDecode(tokenset.id_token);
+ }
+ if (kind === 'userinfo') {
+ return userinfo;
+ }
+ logger.error(
+ `[openidStrategy] Invalid ${label} token kind: ${kind}. Must be one of 'access', 'id', or 'userinfo'.`,
+ );
+ throw new Error(`Invalid ${label} token kind`);
+}
+
+/**
+ * Applies generic OpenID role sync to the request-local user before the existing final update.
+ */
+async function applyOpenIdRoleSync({
+ user,
+ username,
+ tokenset,
+ claims,
+ userinfo,
+ resolvedOverageGroups,
+}) {
+ const options = getOpenIdRoleSyncOptions();
+ if (!options.enabled) {
+ return;
+ }
+
+ if (user.role === SystemRoles.ADMIN) {
+ logger.info(
+ `[openidStrategy] OpenID role sync skipped for ${username}; existing ADMIN role is not managed by generic role sync`,
+ );
+ return;
+ }
+
+ const resolveGroupOverage = async () =>
+ resolvedOverageGroups || (await resolveGroupsFromOverage(tokenset.access_token, claims.sub));
+
+ const openIdRoleValues = await getOpenIdRolesForOpenIdSync({
+ options,
+ accessToken: tokenset.access_token,
+ idToken: tokenset.id_token,
+ claims,
+ userinfo,
+ decodeToken: jwtDecode,
+ resolveGroupOverage,
+ });
+ if (openIdRoleValues === undefined) {
+ logger.warn(
+ `[openidStrategy] OpenID role sync skipped; claim '${options.claim}' was not found, invalid, or unresolved`,
+ );
+ return;
+ }
+
+ const libreChatRoles = {
+ getRolesByNames: findRolesByNames,
+ rolePriority: options.rolePriority,
+ fallbackRole: options.fallbackRole,
+ logPrefix: '[openidStrategy]',
+ };
+
+ /** Role definitions are tenant-scoped, so validate configured roles in the matched user's tenant. */
+ const { rolePriority, fallbackRole } = user?.tenantId
+ ? await tenantStorage.run({ tenantId: user.tenantId }, async () =>
+ getLibreChatRolesForOpenIdSync(libreChatRoles),
+ )
+ : await getLibreChatRolesForOpenIdSync(libreChatRoles);
+ const result = selectOpenIdRole({
+ currentRole: user.role,
+ openIdRoleValues,
+ rolePriority,
+ fallbackRole,
+ });
+
+ if (!result.selectedRole || result.selectedRole === user.role) {
+ return;
+ }
+
+ logger.info(
+ `[openidStrategy] OpenID role sync updated role for ${username}: ${user.role || 'unset'} -> ${result.selectedRole}`,
+ );
+ user.role = result.selectedRole;
+}
+
+/**
+ * Process OpenID authentication tokenset and userinfo
+ * This is the core logic extracted from the passport strategy callback
+ * Can be reused by both the passport strategy and proxy authentication
+ *
+ * @param {Object} tokenset - The OpenID tokenset containing access_token, id_token, etc.
+ * @param {boolean} existingUsersOnly - If true, only existing users will be processed
+ * @returns {Promise} The authenticated user object with tokenset
+ */
+async function processOpenIDAuth(tokenset, existingUsersOnly = false) {
+ const claims = tokenset.claims ? tokenset.claims() : tokenset;
+ const userinfo = {
+ ...claims,
+ };
+
+ if (tokenset.access_token) {
+ const providerUserinfo = await getUserInfo(openidConfig, tokenset.access_token, claims.sub);
+ Object.assign(userinfo, providerUserinfo);
+ }
+
+ const email = getOpenIdEmail(userinfo);
+ const openidIssuer = getOpenIdIssuer(claims, openidConfig);
+
+ const baseConfig = await getAppConfig({ baseOnly: true });
+ if (!isEmailDomainAllowed(email, baseConfig?.registration?.allowedDomains)) {
+ logger.error(
+ `[OpenID Strategy] Authentication blocked - email domain not allowed [Identifier: ${email}]`,
+ );
+ throw new Error('Email domain not allowed');
+ }
+
+ const result = await findOpenIDUser({
+ findUser,
+ email: email,
+ openidId: claims.sub || userinfo.sub,
+ openidIssuer,
+ idOnTheSource: claims.oid || userinfo.oid,
+ strategyName: 'openidStrategy',
+ });
+ let user = result.user;
+ const error = result.error;
+
+ if (error) {
+ throw new Error(ErrorTypes.AUTH_FAILED);
+ }
+
+ const appConfig = user?.tenantId ? await resolveAppConfigForUser(getAppConfig, user) : baseConfig;
+
+ if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
+ logger.error(
+ `[OpenID Strategy] Authentication blocked - email domain not allowed [Identifier: ${email}]`,
+ );
+ throw new Error('Email domain not allowed');
+ }
+
+ const fullName = getFullName(userinfo);
+
+ const requiredRole = process.env.OPENID_REQUIRED_ROLE;
+ let resolvedOverageGroups = null;
+
+ if (requiredRole) {
+ const requiredRoles = requiredRole
+ .split(',')
+ .map((role) => role.trim())
+ .filter(Boolean);
+ const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
+ const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
+
+ const decodedToken = getRoleSource(requiredRoleTokenKind, 'required role', tokenset, userinfo);
+
+ let roles = get(decodedToken, requiredRoleParameterPath);
+
+ // Handle Azure AD group overage for ID token groups: when hasgroups or _claim_* indicate overage,
+ // resolve groups via Microsoft Graph instead of relying on token group values.
+ const hasOverage =
+ decodedToken?.hasgroups ||
+ (decodedToken?._claim_names?.groups &&
+ decodedToken?._claim_sources?.[decodedToken._claim_names.groups]);
+
+ if (
+ requiredRoleTokenKind === 'id' &&
+ requiredRoleParameterPath === 'groups' &&
+ decodedToken &&
+ hasOverage
+ ) {
+ const overageGroups = await resolveGroupsFromOverage(tokenset.access_token, claims.sub);
+ if (overageGroups) {
+ roles = overageGroups;
+ resolvedOverageGroups = overageGroups;
+ }
+ }
+
+ if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) {
+ logger.error(
+ `[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
+ );
+ const rolesList =
+ requiredRoles.length === 1
+ ? `"${requiredRoles[0]}"`
+ : `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`;
+ throw new Error(`You must have ${rolesList} role to log in.`);
+ }
+
+ const roleValues = Array.isArray(roles) ? roles : roles.split(/[\s,]+/).filter(Boolean);
+
+ if (!requiredRoles.some((role) => roleValues.includes(role))) {
+ const rolesList =
+ requiredRoles.length === 1
+ ? `"${requiredRoles[0]}"`
+ : `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`;
+ throw new Error(`You must have ${rolesList} role to log in.`);
+ }
+ }
+
+ let username = '';
+ if (process.env.OPENID_USERNAME_CLAIM) {
+ username = userinfo[process.env.OPENID_USERNAME_CLAIM];
+ } else {
+ username = convertToUsername(
+ userinfo.preferred_username || userinfo.username || userinfo.email,
+ );
+ }
+
+ if (existingUsersOnly && !user) {
+ throw new Error('User does not exist');
+ }
+
+ if (!user) {
+ user = {
+ provider: 'openid',
+ openidId: userinfo.sub,
+ username,
+ email: email || '',
+ emailVerified: userinfo.email_verified || false,
+ name: fullName,
+ idOnTheSource: userinfo.oid,
+ openidIssuer,
+ };
+
+ const balanceConfig = getBalanceConfig(appConfig);
+ user = await createUser(user, balanceConfig, true, true);
+ } else {
+ user.provider = 'openid';
+ user.openidId = userinfo.sub;
+ if (openidIssuer) {
+ user.openidIssuer = openidIssuer;
+ }
+ user.username = username;
+ user.name = fullName;
+ user.idOnTheSource = userinfo.oid;
+ if (email && email !== user.email) {
+ user.email = email;
+ user.emailVerified = userinfo.email_verified || false;
+ }
+ }
+
+ const adminRole = process.env.OPENID_ADMIN_ROLE;
+ const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH;
+ const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND;
+ let adminRoleGranted = false;
+
+ if (adminRole && adminRoleParameterPath && adminRoleTokenKind) {
+ const adminRoleObject = getRoleSource(adminRoleTokenKind, 'admin role', tokenset, userinfo);
+
+ let adminRoles = get(adminRoleObject, adminRoleParameterPath);
+
+ // Handle Azure AD group overage for admin role when using ID token groups
+ if (adminRoleTokenKind === 'id' && adminRoleParameterPath === 'groups' && adminRoleObject) {
+ const hasAdminOverage =
+ adminRoleObject.hasgroups ||
+ (adminRoleObject._claim_names?.groups &&
+ adminRoleObject._claim_sources?.[adminRoleObject._claim_names.groups]);
+
+ if (hasAdminOverage) {
+ const overageGroups =
+ resolvedOverageGroups ||
+ (await resolveGroupsFromOverage(tokenset.access_token, claims.sub));
+ if (overageGroups) {
+ adminRoles = overageGroups;
+ }
+ }
+ }
+
+ let adminRoleValues = [];
+ if (Array.isArray(adminRoles)) {
+ adminRoleValues = adminRoles;
+ } else if (typeof adminRoles === 'string') {
+ adminRoleValues = adminRoles.split(/[\s,]+/).filter(Boolean);
+ }
+
+ if (adminRoles && (adminRoles === true || adminRoleValues.includes(adminRole))) {
+ user.role = SystemRoles.ADMIN;
+ adminRoleGranted = true;
+ logger.info(`[openidStrategy] User ${username} is an admin based on role: ${adminRole}`);
+ } else if (user.role === SystemRoles.ADMIN) {
+ user.role = SystemRoles.USER;
+ logger.info(
+ `[openidStrategy] User ${username} demoted from admin - role no longer present in token`,
+ );
+ }
+ }
+
+ if (!adminRoleGranted) {
+ const roleBeforeSync = user.role;
+ await applyOpenIdRoleSync({
+ user,
+ username,
+ tokenset,
+ claims,
+ userinfo,
+ resolvedOverageGroups,
+ });
+ /**
+ * The earlier login-policy check ran with the pre-sync role. If role sync moved a
+ * tenant user into a different role, re-resolve the tenant config and re-enforce
+ * `allowedDomains` so role-scoped overrides for the new role are honored and a token
+ * cannot complete login under the previous role's looser policy.
+ */
+ if (user?.tenantId && user.role !== roleBeforeSync) {
+ const postSyncConfig = await resolveAppConfigForUser(getAppConfig, user);
+ if (!isEmailDomainAllowed(email, postSyncConfig?.registration?.allowedDomains)) {
+ logger.error(
+ `[OpenID Strategy] Authentication blocked after role sync - email domain not allowed [Identifier: ${email}]`,
+ );
+ throw new Error('Email domain not allowed');
+ }
+ }
+ }
+
+ if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
+ /** @type {string | undefined} */
+ const imageUrl = userinfo.picture;
+
+ let fileName;
+ if (crypto) {
+ fileName = (await hashToken(userinfo.sub)) + '.png';
+ } else {
+ fileName = userinfo.sub + '.png';
+ }
+
+ const userId = user._id.toString();
+ const imageBuffer = await resizeIdentityProviderAvatar(
+ imageUrl,
+ userId,
+ openidConfig,
+ tokenset.access_token,
+ userinfo.sub,
+ );
+ if (imageBuffer) {
+ const fileStrategy = getAvatarFileStrategy(appConfig, process.env.CDN_PROVIDER);
+ const { saveBuffer } = getStrategyFunctions(fileStrategy);
+ const imagePath = await saveBuffer(
+ getAvatarSaveParams(fileStrategy, {
+ fileName,
+ userId,
+ buffer: imageBuffer,
+ tenantId: user.tenantId,
+ }),
+ );
+ user.avatar = imagePath ?? '';
+ }
+ }
+
+ user = await updateUser(user._id, user);
+
+ logger.info(
+ `[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `,
+ {
+ user: {
+ openidId: user.openidId,
+ username: user.username,
+ email: user.email,
+ name: user.name,
+ },
+ },
+ );
+
+ return {
+ ...user,
+ tokenset,
+ federatedTokens: {
+ access_token: tokenset.access_token,
+ id_token: tokenset.id_token,
+ refresh_token: tokenset.refresh_token,
+ expires_at: tokenset.expires_at,
+ },
+ };
+}
+
+/**
+ * @param {boolean | undefined} [existingUsersOnly]
+ */
+function createOpenIDCallback(existingUsersOnly) {
+ return async (tokenset, done) => {
+ try {
+ const user = await processOpenIDAuth(tokenset, existingUsersOnly);
+ done(null, user);
+ } catch (err) {
+ if (err.message === 'Email domain not allowed') {
+ return done(null, false, { message: err.message });
+ }
+ if (err.message === ErrorTypes.AUTH_FAILED) {
+ return done(null, false, { message: err.message });
+ }
+ if (err.message && err.message.includes('role to log in')) {
+ return done(null, false, { message: err.message });
+ }
+ logger.error('[openidStrategy] login failed', err);
+ done(err);
+ }
+ };
+}
+
+/**
+ * Sets up the OpenID strategy specifically for admin authentication.
+ * @param {Configuration} openidConfig
+ */
+const setupOpenIdAdmin = (openidConfig) => {
+ try {
+ if (!openidConfig) {
+ throw new Error('OpenID configuration not initialized');
+ }
+
+ const openidAdminLogin = new CustomOpenIDStrategy(
+ {
+ config: openidConfig,
+ scope: process.env.OPENID_SCOPE,
+ usePKCE: isEnabled(process.env.OPENID_USE_PKCE),
+ clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300,
+ callbackURL: process.env.DOMAIN_SERVER + '/api/admin/oauth/openid/callback',
+ },
+ createOpenIDCallback(true),
+ );
+
+ passport.use('openidAdmin', openidAdminLogin);
+ } catch (err) {
+ logger.error('[openidStrategy] setupOpenIdAdmin', err);
+ }
+};
+
+/**
+ * Sets up the OpenID strategy for authentication.
+ * This function configures the OpenID client, handles proxy settings,
+ * and defines the OpenID strategy for Passport.js.
+ *
+ * @async
+ * @function setupOpenId
+ * @returns {Promise} A promise that resolves when the OpenID strategy is set up and returns the openid client config object.
+ * @throws {Error} If an error occurs during the setup process.
+ */
+async function setupOpenId() {
+ try {
+ const usePKCE = isEnabled(process.env.OPENID_USE_PKCE);
+ const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE);
+
+ /** @type {ClientMetadata} */
+ const clientMetadata = {
+ client_id: process.env.OPENID_CLIENT_ID,
+ response_types: ['code'],
+ grant_types: ['authorization_code'],
+ };
+
+ const clientSecret = process.env.OPENID_CLIENT_SECRET?.trim();
+
+ if (clientSecret) {
+ clientMetadata.client_secret = clientSecret;
+ if (shouldGenerateNonce) {
+ clientMetadata.token_endpoint_auth_method = 'client_secret_post';
+ }
+ } else if (usePKCE) {
+ clientMetadata.token_endpoint_auth_method = 'none';
+ }
+
+ /** @type {Configuration} */
+ openidConfig = await client.discovery(
+ new URL(process.env.OPENID_ISSUER),
+ process.env.OPENID_CLIENT_ID,
+ clientMetadata,
+ undefined,
+ {
+ [client.customFetch]: customFetch,
+ },
+ );
+
+ logger.info(`[openidStrategy] OpenID authentication configuration`, {
+ usePKCE,
+ hasClientSecret: !!clientSecret,
+ tokenEndpointAuthMethod: clientMetadata.token_endpoint_auth_method ?? '(library default)',
+ generateNonce: shouldGenerateNonce,
+ });
+
+ const openidLogin = new CustomOpenIDStrategy(
+ {
+ config: openidConfig,
+ scope: process.env.OPENID_SCOPE,
+ callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL,
+ clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300,
+ usePKCE,
+ },
+ createOpenIDCallback(),
+ );
+ passport.use('openid', openidLogin);
+ setupOpenIdAdmin(openidConfig);
+ return openidConfig;
+ } catch (err) {
+ logger.error('[openidStrategy]', err);
+ return null;
+ }
+}
+
+/**
+ * @function getOpenIdConfig
+ * @description Returns the OpenID client instance.
+ * @throws {Error} If the OpenID client is not initialized.
+ * @returns {Configuration}
+ */
+function getOpenIdConfig() {
+ if (!openidConfig) {
+ throw new Error('OpenID client is not initialized. Please call setupOpenId first.');
+ }
+ return openidConfig;
+}
+
+module.exports = {
+ setupOpenId,
+ getOpenIdConfig,
+ getOpenIdEmail,
+ getRoleSource,
+};
diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js
new file mode 100644
index 0000000..36a491a
--- /dev/null
+++ b/api/strategies/openidStrategy.spec.js
@@ -0,0 +1,2593 @@
+const undici = require('undici');
+const fetch = require('node-fetch');
+const jwtDecode = require('jsonwebtoken/decode');
+const { ErrorTypes, FileSources } = require('librechat-data-provider');
+const { findUser, createUser, updateUser, findRolesByNames } = require('~/models');
+const {
+ getOpenIdProxyDispatcher,
+ resolveAppConfigForUser,
+ getOpenIdIssuer,
+ isEnabled,
+} = require('@librechat/api');
+const { resizeAvatar } = require('~/server/services/Files/images/avatar');
+const { getAppConfig } = require('~/server/services/Config');
+const { setupOpenId } = require('./openidStrategy');
+
+const mockCloudfrontFileSource = FileSources.cloudfront ?? 'cloudfront';
+
+// --- Mocks ---
+jest.mock('node-fetch');
+jest.mock('jsonwebtoken/decode');
+jest.mock('undici', () => ({
+ fetch: jest.fn(),
+}));
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn(() => ({
+ saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
+ })),
+}));
+jest.mock('~/server/services/Files/images/avatar', () => ({
+ resizeAvatar: jest.fn().mockResolvedValue(Buffer.from('safe avatar')),
+}));
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn().mockResolvedValue({}),
+}));
+jest.mock('@librechat/api', () => {
+ const actual = jest.requireActual('@librechat/api');
+ const getStringClaim = (claims, claim) => {
+ const value = claims[claim];
+ return typeof value === 'string' && value ? value : undefined;
+ };
+
+ return {
+ ...actual,
+ isEnabled: jest.fn(() => false),
+ isEmailDomainAllowed: jest.fn(() => true),
+ findOpenIDUser: actual.findOpenIDUser,
+ getOpenIdEmail: jest.fn((claims, strategyName = 'openidStrategy') => {
+ if (claims == null) {
+ return undefined;
+ }
+
+ const claimKey = process.env.OPENID_EMAIL_CLAIM?.trim();
+ if (claimKey) {
+ const value = claims[claimKey];
+ if (typeof value === 'string' && value) {
+ return value;
+ }
+
+ const { logger } = require('@librechat/data-schemas');
+ if (value != null) {
+ logger.warn(
+ `[${strategyName}] OPENID_EMAIL_CLAIM="${claimKey}" resolved to a non-string value (type: ${typeof value}). Falling back to: email -> preferred_username -> upn.`,
+ );
+ } else {
+ logger.warn(
+ `[${strategyName}] OPENID_EMAIL_CLAIM="${claimKey}" not present in userinfo. Falling back to: email -> preferred_username -> upn.`,
+ );
+ }
+ }
+
+ return (
+ getStringClaim(claims, 'email') ??
+ getStringClaim(claims, 'preferred_username') ??
+ getStringClaim(claims, 'upn')
+ );
+ }),
+ getBalanceConfig: jest.fn(() => ({
+ enabled: false,
+ })),
+ getOpenIdIssuer: jest.fn(() => 'https://fake-issuer.com'),
+ getOpenIdProxyDispatcher: jest.fn(() => undefined),
+ getAvatarFileStrategy: jest.fn((config, fallbackStrategy) => {
+ const { FileSources } = jest.requireActual('librechat-data-provider');
+ if (config?.fileStrategies) {
+ return config.fileStrategies.avatar ?? config.fileStrategies.default ?? config.fileStrategy;
+ }
+ return config?.fileStrategy ?? fallbackStrategy ?? FileSources.local;
+ }),
+ getAvatarSaveParams: jest.fn((strategy, params) => {
+ const { FileSources } = jest.requireActual('librechat-data-provider');
+ return strategy === FileSources.s3 || strategy === mockCloudfrontFileSource
+ ? { ...params, basePath: 'avatars' }
+ : params;
+ }),
+ resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
+ };
+});
+jest.mock('~/models', () => ({
+ findUser: jest.fn(),
+ createUser: jest.fn(),
+ updateUser: jest.fn(),
+ findRolesByNames: jest.fn(),
+}));
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/api'),
+ logger: {
+ info: jest.fn(),
+ warn: jest.fn(),
+ debug: jest.fn(),
+ error: jest.fn(),
+ },
+ tenantStorage: {
+ run: jest.fn((_context, fn) => fn()),
+ },
+ hashToken: jest.fn().mockResolvedValue('hashed-token'),
+}));
+jest.mock('~/cache/getLogStores', () =>
+ jest.fn(() => ({
+ get: jest.fn(),
+ set: jest.fn(),
+ })),
+);
+
+// Mock the openid-client module and all its dependencies
+jest.mock('openid-client', () => {
+ return {
+ discovery: jest.fn().mockResolvedValue({
+ clientId: 'fake_client_id',
+ clientSecret: 'fake_client_secret',
+ issuer: 'https://fake-issuer.com',
+ // Add any other properties needed by the implementation
+ }),
+ fetchUserInfo: jest.fn().mockImplementation(() => {
+ // Only return additional properties, but don't override any claims
+ return Promise.resolve({});
+ }),
+ genericGrantRequest: jest.fn().mockResolvedValue({
+ access_token: 'exchanged_graph_token',
+ expires_in: 3600,
+ }),
+ customFetch: Symbol('customFetch'),
+ };
+});
+
+jest.mock('openid-client/passport', () => {
+ /** Store callbacks by strategy name - 'openid' and 'openidAdmin' */
+ const verifyCallbacks = {};
+ const strategies = {};
+ let lastVerifyCallback;
+
+ const mockStrategy = jest.fn(function (options, verify) {
+ lastVerifyCallback = verify;
+ this.name = 'openid';
+ this.options = options;
+ this.verify = verify;
+ });
+ mockStrategy.prototype.authorizationRequestParams = jest.fn(() => new URLSearchParams());
+
+ return {
+ Strategy: mockStrategy,
+ /** Get the last registered callback (for backward compatibility) */
+ __getVerifyCallback: () => lastVerifyCallback,
+ __getStrategyByName: (name) => strategies[name],
+ /** Store callback by name when passport.use is called */
+ __setStrategy: (name, strategy) => {
+ strategies[name] = strategy;
+ if (strategy?.verify) {
+ verifyCallbacks[name] = strategy.verify;
+ }
+ },
+ /** Get callback by strategy name */
+ __getVerifyCallbackByName: (name) => verifyCallbacks[name],
+ };
+});
+
+// Mock passport - capture strategy name and callback
+jest.mock('passport', () => ({
+ use: jest.fn((name, strategy) => {
+ const passportMock = require('openid-client/passport');
+ passportMock.__setStrategy(name, strategy);
+ }),
+}));
+
+describe('setupOpenId', () => {
+ // Store a reference to the verify callback once it's set up
+ let verifyCallback;
+
+ // Helper to wrap the verify callback in a promise
+ const validate = (tokenset) =>
+ new Promise((resolve, reject) => {
+ verifyCallback(tokenset, (err, user, details) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve({ user, details });
+ }
+ });
+ });
+
+ const tokenset = {
+ id_token: 'fake_id_token',
+ access_token: 'fake_access_token',
+ claims: () => ({
+ sub: '1234',
+ email: 'test@example.com',
+ email_verified: true,
+ given_name: 'First',
+ family_name: 'Last',
+ name: 'My Full',
+ preferred_username: 'testusername',
+ username: 'flast',
+ picture: 'https://example.com/avatar.png',
+ }),
+ };
+
+ beforeEach(async () => {
+ // Clear previous mock calls and reset implementations
+ jest.clearAllMocks();
+ isEnabled.mockImplementation(jest.requireActual('@librechat/api').isEnabled);
+ require('~/cache/getLogStores').mockImplementation(() => ({
+ get: jest.fn(),
+ set: jest.fn(),
+ }));
+ getOpenIdProxyDispatcher.mockReturnValue(undefined);
+ require('openid-client').genericGrantRequest.mockReset();
+ require('openid-client').genericGrantRequest.mockResolvedValue({
+ access_token: 'exchanged_graph_token',
+ expires_in: 3600,
+ });
+
+ // Reset environment variables needed by the strategy
+ process.env.OPENID_ISSUER = 'https://fake-issuer.com';
+ process.env.OPENID_CLIENT_ID = 'fake_client_id';
+ process.env.OPENID_CLIENT_SECRET = 'fake_client_secret';
+ process.env.DOMAIN_SERVER = 'https://example.com';
+ process.env.OPENID_CALLBACK_URL = '/callback';
+ process.env.OPENID_SCOPE = 'openid profile email';
+ process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
+ process.env.OPENID_ADMIN_ROLE = 'admin';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'permissions';
+ process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
+ delete process.env.OPENID_USERNAME_CLAIM;
+ delete process.env.OPENID_NAME_CLAIM;
+ delete process.env.OPENID_EMAIL_CLAIM;
+ delete process.env.OPENID_AUDIENCE;
+ delete process.env.OPENID_AVATAR_AUTHORIZED_ORIGINS;
+ delete process.env.PROXY;
+ delete process.env.OPENID_USE_PKCE;
+ delete process.env.OPENID_GENERATE_NONCE;
+ delete process.env.OPENID_ROLE_SYNC_ENABLED;
+ delete process.env.OPENID_ROLE_SYNC_API_ENABLED;
+ delete process.env.OPENID_ROLE_SYNC_SOURCE;
+ delete process.env.OPENID_ROLE_SYNC_CLAIM;
+ delete process.env.OPENID_ROLE_SYNC_ROLE_PRIORITY;
+ delete process.env.OPENID_ROLE_SYNC_FALLBACK_ROLE;
+ delete process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED;
+ delete process.env.OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE;
+
+ // Default jwtDecode mock returns a token that includes the required role.
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole'],
+ permissions: ['admin'],
+ });
+
+ // By default, assume that no user is found, so createUser will be called
+ findUser.mockResolvedValue(null);
+ createUser.mockImplementation(async (userData) => {
+ // simulate created user with an _id property
+ return { _id: 'newUserId', ...userData };
+ });
+ updateUser.mockImplementation(async (id, userData) => {
+ return { _id: id, ...userData };
+ });
+ findRolesByNames.mockImplementation(async (roleNames) =>
+ roleNames.map((roleName) => ({ name: roleName })),
+ );
+
+ resizeAvatar.mockResolvedValue(Buffer.from('safe avatar'));
+
+ // Call the setup function and capture the verify callback for the regular 'openid' strategy
+ // (not 'openidAdmin' which requires existing users)
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+ });
+
+ describe('clientMetadata construction in setupOpenId', () => {
+ let openidClient;
+
+ beforeEach(() => {
+ openidClient = require('openid-client');
+ openidClient.discovery.mockClear();
+ });
+
+ it('sets token_endpoint_auth_method to none for PKCE without a client secret', async () => {
+ process.env.OPENID_USE_PKCE = 'true';
+ delete process.env.OPENID_CLIENT_SECRET;
+
+ await setupOpenId();
+
+ const [, , metadata] = openidClient.discovery.mock.calls.at(-1);
+ expect(metadata.token_endpoint_auth_method).toBe('none');
+ expect(metadata.client_secret).toBeUndefined();
+ });
+
+ it('leaves token_endpoint_auth_method unset for secret-based clients without nonce', async () => {
+ process.env.OPENID_USE_PKCE = 'false';
+ process.env.OPENID_CLIENT_SECRET = 'my-secret';
+
+ await setupOpenId();
+
+ const [, , metadata] = openidClient.discovery.mock.calls.at(-1);
+ expect(metadata.client_secret).toBe('my-secret');
+ expect(metadata.token_endpoint_auth_method).toBeUndefined();
+ });
+
+ it('sets client_secret and client_secret_post when nonce generation is enabled', async () => {
+ process.env.OPENID_USE_PKCE = 'false';
+ process.env.OPENID_GENERATE_NONCE = 'true';
+ process.env.OPENID_CLIENT_SECRET = 'my-secret';
+
+ await setupOpenId();
+
+ const [, , metadata] = openidClient.discovery.mock.calls.at(-1);
+ expect(metadata.client_secret).toBe('my-secret');
+ expect(metadata.token_endpoint_auth_method).toBe('client_secret_post');
+ });
+
+ it('treats whitespace-only secret as absent', async () => {
+ process.env.OPENID_USE_PKCE = 'true';
+ process.env.OPENID_CLIENT_SECRET = ' ';
+
+ await setupOpenId();
+
+ const [, , metadata] = openidClient.discovery.mock.calls.at(-1);
+ expect(metadata.client_secret).toBeUndefined();
+ expect(metadata.token_endpoint_auth_method).toBe('none');
+ });
+
+ it('does not force an auth method when PKCE and a client secret are both configured without nonce', async () => {
+ process.env.OPENID_USE_PKCE = 'true';
+ process.env.OPENID_CLIENT_SECRET = 'my-secret';
+
+ await setupOpenId();
+
+ const [, , metadata] = openidClient.discovery.mock.calls.at(-1);
+ expect(metadata.client_secret).toBe('my-secret');
+ expect(metadata.token_endpoint_auth_method).toBeUndefined();
+ });
+
+ it('uses the shared OpenID proxy dispatcher for custom fetch requests', async () => {
+ const dispatcher = { dispatch: jest.fn() };
+ const response = { status: 204, statusText: 'No Content', headers: new Headers() };
+ getOpenIdProxyDispatcher.mockReturnValue(dispatcher);
+ undici.fetch.mockResolvedValue(response);
+
+ await setupOpenId();
+
+ const [, , , , options] = openidClient.discovery.mock.calls.at(-1);
+ const openIdFetch = options[openidClient.customFetch];
+ await expect(
+ openIdFetch('https://issuer.example.com/.well-known/openid-configuration', {
+ method: 'GET',
+ }),
+ ).resolves.toBe(response);
+
+ expect(getOpenIdProxyDispatcher).toHaveBeenCalled();
+ expect(undici.fetch).toHaveBeenCalledWith(
+ 'https://issuer.example.com/.well-known/openid-configuration',
+ {
+ method: 'GET',
+ dispatcher,
+ },
+ );
+ });
+ });
+
+ describe('authorizationRequestParams', () => {
+ const getLoginStrategy = () => require('openid-client/passport').__getStrategyByName('openid');
+
+ it('adds a single OpenID audience to authorization requests', () => {
+ process.env.OPENID_AUDIENCE = 'librechat';
+
+ const params = getLoginStrategy().authorizationRequestParams({}, { state: 'login-state' });
+
+ expect(params.get('audience')).toBe('librechat');
+ expect(params.get('state')).toBe('login-state');
+ });
+
+ it('uses the first non-empty audience when OPENID_AUDIENCE accepts multiple JWT audiences', () => {
+ process.env.OPENID_AUDIENCE = ' librechat , control-plane-web ';
+
+ const params = getLoginStrategy().authorizationRequestParams({}, {});
+
+ expect(params.get('audience')).toBe('librechat');
+ });
+
+ it('does not add an authorization audience when OPENID_AUDIENCE is empty', () => {
+ process.env.OPENID_AUDIENCE = ' , ';
+
+ const params = getLoginStrategy().authorizationRequestParams({}, {});
+
+ expect(params.has('audience')).toBe(false);
+ });
+ });
+
+ it('should create a new user with correct username when preferred_username claim exists', async () => {
+ // Arrange – our userinfo already has preferred_username 'testusername'
+ const userinfo = tokenset.claims();
+
+ // Act
+ const { user } = await validate(tokenset);
+
+ // Assert
+ expect(user.username).toBe(userinfo.preferred_username);
+ expect(getOpenIdIssuer).toHaveBeenCalledTimes(1);
+ expect(getOpenIdIssuer.mock.calls[0]).toHaveLength(2);
+ expect(getOpenIdIssuer.mock.calls[0][0]).toEqual(userinfo);
+ expect(getOpenIdIssuer.mock.calls[0][1]).toEqual(
+ expect.objectContaining({ issuer: 'https://fake-issuer.com' }),
+ );
+ expect(createUser).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: 'openid',
+ openidId: userinfo.sub,
+ openidIssuer: 'https://fake-issuer.com',
+ username: userinfo.preferred_username,
+ email: userinfo.email,
+ name: `${userinfo.given_name} ${userinfo.family_name}`,
+ }),
+ { enabled: false },
+ true,
+ true,
+ );
+ });
+
+ it('should use username as username when preferred_username claim is missing', async () => {
+ // Arrange – remove preferred_username from userinfo
+ const userinfo = { ...tokenset.claims() };
+ delete userinfo.preferred_username;
+ // Expect the username to be the "username"
+ const expectUsername = userinfo.username;
+
+ // Act
+ const { user } = await validate({ ...tokenset, claims: () => userinfo });
+
+ // Assert
+ expect(user.username).toBe(expectUsername);
+ expect(createUser).toHaveBeenCalledWith(
+ expect.objectContaining({ username: expectUsername }),
+ { enabled: false },
+ true,
+ true,
+ );
+ });
+
+ it('should use email as username when username and preferred_username are missing', async () => {
+ // Arrange – remove username and preferred_username
+ const userinfo = { ...tokenset.claims() };
+ delete userinfo.username;
+ delete userinfo.preferred_username;
+ const expectUsername = userinfo.email;
+
+ // Act
+ const { user } = await validate({ ...tokenset, claims: () => userinfo });
+
+ // Assert
+ expect(user.username).toBe(expectUsername);
+ expect(createUser).toHaveBeenCalledWith(
+ expect.objectContaining({ username: expectUsername }),
+ { enabled: false },
+ true,
+ true,
+ );
+ });
+
+ it('should override username with OPENID_USERNAME_CLAIM when set', async () => {
+ // Arrange – set OPENID_USERNAME_CLAIM so that the sub claim is used
+ process.env.OPENID_USERNAME_CLAIM = 'sub';
+ const userinfo = tokenset.claims();
+
+ // Act
+ const { user } = await validate(tokenset);
+
+ // Assert – username should equal the sub (converted as-is)
+ expect(user.username).toBe(userinfo.sub);
+ expect(createUser).toHaveBeenCalledWith(
+ expect.objectContaining({ username: userinfo.sub }),
+ { enabled: false },
+ true,
+ true,
+ );
+ });
+
+ it('should set the full name correctly when given_name and family_name exist', async () => {
+ // Arrange
+ const userinfo = tokenset.claims();
+ const expectedFullName = `${userinfo.given_name} ${userinfo.family_name}`;
+
+ // Act
+ const { user } = await validate(tokenset);
+
+ // Assert
+ expect(user.name).toBe(expectedFullName);
+ });
+
+ it('should override full name with OPENID_NAME_CLAIM when set', async () => {
+ // Arrange – use the name claim as the full name
+ process.env.OPENID_NAME_CLAIM = 'name';
+ const userinfo = { ...tokenset.claims(), name: 'Custom Name' };
+
+ // Act
+ const { user } = await validate({ ...tokenset, claims: () => userinfo });
+
+ // Assert
+ expect(user.name).toBe('Custom Name');
+ });
+
+ it('should update an existing user on login', async () => {
+ // Arrange – simulate that a user already exists with openid provider
+ const existingUser = {
+ _id: 'existingUserId',
+ provider: 'openid',
+ email: tokenset.claims().email,
+ openidId: '',
+ username: '',
+ name: '',
+ };
+ findUser.mockImplementation(async (query) => {
+ if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
+ return existingUser;
+ }
+ return null;
+ });
+
+ const userinfo = tokenset.claims();
+
+ // Act
+ await validate(tokenset);
+
+ // Assert – updateUser should be called and the user object updated
+ expect(updateUser).toHaveBeenCalledWith(
+ existingUser._id,
+ expect.objectContaining({
+ provider: 'openid',
+ openidId: userinfo.sub,
+ openidIssuer: 'https://fake-issuer.com',
+ username: userinfo.preferred_username,
+ name: `${userinfo.given_name} ${userinfo.family_name}`,
+ }),
+ );
+ });
+
+ it('should block login when email exists with different provider', async () => {
+ // Arrange – simulate that a user exists with same email but different provider
+ const existingUser = {
+ _id: 'existingUserId',
+ provider: 'google',
+ email: tokenset.claims().email,
+ googleId: 'some-google-id',
+ username: 'existinguser',
+ name: 'Existing User',
+ };
+ findUser.mockImplementation(async (query) => {
+ if (query.email === tokenset.claims().email && !query.provider) {
+ return existingUser;
+ }
+ return null;
+ });
+
+ // Act
+ const result = await validate(tokenset);
+
+ // Assert – verify that the strategy rejects login
+ expect(result.user).toBe(false);
+ expect(result.details.message).toBe(ErrorTypes.AUTH_FAILED);
+ expect(createUser).not.toHaveBeenCalled();
+ expect(updateUser).not.toHaveBeenCalled();
+ });
+
+ it('should block login when email fallback finds user with mismatched openidId', async () => {
+ const existingUser = {
+ _id: 'existingUserId',
+ provider: 'openid',
+ openidId: 'different-sub-claim',
+ email: tokenset.claims().email,
+ username: 'existinguser',
+ name: 'Existing User',
+ };
+ findUser.mockImplementation(async (query) => {
+ if (query.$or) {
+ return null;
+ }
+ if (query.email === tokenset.claims().email) {
+ return existingUser;
+ }
+ return null;
+ });
+
+ const result = await validate(tokenset);
+
+ expect(result.user).toBe(false);
+ expect(result.details.message).toBe(ErrorTypes.AUTH_FAILED);
+ expect(createUser).not.toHaveBeenCalled();
+ expect(updateUser).not.toHaveBeenCalled();
+ });
+
+ it('should enforce the required role and reject login if missing', async () => {
+ // Arrange – simulate a token without the required role.
+ jwtDecode.mockReturnValue({
+ roles: ['SomeOtherRole'],
+ });
+
+ // Act
+ const { user, details } = await validate(tokenset);
+
+ // Assert – verify that the strategy rejects login
+ expect(user).toBe(false);
+ expect(details.message).toBe('You must have "requiredRole" role to log in.');
+ });
+
+ it('should not treat substring matches in string roles as satisfying required role', async () => {
+ // Arrange – override required role to "read" then re-setup
+ process.env.OPENID_REQUIRED_ROLE = 'read';
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ // Token contains "bread" which *contains* "read" as a substring
+ jwtDecode.mockReturnValue({
+ roles: 'bread',
+ });
+
+ // Act
+ const { user, details } = await validate(tokenset);
+
+ // Assert – verify that substring match does not grant access
+ expect(user).toBe(false);
+ expect(details.message).toBe('You must have "read" role to log in.');
+ });
+
+ it('should allow login when roles claim is a space-separated string containing the required role', async () => {
+ // Arrange – IdP returns roles as a space-delimited string
+ jwtDecode.mockReturnValue({
+ roles: 'role1 role2 requiredRole',
+ });
+
+ // Act
+ const { user } = await validate(tokenset);
+
+ // Assert – login succeeds when required role is present after splitting
+ expect(user).toBeTruthy();
+ expect(createUser).toHaveBeenCalled();
+ });
+
+ it('should allow login when roles claim is a comma-separated string containing the required role', async () => {
+ // Arrange – IdP returns roles as a comma-delimited string
+ jwtDecode.mockReturnValue({
+ roles: 'role1,role2,requiredRole',
+ });
+
+ // Act
+ const { user } = await validate(tokenset);
+
+ // Assert – login succeeds when required role is present after splitting
+ expect(user).toBeTruthy();
+ expect(createUser).toHaveBeenCalled();
+ });
+
+ it('should allow login when roles claim is a mixed comma-and-space-separated string containing the required role', async () => {
+ // Arrange – IdP returns roles with comma-and-space delimiters
+ jwtDecode.mockReturnValue({
+ roles: 'role1, role2, requiredRole',
+ });
+
+ // Act
+ const { user } = await validate(tokenset);
+
+ // Assert – login succeeds when required role is present after splitting
+ expect(user).toBeTruthy();
+ expect(createUser).toHaveBeenCalled();
+ });
+
+ it('should reject login when roles claim is a space-separated string that does not contain the required role', async () => {
+ // Arrange – IdP returns a delimited string but required role is absent
+ jwtDecode.mockReturnValue({
+ roles: 'role1 role2 otherRole',
+ });
+
+ // Act
+ const { user, details } = await validate(tokenset);
+
+ // Assert – login is rejected with the correct error message
+ expect(user).toBe(false);
+ expect(details.message).toBe('You must have "requiredRole" role to log in.');
+ });
+
+ it('should allow login when single required role is present (backward compatibility)', async () => {
+ // Arrange – ensure single role configuration (as set in beforeEach)
+ // OPENID_REQUIRED_ROLE = 'requiredRole'
+ // Default jwtDecode mock in beforeEach already returns this role
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole', 'anotherRole'],
+ });
+
+ // Act
+ const { user } = await validate(tokenset);
+
+ // Assert – verify that login succeeds with single role configuration
+ expect(user).toBeTruthy();
+ expect(user.email).toBe(tokenset.claims().email);
+ expect(user.username).toBe(tokenset.claims().preferred_username);
+ expect(createUser).toHaveBeenCalled();
+ });
+
+ it('should allow login when required role is found in userinfo claims', async () => {
+ process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'userinfo';
+
+ // The role is intentionally absent from the id_token and only present in
+ // the userinfo response — exercises the userinfo branch of the switch.
+ jwtDecode.mockReturnValue({});
+ require('openid-client').fetchUserInfo.mockResolvedValue({
+ roles: ['requiredRole'],
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user } = await validate(tokenset);
+
+ expect(user).toBeTruthy();
+ expect(user.email).toBe(tokenset.claims().email);
+ });
+
+ it('should reject login when required role is missing from userinfo claims', async () => {
+ const { logger } = require('@librechat/data-schemas');
+ process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'userinfo';
+
+ jwtDecode.mockReturnValue({});
+ require('openid-client').fetchUserInfo.mockResolvedValue({
+ other_claim: 'value',
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user, details } = await validate(tokenset);
+
+ expect(user).toBe(false);
+ expect(details.message).toBe('You must have "requiredRole" role to log in.');
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining("Key 'roles' not found in userinfo token!"),
+ );
+ });
+
+ it('should reject login with invalid required role token kind', async () => {
+ const { logger } = require('@librechat/data-schemas');
+ process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'invalid';
+
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole'],
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ await expect(validate(tokenset)).rejects.toThrow('Invalid required role token kind');
+
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "Invalid required role token kind: invalid. Must be one of 'access', 'id', or 'userinfo'",
+ ),
+ );
+ });
+
+ describe('group overage and groups handling', () => {
+ it.each([
+ ['groups array contains required group', ['group-required', 'other-group'], true, undefined],
+ [
+ 'groups array missing required group',
+ ['other-group'],
+ false,
+ 'You must have "group-required" role to log in.',
+ ],
+ ['groups string equals required group', 'group-required', true, undefined],
+ [
+ 'groups string is other group',
+ 'other-group',
+ false,
+ 'You must have "group-required" role to log in.',
+ ],
+ ])(
+ 'uses groups claim directly when %s (no overage)',
+ async (_label, groupsClaim, expectedAllowed, expectedMessage) => {
+ process.env.OPENID_REQUIRED_ROLE = 'group-required';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
+
+ jwtDecode.mockReturnValue({
+ groups: groupsClaim,
+ permissions: ['admin'],
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user, details } = await validate(tokenset);
+
+ expect(undici.fetch).not.toHaveBeenCalled();
+ expect(Boolean(user)).toBe(expectedAllowed);
+ expect(details?.message).toBe(expectedMessage);
+ },
+ );
+
+ it.each([
+ ['token kind is not id', { kind: 'access', path: 'groups', decoded: { hasgroups: true } }],
+ ['parameter path is not groups', { kind: 'id', path: 'roles', decoded: { hasgroups: true } }],
+ ['decoded token is falsy', { kind: 'id', path: 'groups', decoded: null }],
+ [
+ 'no overage indicators in decoded token',
+ {
+ kind: 'id',
+ path: 'groups',
+ decoded: {
+ permissions: ['admin'],
+ },
+ },
+ ],
+ [
+ 'only _claim_names present (no _claim_sources)',
+ {
+ kind: 'id',
+ path: 'groups',
+ decoded: {
+ _claim_names: { groups: 'src1' },
+ permissions: ['admin'],
+ },
+ },
+ ],
+ [
+ 'only _claim_sources present (no _claim_names)',
+ {
+ kind: 'id',
+ path: 'groups',
+ decoded: {
+ _claim_sources: { src1: { endpoint: 'https://graph.windows.net/ignored' } },
+ permissions: ['admin'],
+ },
+ },
+ ],
+ ])('does not attempt overage resolution when %s', async (_label, cfg) => {
+ process.env.OPENID_REQUIRED_ROLE = 'group-required';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = cfg.path;
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = cfg.kind;
+
+ jwtDecode.mockReturnValue(cfg.decoded);
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user, details } = await validate(tokenset);
+
+ expect(undici.fetch).not.toHaveBeenCalled();
+ expect(user).toBe(false);
+ expect(details.message).toBe('You must have "group-required" role to log in.');
+ const { logger } = require('@librechat/data-schemas');
+ const expectedTokenKind = cfg.kind === 'access' ? 'access token' : 'id token';
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining(`Key '${cfg.path}' not found in ${expectedTokenKind}!`),
+ );
+ });
+ });
+
+ describe('resolving groups via Microsoft Graph', () => {
+ it('denies login and does not call Graph when access token is missing', async () => {
+ process.env.OPENID_REQUIRED_ROLE = 'group-required';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
+
+ const { logger } = require('@librechat/data-schemas');
+
+ jwtDecode.mockReturnValue({
+ hasgroups: true,
+ permissions: ['admin'],
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const tokensetWithoutAccess = {
+ ...tokenset,
+ access_token: undefined,
+ };
+
+ const { user, details } = await validate(tokensetWithoutAccess);
+
+ expect(user).toBe(false);
+ expect(details.message).toBe('You must have "group-required" role to log in.');
+
+ expect(undici.fetch).not.toHaveBeenCalled();
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining('Access token missing; cannot resolve group overage'),
+ );
+ });
+
+ it.each([
+ [
+ 'Graph returns HTTP error',
+ async () => ({
+ ok: false,
+ status: 403,
+ statusText: 'Forbidden',
+ json: async () => ({}),
+ }),
+ [
+ '[openidStrategy] Failed to resolve groups via Microsoft Graph getMemberObjects: HTTP 403 Forbidden',
+ ],
+ ],
+ [
+ 'Graph network error',
+ async () => {
+ throw new Error('network error');
+ },
+ [
+ '[openidStrategy] Error resolving groups via Microsoft Graph getMemberObjects:',
+ expect.any(Error),
+ ],
+ ],
+ [
+ 'Graph returns unexpected shape (no value)',
+ async () => ({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => ({}),
+ }),
+ [
+ '[openidStrategy] Unexpected response format when resolving groups via Microsoft Graph getMemberObjects',
+ ],
+ ],
+ [
+ 'Graph returns invalid value type',
+ async () => ({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => ({ value: 'not-an-array' }),
+ }),
+ [
+ '[openidStrategy] Unexpected response format when resolving groups via Microsoft Graph getMemberObjects',
+ ],
+ ],
+ ])(
+ 'denies login when overage resolution fails because %s',
+ async (_label, setupFetch, expectedErrorArgs) => {
+ process.env.OPENID_REQUIRED_ROLE = 'group-required';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
+
+ const { logger } = require('@librechat/data-schemas');
+
+ jwtDecode.mockReturnValue({
+ hasgroups: true,
+ permissions: ['admin'],
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ undici.fetch.mockImplementation(setupFetch);
+
+ const { user, details } = await validate(tokenset);
+
+ expect(undici.fetch).toHaveBeenCalled();
+ expect(user).toBe(false);
+ expect(details.message).toBe('You must have "group-required" role to log in.');
+
+ expect(logger.error).toHaveBeenCalledWith(...expectedErrorArgs);
+ },
+ );
+
+ it.each([
+ [
+ 'hasgroups overage and Graph contains required group',
+ {
+ hasgroups: true,
+ },
+ ['group-required', 'some-other-group'],
+ true,
+ ],
+ [
+ '_claim_* overage and Graph contains required group',
+ {
+ _claim_names: { groups: 'src1' },
+ _claim_sources: { src1: { endpoint: 'https://graph.windows.net/ignored' } },
+ },
+ ['group-required', 'some-other-group'],
+ true,
+ ],
+ [
+ 'hasgroups overage and Graph does NOT contain required group',
+ {
+ hasgroups: true,
+ },
+ ['some-other-group'],
+ false,
+ ],
+ [
+ '_claim_* overage and Graph does NOT contain required group',
+ {
+ _claim_names: { groups: 'src1' },
+ _claim_sources: { src1: { endpoint: 'https://graph.windows.net/ignored' } },
+ },
+ ['some-other-group'],
+ false,
+ ],
+ ])(
+ 'resolves groups via Microsoft Graph when %s',
+ async (_label, decodedTokenValue, graphGroups, expectedAllowed) => {
+ process.env.OPENID_REQUIRED_ROLE = 'group-required';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
+
+ const { logger } = require('@librechat/data-schemas');
+
+ jwtDecode.mockReturnValue(decodedTokenValue);
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ undici.fetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => ({
+ value: graphGroups,
+ }),
+ });
+
+ const { user } = await validate(tokenset);
+
+ expect(undici.fetch).toHaveBeenCalledWith(
+ 'https://graph.microsoft.com/v1.0/me/getMemberObjects',
+ expect.objectContaining({
+ method: 'POST',
+ headers: expect.objectContaining({
+ Authorization: 'Bearer exchanged_graph_token',
+ }),
+ }),
+ );
+ expect(Boolean(user)).toBe(expectedAllowed);
+
+ expect(logger.debug).toHaveBeenCalledWith(
+ expect.stringContaining(
+ `Successfully resolved ${graphGroups.length} groups via Microsoft Graph getMemberObjects`,
+ ),
+ );
+ },
+ );
+ });
+
+ describe('OBO token exchange for overage', () => {
+ beforeEach(() => {
+ delete process.env.OPENID_ADMIN_ROLE;
+ });
+
+ it('exchanges access token via OBO before calling Graph API', async () => {
+ const openidClient = require('openid-client');
+ process.env.OPENID_REQUIRED_ROLE = 'group-required';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
+
+ jwtDecode.mockReturnValue({ hasgroups: true });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ undici.fetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => ({ value: ['group-required'] }),
+ });
+
+ await validate(tokenset);
+
+ expect(openidClient.genericGrantRequest).toHaveBeenCalledWith(
+ expect.anything(),
+ 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+ expect.objectContaining({
+ scope: 'https://graph.microsoft.com/User.Read',
+ assertion: tokenset.access_token,
+ requested_token_use: 'on_behalf_of',
+ }),
+ );
+
+ expect(undici.fetch).toHaveBeenCalledWith(
+ 'https://graph.microsoft.com/v1.0/me/getMemberObjects',
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: 'Bearer exchanged_graph_token',
+ }),
+ }),
+ );
+ });
+
+ it('caches the exchanged token and reuses it on subsequent calls', async () => {
+ const openidClient = require('openid-client');
+ const getLogStores = require('~/cache/getLogStores');
+ const mockSet = jest.fn();
+ const mockGet = jest
+ .fn()
+ .mockResolvedValueOnce(undefined)
+ .mockResolvedValueOnce({ access_token: 'exchanged_graph_token' });
+ getLogStores.mockReturnValue({ get: mockGet, set: mockSet });
+
+ process.env.OPENID_REQUIRED_ROLE = 'group-required';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
+
+ jwtDecode.mockReturnValue({ hasgroups: true });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ undici.fetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => ({ value: ['group-required'] }),
+ });
+
+ // First call: cache miss → OBO exchange → cache set
+ await validate(tokenset);
+ expect(mockSet).toHaveBeenCalledWith(
+ '1234:overage',
+ { access_token: 'exchanged_graph_token' },
+ 3600000,
+ );
+ expect(openidClient.genericGrantRequest).toHaveBeenCalledTimes(1);
+
+ // Second call: cache hit → no new OBO exchange
+ openidClient.genericGrantRequest.mockClear();
+ await validate(tokenset);
+ expect(openidClient.genericGrantRequest).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('admin role group overage', () => {
+ it('resolves admin groups via Graph when overage is detected for admin role', async () => {
+ process.env.OPENID_REQUIRED_ROLE = 'group-required';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
+ process.env.OPENID_ADMIN_ROLE = 'admin-group-id';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
+
+ jwtDecode.mockReturnValue({ hasgroups: true });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ undici.fetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => ({ value: ['group-required', 'admin-group-id'] }),
+ });
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBe('ADMIN');
+ });
+
+ it('does not grant admin when overage groups do not contain admin role', async () => {
+ process.env.OPENID_REQUIRED_ROLE = 'group-required';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
+ process.env.OPENID_ADMIN_ROLE = 'admin-group-id';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
+
+ jwtDecode.mockReturnValue({ hasgroups: true });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ undici.fetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => ({ value: ['group-required', 'other-group'] }),
+ });
+
+ const { user } = await validate(tokenset);
+
+ expect(user).toBeTruthy();
+ expect(user.role).toBeUndefined();
+ });
+
+ it('reuses already-resolved overage groups for admin role check (no duplicate Graph call)', async () => {
+ process.env.OPENID_REQUIRED_ROLE = 'group-required';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
+ process.env.OPENID_ADMIN_ROLE = 'admin-group-id';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
+
+ jwtDecode.mockReturnValue({ hasgroups: true });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ undici.fetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => ({ value: ['group-required', 'admin-group-id'] }),
+ });
+
+ await validate(tokenset);
+
+ // Graph API should be called only once (for required role), admin role reuses the result
+ expect(undici.fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('demotes existing admin when overage groups no longer contain admin role', async () => {
+ process.env.OPENID_REQUIRED_ROLE = 'group-required';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
+ process.env.OPENID_ADMIN_ROLE = 'admin-group-id';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
+
+ const existingAdminUser = {
+ _id: 'existingAdminId',
+ provider: 'openid',
+ email: tokenset.claims().email,
+ openidId: tokenset.claims().sub,
+ username: 'adminuser',
+ name: 'Admin User',
+ role: 'ADMIN',
+ };
+
+ findUser.mockImplementation(async (query) => {
+ if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
+ return existingAdminUser;
+ }
+ return null;
+ });
+
+ jwtDecode.mockReturnValue({ hasgroups: true });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ undici.fetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => ({ value: ['group-required'] }),
+ });
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBe('USER');
+ });
+
+ it('does not attempt overage for admin role when token kind is not id', async () => {
+ process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
+ process.env.OPENID_ADMIN_ROLE = 'admin';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'access';
+
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole'],
+ hasgroups: true,
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user } = await validate(tokenset);
+
+ // No Graph call since admin uses access token (not id)
+ expect(undici.fetch).not.toHaveBeenCalled();
+ expect(user.role).toBeUndefined();
+ });
+
+ it('resolves admin via Graph independently when OPENID_REQUIRED_ROLE is not configured', async () => {
+ delete process.env.OPENID_REQUIRED_ROLE;
+ process.env.OPENID_ADMIN_ROLE = 'admin-group-id';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
+
+ jwtDecode.mockReturnValue({ hasgroups: true });
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ undici.fetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => ({ value: ['admin-group-id'] }),
+ });
+
+ const { user } = await validate(tokenset);
+ expect(user.role).toBe('ADMIN');
+ expect(undici.fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('denies admin when OPENID_REQUIRED_ROLE is absent and Graph does not contain admin group', async () => {
+ delete process.env.OPENID_REQUIRED_ROLE;
+ process.env.OPENID_ADMIN_ROLE = 'admin-group-id';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
+
+ jwtDecode.mockReturnValue({ hasgroups: true });
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ undici.fetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => ({ value: ['other-group'] }),
+ });
+
+ const { user } = await validate(tokenset);
+ expect(user).toBeTruthy();
+ expect(user.role).toBeUndefined();
+ });
+
+ it('denies login and logs error when OBO exchange throws', async () => {
+ const openidClient = require('openid-client');
+ process.env.OPENID_REQUIRED_ROLE = 'group-required';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
+
+ jwtDecode.mockReturnValue({ hasgroups: true });
+ openidClient.genericGrantRequest.mockRejectedValueOnce(new Error('OBO exchange rejected'));
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user, details } = await validate(tokenset);
+ expect(user).toBe(false);
+ expect(details.message).toBe('You must have "group-required" role to log in.');
+ expect(undici.fetch).not.toHaveBeenCalled();
+ });
+
+ it('denies login when OBO exchange returns no access_token', async () => {
+ const openidClient = require('openid-client');
+ process.env.OPENID_REQUIRED_ROLE = 'group-required';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
+
+ jwtDecode.mockReturnValue({ hasgroups: true });
+ openidClient.genericGrantRequest.mockResolvedValueOnce({ expires_in: 3600 });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user, details } = await validate(tokenset);
+ expect(user).toBe(false);
+ expect(details.message).toBe('You must have "group-required" role to log in.');
+ expect(undici.fetch).not.toHaveBeenCalled();
+ });
+ });
+
+ it('should process and save the avatar through the shared avatar path if picture is provided', async () => {
+ const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+
+ // Act
+ const { user } = await validate(tokenset);
+ const strategyResult =
+ getStrategyFunctions.mock.results[getStrategyFunctions.mock.results.length - 1];
+ const { saveBuffer } = strategyResult.value;
+ const [saveParams] = saveBuffer.mock.calls[0];
+
+ expect(resizeAvatar).toHaveBeenCalledWith({
+ userId: 'newUserId',
+ input: 'https://example.com/avatar.png',
+ });
+ expect(fetch).not.toHaveBeenCalled();
+ expect(saveParams).toEqual(
+ expect.objectContaining({
+ fileName: 'hashed-token.png',
+ userId: 'newUserId',
+ buffer: expect.any(Buffer),
+ }),
+ );
+ expect(saveParams).not.toHaveProperty('basePath');
+ // Our mock getStrategyFunctions.saveBuffer returns '/fake/path/to/avatar.png'
+ expect(user.avatar).toBe('/fake/path/to/avatar.png');
+ });
+
+ it('uses only the shared avatar processor for OpenID picture URLs', async () => {
+ await validate(tokenset);
+
+ expect(resizeAvatar).toHaveBeenCalledWith({
+ userId: 'newUserId',
+ input: 'https://example.com/avatar.png',
+ });
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it('adds auth headers for configured OpenID avatar origins', async () => {
+ process.env.OPENID_AVATAR_AUTHORIZED_ORIGINS = 'https://example.com';
+
+ await validate(tokenset);
+
+ expect(resizeAvatar).toHaveBeenCalledWith({
+ userId: 'newUserId',
+ input: 'https://example.com/avatar.png',
+ fetchOptions: {
+ headers: {
+ Authorization: 'Bearer fake_access_token',
+ },
+ },
+ });
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it('continues login when shared avatar processing rejects the picture URL', async () => {
+ const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+ resizeAvatar.mockRejectedValueOnce(new Error('avatar processing failed'));
+
+ const { user } = await validate(tokenset);
+
+ expect(user).toBeTruthy();
+ expect(user.avatar).toBeUndefined();
+ expect(getStrategyFunctions).not.toHaveBeenCalled();
+ });
+
+ it('should save CloudFront IdP avatars under the shared avatar prefix', async () => {
+ const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+ getAppConfig.mockResolvedValueOnce({ fileStrategy: mockCloudfrontFileSource });
+
+ const { user } = await validate(tokenset);
+ const strategyResult =
+ getStrategyFunctions.mock.results[getStrategyFunctions.mock.results.length - 1];
+ const { saveBuffer } = strategyResult.value;
+ const [saveParams] = saveBuffer.mock.calls[0];
+
+ expect(getStrategyFunctions).toHaveBeenLastCalledWith(mockCloudfrontFileSource);
+ expect(resizeAvatar).toHaveBeenCalledWith({
+ userId: 'newUserId',
+ input: 'https://example.com/avatar.png',
+ });
+ expect(fetch).not.toHaveBeenCalled();
+ expect(saveParams).toEqual(
+ expect.objectContaining({
+ basePath: 'avatars',
+ fileName: 'hashed-token.png',
+ userId: 'newUserId',
+ }),
+ );
+ expect(user.avatar).toBe('/fake/path/to/avatar.png');
+ });
+
+ it('should not attempt to download avatar if picture is not provided', async () => {
+ // Arrange – remove picture
+ const userinfo = { ...tokenset.claims() };
+ delete userinfo.picture;
+
+ // Act
+ await validate({ ...tokenset, claims: () => userinfo });
+
+ // Assert – fetch should not be called and avatar should remain undefined or empty
+ expect(fetch).not.toHaveBeenCalled();
+ expect(resizeAvatar).not.toHaveBeenCalled();
+ // Depending on your implementation, user.avatar may be undefined or an empty string.
+ });
+
+ it('should support comma-separated multiple roles', async () => {
+ // Arrange
+ process.env.OPENID_REQUIRED_ROLE = 'someRole,anotherRole,admin';
+ await setupOpenId(); // Re-initialize the strategy
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+ jwtDecode.mockReturnValue({
+ roles: ['anotherRole', 'aThirdRole'],
+ });
+
+ // Act
+ const { user } = await validate(tokenset);
+
+ // Assert
+ expect(user).toBeTruthy();
+ expect(user.email).toBe(tokenset.claims().email);
+ });
+
+ it('should reject login when user has none of the required multiple roles', async () => {
+ // Arrange
+ process.env.OPENID_REQUIRED_ROLE = 'someRole,anotherRole,admin';
+ await setupOpenId(); // Re-initialize the strategy
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+ jwtDecode.mockReturnValue({
+ roles: ['aThirdRole', 'aFourthRole'],
+ });
+
+ // Act
+ const { user, details } = await validate(tokenset);
+
+ // Assert
+ expect(user).toBe(false);
+ expect(details.message).toBe(
+ 'You must have one of: "someRole", "anotherRole", "admin" role to log in.',
+ );
+ });
+
+ it('should handle spaces in comma-separated roles', async () => {
+ // Arrange
+ process.env.OPENID_REQUIRED_ROLE = ' someRole , anotherRole , admin ';
+ await setupOpenId(); // Re-initialize the strategy
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+ jwtDecode.mockReturnValue({
+ roles: ['someRole'],
+ });
+
+ // Act
+ const { user } = await validate(tokenset);
+
+ // Assert
+ expect(user).toBeTruthy();
+ });
+
+ it('should default to usePKCE false when OPENID_USE_PKCE is not defined', async () => {
+ const OpenIDStrategy = require('openid-client/passport').Strategy;
+
+ delete process.env.OPENID_USE_PKCE;
+ await setupOpenId();
+
+ const callOptions = OpenIDStrategy.mock.calls[OpenIDStrategy.mock.calls.length - 1][0];
+ expect(callOptions.usePKCE).toBe(false);
+ expect(callOptions.params?.code_challenge_method).toBeUndefined();
+ });
+
+ it('should attach federatedTokens to user object for token propagation', async () => {
+ // Arrange - setup tokenset with access token, id token, refresh token, and expiration
+ const tokensetWithTokens = {
+ ...tokenset,
+ access_token: 'mock_access_token_abc123',
+ id_token: 'mock_id_token_def456',
+ refresh_token: 'mock_refresh_token_xyz789',
+ expires_at: 1234567890,
+ };
+
+ // Act - validate with the tokenset containing tokens
+ const { user } = await validate(tokensetWithTokens);
+
+ // Assert - verify federatedTokens object is attached with correct values
+ expect(user.federatedTokens).toBeDefined();
+ expect(user.federatedTokens).toEqual({
+ access_token: 'mock_access_token_abc123',
+ id_token: 'mock_id_token_def456',
+ refresh_token: 'mock_refresh_token_xyz789',
+ expires_at: 1234567890,
+ });
+ });
+
+ it('should include id_token in federatedTokens distinct from access_token', async () => {
+ // Arrange - use different values for access_token and id_token
+ const tokensetWithTokens = {
+ ...tokenset,
+ access_token: 'the_access_token',
+ id_token: 'the_id_token',
+ refresh_token: 'the_refresh_token',
+ expires_at: 9999999999,
+ };
+
+ // Act
+ const { user } = await validate(tokensetWithTokens);
+
+ // Assert - id_token and access_token must be different values
+ expect(user.federatedTokens.access_token).toBe('the_access_token');
+ expect(user.federatedTokens.id_token).toBe('the_id_token');
+ expect(user.federatedTokens.id_token).not.toBe(user.federatedTokens.access_token);
+ });
+
+ it('should include tokenset along with federatedTokens', async () => {
+ // Arrange
+ const tokensetWithTokens = {
+ ...tokenset,
+ access_token: 'test_access_token',
+ id_token: 'test_id_token',
+ refresh_token: 'test_refresh_token',
+ expires_at: 9999999999,
+ };
+
+ // Act
+ const { user } = await validate(tokensetWithTokens);
+
+ // Assert - both tokenset and federatedTokens should be present
+ expect(user.tokenset).toBeDefined();
+ expect(user.federatedTokens).toBeDefined();
+ expect(user.tokenset.access_token).toBe('test_access_token');
+ expect(user.tokenset.id_token).toBe('test_id_token');
+ expect(user.federatedTokens.access_token).toBe('test_access_token');
+ expect(user.federatedTokens.id_token).toBe('test_id_token');
+ });
+
+ it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => {
+ // Act
+ const { user } = await validate(tokenset);
+
+ // Assert – verify that the user role is set to "ADMIN"
+ expect(user.role).toBe('ADMIN');
+ });
+
+ it('should not set user role if OPENID_ADMIN_ROLE is set but the user does not have that role', async () => {
+ // Arrange – simulate a token without the admin permission
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole'],
+ permissions: ['not-admin'],
+ });
+
+ // Act
+ const { user } = await validate(tokenset);
+
+ // Assert – verify that the user role is not defined
+ expect(user.role).toBeUndefined();
+ });
+
+ describe('OpenID role sync', () => {
+ beforeEach(() => {
+ process.env.OPENID_ROLE_SYNC_ENABLED = 'true';
+ process.env.OPENID_ROLE_SYNC_SOURCE = 'id';
+ process.env.OPENID_ROLE_SYNC_CLAIM = 'roles';
+ process.env.OPENID_ROLE_SYNC_ROLE_PRIORITY = 'STANDARD-USER,BASIC-USER';
+ process.env.OPENID_ROLE_SYNC_FALLBACK_ROLE = 'USER';
+ });
+
+ it('selects the highest configured matching role from the OpenID token', async () => {
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole', 'BASIC-USER', 'STANDARD-USER'],
+ permissions: ['not-admin'],
+ });
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBe('STANDARD-USER');
+ expect(updateUser).toHaveBeenCalledWith(
+ 'newUserId',
+ expect.objectContaining({ role: 'STANDARD-USER' }),
+ );
+ });
+
+ it('does not run when disabled', async () => {
+ delete process.env.OPENID_ROLE_SYNC_ENABLED;
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole', 'STANDARD-USER'],
+ permissions: ['not-admin'],
+ });
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBeUndefined();
+ expect(findRolesByNames).not.toHaveBeenCalled();
+ });
+
+ it('leaves ADMIN authoritative when OPENID_ADMIN_ROLE grants admin', async () => {
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole', 'STANDARD-USER'],
+ permissions: ['admin'],
+ });
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBe('ADMIN');
+ });
+
+ it('preserves an existing ADMIN role when admin is manually assigned', async () => {
+ delete process.env.OPENID_ADMIN_ROLE;
+ delete process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH;
+ delete process.env.OPENID_ADMIN_ROLE_TOKEN_KIND;
+ const existingAdminUser = {
+ _id: 'existingAdminId',
+ provider: 'openid',
+ email: tokenset.claims().email,
+ openidId: tokenset.claims().sub,
+ username: 'adminuser',
+ name: 'Admin User',
+ role: 'ADMIN',
+ };
+
+ findUser.mockImplementation(async (query) => {
+ if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
+ return existingAdminUser;
+ }
+ return null;
+ });
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole', 'STANDARD-USER'],
+ permissions: ['not-admin'],
+ });
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBe('ADMIN');
+ expect(findRolesByNames).not.toHaveBeenCalled();
+ });
+
+ it('uses fallback when a valid role claim has no configured role match', async () => {
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole', 'external-role'],
+ permissions: ['not-admin'],
+ });
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBe('USER');
+ });
+
+ it('uses fallback when the role claim is present but empty', async () => {
+ // The required-role gate reads the same `roles` claim this test empties, so
+ // disable it to model an IdP that authenticates the user yet emits no roles.
+ delete process.env.OPENID_REQUIRED_ROLE;
+ jwtDecode.mockReturnValue({
+ roles: '',
+ permissions: ['not-admin'],
+ });
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBe('USER');
+ expect(updateUser).toHaveBeenCalledWith(
+ 'newUserId',
+ expect.objectContaining({ role: 'USER' }),
+ );
+ });
+
+ it('applies fallback when the role claim is absent from the token', async () => {
+ // Required-role gate reads the same `roles` claim; disable it to model an IdP
+ // that authenticates the user but stops emitting the role claim entirely.
+ delete process.env.OPENID_REQUIRED_ROLE;
+ jwtDecode.mockReturnValue({
+ permissions: ['not-admin'],
+ });
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBe('USER');
+ expect(updateUser).toHaveBeenCalledWith(
+ 'newUserId',
+ expect.objectContaining({ role: 'USER' }),
+ );
+ });
+
+ it('rejects login when configured sync roles do not exist', async () => {
+ findRolesByNames.mockImplementation(async (roleNames) =>
+ roleNames
+ .filter((roleName) => roleName !== 'STANDARD-USER')
+ .map((roleName) => ({ name: roleName })),
+ );
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole', 'STANDARD-USER'],
+ permissions: ['not-admin'],
+ });
+
+ await expect(validate(tokenset)).rejects.toThrow(
+ 'OpenID role sync configured roles do not exist: STANDARD-USER',
+ );
+ });
+
+ it('can assign a non-admin role after the existing admin demotion path runs', async () => {
+ const existingAdminUser = {
+ _id: 'existingAdminId',
+ provider: 'openid',
+ email: tokenset.claims().email,
+ openidId: tokenset.claims().sub,
+ username: 'adminuser',
+ name: 'Admin User',
+ role: 'ADMIN',
+ };
+
+ findUser.mockImplementation(async (query) => {
+ if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
+ return existingAdminUser;
+ }
+ return null;
+ });
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole', 'STANDARD-USER'],
+ permissions: ['not-admin'],
+ });
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBe('STANDARD-USER');
+ expect(updateUser).toHaveBeenCalledWith(
+ existingAdminUser._id,
+ expect.objectContaining({ role: 'STANDARD-USER' }),
+ );
+ });
+
+ it('wraps role lookup in tenant context for tenant users', async () => {
+ const existingUser = {
+ _id: 'existingTenantUserId',
+ provider: 'openid',
+ email: tokenset.claims().email,
+ openidId: tokenset.claims().sub,
+ username: 'tenantuser',
+ name: 'Tenant User',
+ tenantId: 'tenant-a',
+ role: 'USER',
+ };
+ const { tenantStorage } = require('@librechat/data-schemas');
+
+ findUser.mockImplementation(async (query) => {
+ if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
+ return existingUser;
+ }
+ return null;
+ });
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole', 'BASIC-USER'],
+ permissions: ['not-admin'],
+ });
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBe('BASIC-USER');
+ expect(tenantStorage.run).toHaveBeenCalledWith(
+ { tenantId: 'tenant-a' },
+ expect.any(Function),
+ );
+ });
+
+ it('re-enforces tenant login policy after role sync changes the role', async () => {
+ const existingUser = {
+ _id: 'existingTenantUserId',
+ provider: 'openid',
+ email: tokenset.claims().email,
+ openidId: tokenset.claims().sub,
+ username: 'tenantuser',
+ name: 'Tenant User',
+ tenantId: 'tenant-a',
+ role: 'USER',
+ };
+ const { isEmailDomainAllowed } = require('@librechat/api');
+
+ findUser.mockImplementation(async (query) => {
+ if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
+ return existingUser;
+ }
+ return null;
+ });
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole', 'BASIC-USER'],
+ permissions: ['not-admin'],
+ });
+ // Pre-sync domain check passes; the post-sync re-resolved config rejects the domain.
+ isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false);
+ resolveAppConfigForUser.mockResolvedValue({
+ registration: { allowedDomains: ['restricted.com'] },
+ });
+
+ const { user, details } = await validate(tokenset);
+
+ expect(user).toBe(false);
+ expect(details).toEqual({ message: 'Email domain not allowed' });
+ });
+
+ it('reuses required-role overage groups for role sync', async () => {
+ process.env.OPENID_REQUIRED_ROLE = 'group-required';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
+ process.env.OPENID_ROLE_SYNC_CLAIM = 'groups';
+
+ jwtDecode.mockReturnValue({
+ hasgroups: true,
+ permissions: ['not-admin'],
+ });
+ undici.fetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ value: ['group-required', 'STANDARD-USER'] }),
+ });
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBe('STANDARD-USER');
+ expect(undici.fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('leaves the role unchanged when role-sync group overage cannot be resolved', async () => {
+ process.env.OPENID_ROLE_SYNC_CLAIM = 'groups';
+ const existingUser = {
+ _id: 'existingUserId',
+ provider: 'openid',
+ email: tokenset.claims().email,
+ openidId: tokenset.claims().sub,
+ username: 'existinguser',
+ name: 'Existing User',
+ role: 'BASIC-USER',
+ };
+
+ findUser.mockImplementation(async (query) => {
+ if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
+ return existingUser;
+ }
+ return null;
+ });
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole'],
+ hasgroups: true,
+ permissions: ['not-admin'],
+ });
+
+ const { user } = await validate({ ...tokenset, access_token: undefined });
+
+ expect(user.role).toBe('BASIC-USER');
+ });
+ });
+
+ it('should demote existing admin user when admin role is removed from token', async () => {
+ // Arrange – simulate an existing user who is currently an admin
+ const existingAdminUser = {
+ _id: 'existingAdminId',
+ provider: 'openid',
+ email: tokenset.claims().email,
+ openidId: tokenset.claims().sub,
+ username: 'adminuser',
+ name: 'Admin User',
+ role: 'ADMIN',
+ };
+
+ findUser.mockImplementation(async (query) => {
+ if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
+ return existingAdminUser;
+ }
+ return null;
+ });
+
+ // Token without admin permission
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole'],
+ permissions: ['not-admin'],
+ });
+
+ const { logger } = require('@librechat/data-schemas');
+
+ // Act
+ const { user } = await validate(tokenset);
+
+ // Assert – verify that the user was demoted
+ expect(user.role).toBe('USER');
+ expect(updateUser).toHaveBeenCalledWith(
+ existingAdminUser._id,
+ expect.objectContaining({
+ role: 'USER',
+ }),
+ );
+ expect(logger.info).toHaveBeenCalledWith(
+ expect.stringContaining('demoted from admin - role no longer present in token'),
+ );
+ });
+
+ it('should NOT demote admin user when admin role env vars are not configured', async () => {
+ // Arrange – remove admin role env vars
+ delete process.env.OPENID_ADMIN_ROLE;
+ delete process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH;
+ delete process.env.OPENID_ADMIN_ROLE_TOKEN_KIND;
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ // Simulate an existing admin user
+ const existingAdminUser = {
+ _id: 'existingAdminId',
+ provider: 'openid',
+ email: tokenset.claims().email,
+ openidId: tokenset.claims().sub,
+ username: 'adminuser',
+ name: 'Admin User',
+ role: 'ADMIN',
+ };
+
+ findUser.mockImplementation(async (query) => {
+ if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
+ return existingAdminUser;
+ }
+ return null;
+ });
+
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole'],
+ });
+
+ // Act
+ const { user } = await validate(tokenset);
+
+ // Assert – verify that the admin user was NOT demoted
+ expect(user.role).toBe('ADMIN');
+ expect(updateUser).toHaveBeenCalledWith(
+ existingAdminUser._id,
+ expect.objectContaining({
+ role: 'ADMIN',
+ }),
+ );
+ });
+
+ describe('lodash get - nested path extraction', () => {
+ it('should extract roles from deeply nested token path', async () => {
+ process.env.OPENID_REQUIRED_ROLE = 'app-user';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-client.roles';
+
+ jwtDecode.mockReturnValue({
+ resource_access: {
+ 'my-client': {
+ roles: ['app-user', 'viewer'],
+ },
+ },
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user } = await validate(tokenset);
+
+ expect(user).toBeTruthy();
+ expect(user.email).toBe(tokenset.claims().email);
+ });
+
+ it('should extract roles from three-level nested path', async () => {
+ process.env.OPENID_REQUIRED_ROLE = 'editor';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.access.permissions.roles';
+
+ jwtDecode.mockReturnValue({
+ data: {
+ access: {
+ permissions: {
+ roles: ['editor', 'reader'],
+ },
+ },
+ },
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user } = await validate(tokenset);
+
+ expect(user).toBeTruthy();
+ });
+
+ it('should log error and reject login when required role path does not exist in token', async () => {
+ const { logger } = require('@librechat/data-schemas');
+ process.env.OPENID_REQUIRED_ROLE = 'app-user';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.nonexistent.roles';
+
+ jwtDecode.mockReturnValue({
+ resource_access: {
+ 'my-client': {
+ roles: ['app-user'],
+ },
+ },
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user, details } = await validate(tokenset);
+
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining("Key 'resource_access.nonexistent.roles' not found in id token!"),
+ );
+ expect(user).toBe(false);
+ expect(details.message).toContain('role to log in');
+ });
+
+ it('should handle missing intermediate nested path gracefully', async () => {
+ const { logger } = require('@librechat/data-schemas');
+ process.env.OPENID_REQUIRED_ROLE = 'user';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'org.team.roles';
+
+ jwtDecode.mockReturnValue({
+ org: {
+ other: 'value',
+ },
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user } = await validate(tokenset);
+
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining("Key 'org.team.roles' not found in id token!"),
+ );
+ expect(user).toBe(false);
+ });
+
+ it('should extract admin role from nested path in access token', async () => {
+ process.env.OPENID_ADMIN_ROLE = 'admin';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'realm_access.roles';
+ process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'access';
+
+ jwtDecode.mockImplementation((token) => {
+ if (token === 'fake_access_token') {
+ return {
+ realm_access: {
+ roles: ['admin', 'user'],
+ },
+ };
+ }
+ return {
+ roles: ['requiredRole'],
+ };
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBe('ADMIN');
+ });
+
+ it('should extract admin role from nested path in userinfo', async () => {
+ process.env.OPENID_ADMIN_ROLE = 'admin';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'organization.permissions';
+ process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'userinfo';
+
+ const userinfoWithNestedGroups = {
+ ...tokenset.claims(),
+ organization: {
+ permissions: ['admin', 'write'],
+ },
+ };
+
+ require('openid-client').fetchUserInfo.mockResolvedValue({
+ organization: {
+ permissions: ['admin', 'write'],
+ },
+ });
+
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole'],
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user } = await validate({
+ ...tokenset,
+ claims: () => userinfoWithNestedGroups,
+ });
+
+ expect(user.role).toBe('ADMIN');
+ });
+
+ it('should handle boolean admin role value', async () => {
+ process.env.OPENID_ADMIN_ROLE = 'admin';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'is_admin';
+
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole'],
+ is_admin: true,
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBe('ADMIN');
+ });
+
+ it('should handle string admin role value matching exactly', async () => {
+ process.env.OPENID_ADMIN_ROLE = 'super-admin';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role';
+
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole'],
+ role: 'super-admin',
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBe('ADMIN');
+ });
+
+ it('should not set admin role when string value does not match', async () => {
+ process.env.OPENID_ADMIN_ROLE = 'super-admin';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role';
+
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole'],
+ role: 'regular-user',
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBeUndefined();
+ });
+
+ it('should handle array admin role value', async () => {
+ process.env.OPENID_ADMIN_ROLE = 'site-admin';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles';
+
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole'],
+ app_roles: ['user', 'site-admin', 'moderator'],
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBe('ADMIN');
+ });
+
+ it('should not set admin when role is not in array', async () => {
+ process.env.OPENID_ADMIN_ROLE = 'site-admin';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles';
+
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole'],
+ app_roles: ['user', 'moderator'],
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user } = await validate(tokenset);
+
+ expect(user.role).toBeUndefined();
+ });
+
+ it('should grant admin when admin role claim is a space-separated string containing the admin role', async () => {
+ // Arrange – IdP returns admin roles as a space-delimited string
+ process.env.OPENID_ADMIN_ROLE = 'site-admin';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles';
+
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole'],
+ app_roles: 'user site-admin moderator',
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ // Act
+ const { user } = await validate(tokenset);
+
+ // Assert – admin role is granted after splitting the delimited string
+ expect(user.role).toBe('ADMIN');
+ });
+
+ it('should not grant admin when admin role claim is a space-separated string that does not contain the admin role', async () => {
+ // Arrange – delimited string present but admin role is absent
+ process.env.OPENID_ADMIN_ROLE = 'site-admin';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles';
+
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole'],
+ app_roles: 'user moderator',
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ // Act
+ const { user } = await validate(tokenset);
+
+ // Assert – admin role is not granted
+ expect(user.role).toBeUndefined();
+ });
+
+ it('should handle nested path with special characters in keys', async () => {
+ process.env.OPENID_REQUIRED_ROLE = 'app-user';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-app-123.roles';
+
+ jwtDecode.mockReturnValue({
+ resource_access: {
+ 'my-app-123': {
+ roles: ['app-user'],
+ },
+ },
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user } = await validate(tokenset);
+
+ expect(user).toBeTruthy();
+ });
+
+ it('should handle empty object at nested path', async () => {
+ const { logger } = require('@librechat/data-schemas');
+ process.env.OPENID_REQUIRED_ROLE = 'user';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'access.roles';
+
+ jwtDecode.mockReturnValue({
+ access: {},
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user } = await validate(tokenset);
+
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining("Key 'access.roles' not found in id token!"),
+ );
+ expect(user).toBe(false);
+ });
+
+ it('should handle null value at intermediate path', async () => {
+ const { logger } = require('@librechat/data-schemas');
+ process.env.OPENID_REQUIRED_ROLE = 'user';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.roles';
+
+ jwtDecode.mockReturnValue({
+ data: null,
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user } = await validate(tokenset);
+
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining("Key 'data.roles' not found in id token!"),
+ );
+ expect(user).toBe(false);
+ });
+
+ it('should reject login with invalid admin role token kind', async () => {
+ process.env.OPENID_ADMIN_ROLE = 'admin';
+ process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'roles';
+ process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'invalid';
+
+ const { logger } = require('@librechat/data-schemas');
+
+ jwtDecode.mockReturnValue({
+ roles: ['requiredRole', 'admin'],
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ await expect(validate(tokenset)).rejects.toThrow('Invalid admin role token kind');
+
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "Invalid admin role token kind: invalid. Must be one of 'access', 'id', or 'userinfo'",
+ ),
+ );
+ });
+
+ it('should reject login when roles path returns invalid type (object)', async () => {
+ const { logger } = require('@librechat/data-schemas');
+ process.env.OPENID_REQUIRED_ROLE = 'app-user';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
+
+ jwtDecode.mockReturnValue({
+ roles: { admin: true, user: false },
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user, details } = await validate(tokenset);
+
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining("Key 'roles' not found in id token!"),
+ );
+ expect(user).toBe(false);
+ expect(details.message).toContain('role to log in');
+ });
+
+ it('should reject login when roles path returns invalid type (number)', async () => {
+ const { logger } = require('@librechat/data-schemas');
+ process.env.OPENID_REQUIRED_ROLE = 'user';
+ process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roleCount';
+
+ jwtDecode.mockReturnValue({
+ roleCount: 5,
+ });
+
+ await setupOpenId();
+ verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
+
+ const { user } = await validate(tokenset);
+
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining("Key 'roleCount' not found in id token!"),
+ );
+ expect(user).toBe(false);
+ });
+ });
+
+ describe('OPENID_EMAIL_CLAIM', () => {
+ it('should use the default email when OPENID_EMAIL_CLAIM is not set', async () => {
+ const { user } = await validate(tokenset);
+ expect(user.email).toBe('test@example.com');
+ });
+
+ it('should use the configured claim when OPENID_EMAIL_CLAIM is set', async () => {
+ process.env.OPENID_EMAIL_CLAIM = 'upn';
+ const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' };
+
+ const { user } = await validate({ ...tokenset, claims: () => userinfo });
+
+ expect(user.email).toBe('user@corp.example.com');
+ expect(createUser).toHaveBeenCalledWith(
+ expect.objectContaining({ email: 'user@corp.example.com' }),
+ expect.anything(),
+ true,
+ true,
+ );
+ });
+
+ it('should fall back to preferred_username when email is missing and OPENID_EMAIL_CLAIM is not set', async () => {
+ const userinfo = { ...tokenset.claims() };
+ delete userinfo.email;
+
+ const { user } = await validate({ ...tokenset, claims: () => userinfo });
+
+ expect(user.email).toBe('testusername');
+ });
+
+ it('should fall back to upn when email and preferred_username are missing and OPENID_EMAIL_CLAIM is not set', async () => {
+ const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' };
+ delete userinfo.email;
+ delete userinfo.preferred_username;
+
+ const { user } = await validate({ ...tokenset, claims: () => userinfo });
+
+ expect(user.email).toBe('user@corp.example.com');
+ });
+
+ it('should ignore empty string OPENID_EMAIL_CLAIM and use default fallback', async () => {
+ process.env.OPENID_EMAIL_CLAIM = '';
+
+ const { user } = await validate(tokenset);
+
+ expect(user.email).toBe('test@example.com');
+ });
+
+ it('should trim whitespace from OPENID_EMAIL_CLAIM and resolve correctly', async () => {
+ process.env.OPENID_EMAIL_CLAIM = ' upn ';
+ const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' };
+
+ const { user } = await validate({ ...tokenset, claims: () => userinfo });
+
+ expect(user.email).toBe('user@corp.example.com');
+ });
+
+ it('should ignore whitespace-only OPENID_EMAIL_CLAIM and use default fallback', async () => {
+ process.env.OPENID_EMAIL_CLAIM = ' ';
+
+ const { user } = await validate(tokenset);
+
+ expect(user.email).toBe('test@example.com');
+ });
+
+ it('should fall back to default chain with warning when configured claim is missing from userinfo', async () => {
+ const { logger } = require('@librechat/data-schemas');
+ process.env.OPENID_EMAIL_CLAIM = 'nonexistent_claim';
+
+ const { user } = await validate(tokenset);
+
+ expect(user.email).toBe('test@example.com');
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('OPENID_EMAIL_CLAIM="nonexistent_claim" not present in userinfo'),
+ );
+ });
+ });
+
+ describe('Tenant-scoped config', () => {
+ it('should call resolveAppConfigForUser for tenant user', async () => {
+ const existingUser = {
+ _id: 'openid-tenant-user',
+ provider: 'openid',
+ openidId: '1234',
+ email: 'test@example.com',
+ tenantId: 'tenant-d',
+ role: 'USER',
+ };
+ findUser.mockResolvedValue(existingUser);
+
+ await validate(tokenset);
+
+ expect(resolveAppConfigForUser).toHaveBeenCalledWith(getAppConfig, existingUser);
+ });
+
+ it('should use baseConfig for new user without calling resolveAppConfigForUser', async () => {
+ findUser.mockResolvedValue(null);
+
+ await validate(tokenset);
+
+ expect(resolveAppConfigForUser).not.toHaveBeenCalled();
+ expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true });
+ });
+
+ it('should block login when tenant config restricts the domain', async () => {
+ const { isEmailDomainAllowed } = require('@librechat/api');
+ const existingUser = {
+ _id: 'openid-tenant-blocked',
+ provider: 'openid',
+ openidId: '1234',
+ email: 'test@example.com',
+ tenantId: 'tenant-restrict',
+ role: 'USER',
+ };
+ findUser.mockResolvedValue(existingUser);
+ resolveAppConfigForUser.mockResolvedValue({
+ registration: { allowedDomains: ['other.com'] },
+ });
+ isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false);
+
+ const { user, details } = await validate(tokenset);
+ expect(user).toBe(false);
+ expect(details).toEqual({ message: 'Email domain not allowed' });
+ });
+ });
+});
+
+describe('getRoleSource', () => {
+ const { getRoleSource } = require('./openidStrategy');
+ const { logger } = require('@librechat/data-schemas');
+
+ const accessClaims = { roles: ['from-access'] };
+ const idClaims = { roles: ['from-id'] };
+ const userinfo = { roles: ['from-userinfo'] };
+ const tokenset = { access_token: 'access.jwt', id_token: 'id.jwt' };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jwtDecode.mockImplementation((token) => {
+ if (token === 'access.jwt') return accessClaims;
+ if (token === 'id.jwt') return idClaims;
+ return {};
+ });
+ });
+
+ it.each([
+ ['access', accessClaims],
+ ['id', idClaims],
+ ['userinfo', userinfo],
+ ])('returns the expected source object for kind=%s', (kind, expected) => {
+ expect(getRoleSource(kind, 'required role', tokenset, userinfo)).toEqual(expected);
+ });
+
+ it.each([
+ ['undefined', undefined],
+ ['empty string', ''],
+ ['unknown kind', 'bogus'],
+ ])('throws and logs for invalid kind: %s', (_name, kind) => {
+ expect(() => getRoleSource(kind, 'required role', tokenset, userinfo)).toThrow(
+ 'Invalid required role token kind',
+ );
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining(`Invalid required role token kind: ${kind}`),
+ );
+ });
+
+ it('uses the provided label in the error message and thrown error', () => {
+ expect(() => getRoleSource('bogus', 'admin role', tokenset, userinfo)).toThrow(
+ 'Invalid admin role token kind',
+ );
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining('Invalid admin role token kind: bogus'),
+ );
+ });
+
+ it('propagates jwtDecode errors when the requested token is missing', () => {
+ jwtDecode.mockImplementation(() => {
+ throw new Error('Invalid token specified');
+ });
+ expect(() => getRoleSource('access', 'required role', {}, userinfo)).toThrow(
+ 'Invalid token specified',
+ );
+ });
+});
diff --git a/api/strategies/process.js b/api/strategies/process.js
new file mode 100644
index 0000000..c1e0ad0
--- /dev/null
+++ b/api/strategies/process.js
@@ -0,0 +1,125 @@
+const { getBalanceConfig } = require('@librechat/api');
+const { FileSources } = require('librechat-data-provider');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { resizeAvatar } = require('~/server/services/Files/images/avatar');
+const { updateUser, createUser, getUserById } = require('~/models');
+
+/**
+ * Updates the avatar URL and email of an existing user. If the user's avatar URL does not include the query parameter
+ * '?manual=true', it updates the user's avatar with the provided URL. For local file storage, it directly updates
+ * the avatar URL, while for other storage types, it processes the avatar URL using the specified file strategy.
+ * Also updates the email if it has changed (e.g., when a Google Workspace email is updated).
+ *
+ * @param {IUser} oldUser - The existing user object that needs to be updated.
+ * @param {string} avatarUrl - The new avatar URL to be set for the user.
+ * @param {AppConfig} appConfig - The application configuration object.
+ * @param {string} [email] - Optional. The new email address to update if it has changed.
+ *
+ * @returns {Promise}
+ * The function updates the user's avatar and/or email and saves the user object. It does not return any value.
+ *
+ * @throws {Error} Throws an error if there's an issue saving the updated user object.
+ */
+const handleExistingUser = async (oldUser, avatarUrl, appConfig, email) => {
+ const fileStrategy = appConfig?.fileStrategy ?? process.env.CDN_PROVIDER;
+ const isLocal = fileStrategy === FileSources.local;
+ const updates = {};
+
+ let updatedAvatar = false;
+ const hasManualFlag =
+ typeof oldUser?.avatar === 'string' && oldUser.avatar.includes('?manual=true');
+
+ if (isLocal && (!oldUser?.avatar || !hasManualFlag)) {
+ updatedAvatar = avatarUrl;
+ } else if (!isLocal && (!oldUser?.avatar || !hasManualFlag)) {
+ const userId = oldUser._id;
+ const resizedBuffer = await resizeAvatar({
+ userId,
+ input: avatarUrl,
+ });
+ const { processAvatar } = getStrategyFunctions(fileStrategy);
+ updatedAvatar = await processAvatar({ buffer: resizedBuffer, userId, manual: 'false' });
+ }
+
+ if (updatedAvatar) {
+ updates.avatar = updatedAvatar;
+ }
+
+ /** Update email if it has changed */
+ if (email && email.trim() !== oldUser.email) {
+ updates.email = email.trim();
+ }
+
+ if (Object.keys(updates).length > 0) {
+ await updateUser(oldUser._id, updates);
+ }
+};
+
+/**
+ * Creates a new user with the provided user details. If the file strategy is not local, the avatar URL is
+ * processed using the specified file strategy. The new user is saved to the database with the processed or
+ * original avatar URL.
+ *
+ * @param {Object} params - The parameters object for user creation.
+ * @param {string} params.email - The email of the new user.
+ * @param {string} params.avatarUrl - The avatar URL of the new user.
+ * @param {string} params.provider - The provider of the user's account.
+ * @param {string} params.providerKey - The key to identify the provider in the user model.
+ * @param {string} params.providerId - The provider-specific ID of the user.
+ * @param {string} params.username - The username of the new user.
+ * @param {string} params.name - The name of the new user.
+ * @param {AppConfig} appConfig - The application configuration object.
+ * @param {boolean} [params.emailVerified=false] - Optional. Indicates whether the user's email is verified. Defaults to false.
+ *
+ * @returns {Promise}
+ * A promise that resolves to the newly created user object.
+ *
+ * @throws {Error} Throws an error if there's an issue creating or saving the new user object.
+ */
+const createSocialUser = async ({
+ email,
+ avatarUrl,
+ provider,
+ providerKey,
+ providerId,
+ username,
+ name,
+ appConfig,
+ emailVerified,
+}) => {
+ const update = {
+ email,
+ avatar: avatarUrl,
+ provider,
+ [providerKey]: providerId,
+ username,
+ name,
+ emailVerified,
+ };
+
+ const balanceConfig = getBalanceConfig(appConfig);
+ const newUserId = await createUser(update, balanceConfig);
+ const fileStrategy = appConfig?.fileStrategy ?? process.env.CDN_PROVIDER;
+ const isLocal = fileStrategy === FileSources.local;
+
+ if (!isLocal) {
+ const resizedBuffer = await resizeAvatar({
+ userId: newUserId,
+ input: avatarUrl,
+ });
+ const { processAvatar } = getStrategyFunctions(fileStrategy);
+ const avatar = await processAvatar({
+ buffer: resizedBuffer,
+ userId: newUserId,
+ manual: 'false',
+ });
+ await updateUser(newUserId, { avatar });
+ }
+
+ return await getUserById(newUserId);
+};
+
+module.exports = {
+ handleExistingUser,
+ createSocialUser,
+};
diff --git a/api/strategies/process.test.js b/api/strategies/process.test.js
new file mode 100644
index 0000000..ab5fdb6
--- /dev/null
+++ b/api/strategies/process.test.js
@@ -0,0 +1,242 @@
+const { FileSources } = require('librechat-data-provider');
+const { handleExistingUser } = require('./process');
+
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/images/avatar', () => ({
+ resizeAvatar: jest.fn(),
+}));
+
+jest.mock('~/models', () => ({
+ updateUser: jest.fn(),
+ createUser: jest.fn(),
+ getUserById: jest.fn(),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn().mockResolvedValue({}),
+}));
+
+jest.mock('@librechat/api', () => ({
+ getBalanceConfig: jest.fn(() => ({
+ enabled: false,
+ })),
+}));
+
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { resizeAvatar } = require('~/server/services/Files/images/avatar');
+const { updateUser } = require('~/models');
+
+describe('handleExistingUser', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ process.env.CDN_PROVIDER = FileSources.local;
+ });
+
+ it('should handle null avatar without throwing error', async () => {
+ const oldUser = {
+ _id: 'user123',
+ avatar: null,
+ };
+ const avatarUrl = 'https://example.com/avatar.png';
+
+ await handleExistingUser(oldUser, avatarUrl);
+
+ expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
+ });
+
+ it('should handle undefined avatar without throwing error', async () => {
+ const oldUser = {
+ _id: 'user123',
+ // avatar is undefined
+ };
+ const avatarUrl = 'https://example.com/avatar.png';
+
+ await handleExistingUser(oldUser, avatarUrl);
+
+ expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
+ });
+
+ it('should not update avatar if it has manual=true flag', async () => {
+ const oldUser = {
+ _id: 'user123',
+ avatar: 'https://example.com/avatar.png?manual=true',
+ };
+ const avatarUrl = 'https://example.com/new-avatar.png';
+
+ await handleExistingUser(oldUser, avatarUrl);
+
+ expect(updateUser).not.toHaveBeenCalled();
+ });
+
+ it('should update avatar for local storage when avatar has no manual flag', async () => {
+ const oldUser = {
+ _id: 'user123',
+ avatar: 'https://example.com/old-avatar.png',
+ };
+ const avatarUrl = 'https://example.com/new-avatar.png';
+
+ await handleExistingUser(oldUser, avatarUrl);
+
+ expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
+ });
+
+ it('should process avatar for non-local storage', async () => {
+ process.env.CDN_PROVIDER = 's3';
+
+ const mockProcessAvatar = jest.fn().mockResolvedValue('processed-avatar-url');
+ getStrategyFunctions.mockReturnValue({ processAvatar: mockProcessAvatar });
+ resizeAvatar.mockResolvedValue(Buffer.from('resized-image'));
+
+ const oldUser = {
+ _id: 'user123',
+ avatar: null,
+ };
+ const avatarUrl = 'https://example.com/avatar.png';
+
+ await handleExistingUser(oldUser, avatarUrl);
+
+ expect(resizeAvatar).toHaveBeenCalledWith({
+ userId: 'user123',
+ input: avatarUrl,
+ });
+ expect(mockProcessAvatar).toHaveBeenCalledWith({
+ buffer: Buffer.from('resized-image'),
+ userId: 'user123',
+ manual: 'false',
+ });
+ expect(updateUser).toHaveBeenCalledWith('user123', { avatar: 'processed-avatar-url' });
+ });
+
+ it('should not update if avatar already has manual flag in non-local storage', async () => {
+ process.env.CDN_PROVIDER = 's3';
+
+ const oldUser = {
+ _id: 'user123',
+ avatar: 'https://cdn.example.com/avatar.png?manual=true',
+ };
+ const avatarUrl = 'https://example.com/new-avatar.png';
+
+ await handleExistingUser(oldUser, avatarUrl);
+
+ expect(resizeAvatar).not.toHaveBeenCalled();
+ expect(updateUser).not.toHaveBeenCalled();
+ });
+
+ it('should handle avatar with query parameters but without manual flag', async () => {
+ const oldUser = {
+ _id: 'user123',
+ avatar: 'https://example.com/avatar.png?size=large&format=webp',
+ };
+ const avatarUrl = 'https://example.com/new-avatar.png';
+
+ await handleExistingUser(oldUser, avatarUrl);
+
+ expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
+ });
+
+ it('should handle empty string avatar', async () => {
+ const oldUser = {
+ _id: 'user123',
+ avatar: '',
+ };
+ const avatarUrl = 'https://example.com/avatar.png';
+
+ await handleExistingUser(oldUser, avatarUrl);
+
+ expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
+ });
+
+ it('should handle avatar with manual=false parameter', async () => {
+ const oldUser = {
+ _id: 'user123',
+ avatar: 'https://example.com/avatar.png?manual=false',
+ };
+ const avatarUrl = 'https://example.com/new-avatar.png';
+
+ await handleExistingUser(oldUser, avatarUrl);
+
+ expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
+ });
+
+ it('should handle oldUser being null gracefully', async () => {
+ const avatarUrl = 'https://example.com/avatar.png';
+
+ // This should throw an error when trying to access oldUser._id
+ await expect(handleExistingUser(null, avatarUrl)).rejects.toThrow();
+ });
+
+ it('should update email when it has changed', async () => {
+ const oldUser = {
+ _id: 'user123',
+ email: 'old@example.com',
+ avatar: 'https://example.com/avatar.png?manual=true',
+ };
+ const avatarUrl = 'https://example.com/avatar.png';
+ const newEmail = 'new@example.com';
+
+ await handleExistingUser(oldUser, avatarUrl, {}, newEmail);
+
+ expect(updateUser).toHaveBeenCalledWith('user123', { email: 'new@example.com' });
+ });
+
+ it('should update both avatar and email when both have changed', async () => {
+ const oldUser = {
+ _id: 'user123',
+ email: 'old@example.com',
+ avatar: null,
+ };
+ const avatarUrl = 'https://example.com/new-avatar.png';
+ const newEmail = 'new@example.com';
+
+ await handleExistingUser(oldUser, avatarUrl, {}, newEmail);
+
+ expect(updateUser).toHaveBeenCalledWith('user123', {
+ avatar: avatarUrl,
+ email: 'new@example.com',
+ });
+ });
+
+ it('should not update email when it has not changed', async () => {
+ const oldUser = {
+ _id: 'user123',
+ email: 'same@example.com',
+ avatar: 'https://example.com/avatar.png?manual=true',
+ };
+ const avatarUrl = 'https://example.com/avatar.png';
+ const sameEmail = 'same@example.com';
+
+ await handleExistingUser(oldUser, avatarUrl, {}, sameEmail);
+
+ expect(updateUser).not.toHaveBeenCalled();
+ });
+
+ it('should trim email before comparison and update', async () => {
+ const oldUser = {
+ _id: 'user123',
+ email: 'test@example.com',
+ avatar: 'https://example.com/avatar.png?manual=true',
+ };
+ const avatarUrl = 'https://example.com/avatar.png';
+ const newEmailWithSpaces = ' newemail@example.com ';
+
+ await handleExistingUser(oldUser, avatarUrl, {}, newEmailWithSpaces);
+
+ expect(updateUser).toHaveBeenCalledWith('user123', { email: 'newemail@example.com' });
+ });
+
+ it('should not update when email parameter is not provided', async () => {
+ const oldUser = {
+ _id: 'user123',
+ email: 'test@example.com',
+ avatar: 'https://example.com/avatar.png?manual=true',
+ };
+ const avatarUrl = 'https://example.com/avatar.png';
+
+ await handleExistingUser(oldUser, avatarUrl, {});
+
+ expect(updateUser).not.toHaveBeenCalled();
+ });
+});
diff --git a/api/strategies/samlStrategy.js b/api/strategies/samlStrategy.js
new file mode 100644
index 0000000..cab4304
--- /dev/null
+++ b/api/strategies/samlStrategy.js
@@ -0,0 +1,357 @@
+const fs = require('fs');
+const path = require('path');
+const passport = require('passport');
+const { ErrorTypes } = require('librechat-data-provider');
+const { hashToken, logger } = require('@librechat/data-schemas');
+const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
+const {
+ getBalanceConfig,
+ isEmailDomainAllowed,
+ getAvatarFileStrategy,
+ getAvatarSaveParams,
+ resolveAppConfigForUser,
+} = require('@librechat/api');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { resizeAvatar } = require('~/server/services/Files/images/avatar');
+const { findUser, createUser, updateUser } = require('~/models');
+const { getAppConfig } = require('~/server/services/Config');
+const paths = require('~/config/paths');
+
+let crypto;
+try {
+ crypto = require('node:crypto');
+} catch (err) {
+ logger.error('[samlStrategy] crypto support is disabled!', err);
+}
+
+/**
+ * Retrieves the certificate content from the given value.
+ *
+ * This function determines whether the provided value is a certificate string (RFC7468 format or
+ * base64-encoded without a header) or a valid file path. If the value matches one of these formats,
+ * the certificate content is returned. Otherwise, an error is thrown.
+ *
+ * @see https://github.com/node-saml/node-saml/tree/master?tab=readme-ov-file#configuration-option-idpcert
+ * @param {string} value - The certificate string or file path.
+ * @returns {string} The certificate content if valid.
+ * @throws {Error} If the value is not a valid certificate string or file path.
+ */
+function getCertificateContent(value) {
+ if (typeof value !== 'string') {
+ throw new Error('Invalid input: SAML_CERT must be a string.');
+ }
+
+ // Check if it's an RFC7468 formatted PEM certificate
+ const pemRegex = new RegExp(
+ '-----BEGIN (CERTIFICATE|PUBLIC KEY)-----\n' + // header
+ '([A-Za-z0-9+/=]{64}\n)+' + // base64 content (64 characters per line)
+ '[A-Za-z0-9+/=]{1,64}\n' + // base64 content (last line)
+ '-----END (CERTIFICATE|PUBLIC KEY)-----', // footer
+ );
+ if (pemRegex.test(value)) {
+ logger.info('[samlStrategy] Detected RFC7468-formatted certificate string.');
+ return value;
+ }
+
+ // Check if it's a Base64-encoded certificate (no header)
+ if (/^[A-Za-z0-9+/=]+$/.test(value) && value.length % 4 === 0) {
+ logger.info('[samlStrategy] Detected base64-encoded certificate string (no header).');
+ return value;
+ }
+
+ // Check if file exists and is readable
+ const certPath = path.normalize(path.isAbsolute(value) ? value : path.join(paths.root, value));
+ if (fs.existsSync(certPath) && fs.statSync(certPath).isFile()) {
+ try {
+ logger.info(`[samlStrategy] Loading certificate from file: ${certPath}`);
+ return fs.readFileSync(certPath, 'utf8').trim();
+ } catch (error) {
+ throw new Error(`Error reading certificate file: ${error.message}`);
+ }
+ }
+
+ throw new Error('Invalid cert: SAML_CERT must be a valid file path or certificate string.');
+}
+
+/**
+ * Retrieves a SAML claim from a profile object based on environment configuration.
+ * @param {object} profile - Saml profile
+ * @param {string} envVar - Environment variable name (SAML_*)
+ * @param {string} defaultKey - Default key to use if the environment variable is not set
+ * @returns {string}
+ */
+function getSamlClaim(profile, envVar, defaultKey) {
+ const claimKey = process.env[envVar];
+
+ // Avoids accessing `profile[""]` when the environment variable is empty string.
+ if (claimKey) {
+ return profile[claimKey] ?? profile[defaultKey];
+ }
+ return profile[defaultKey];
+}
+
+function getEmail(profile) {
+ return getSamlClaim(profile, 'SAML_EMAIL_CLAIM', 'email');
+}
+
+function getUserName(profile) {
+ return getSamlClaim(profile, 'SAML_USERNAME_CLAIM', 'username');
+}
+
+function getGivenName(profile) {
+ return getSamlClaim(profile, 'SAML_GIVEN_NAME_CLAIM', 'given_name');
+}
+
+function getFamilyName(profile) {
+ return getSamlClaim(profile, 'SAML_FAMILY_NAME_CLAIM', 'family_name');
+}
+
+function getPicture(profile) {
+ return getSamlClaim(profile, 'SAML_PICTURE_CLAIM', 'picture');
+}
+
+const resizeIdentityProviderAvatar = async (url, userId) => {
+ if (!url) {
+ return null;
+ }
+
+ try {
+ return await resizeAvatar({ userId, input: url });
+ } catch (error) {
+ logger.error(
+ `[samlStrategy] resizeIdentityProviderAvatar: Error processing avatar at URL "${url}": ${error}`,
+ );
+ return null;
+ }
+};
+
+/**
+ * Determines the full name of a user based on SAML profile and environment configuration.
+ *
+ * @param {Object} profile - The user profile object from SAML Connect
+ * @returns {string} The determined full name of the user
+ */
+function getFullName(profile) {
+ if (process.env.SAML_NAME_CLAIM) {
+ logger.info(
+ `[samlStrategy] Using SAML_NAME_CLAIM: ${process.env.SAML_NAME_CLAIM}, profile: ${profile[process.env.SAML_NAME_CLAIM]}`,
+ );
+ return profile[process.env.SAML_NAME_CLAIM];
+ }
+
+ const givenName = getGivenName(profile);
+ const familyName = getFamilyName(profile);
+
+ if (givenName && familyName) {
+ return `${givenName} ${familyName}`;
+ }
+
+ if (givenName) {
+ return givenName;
+ }
+ if (familyName) {
+ return familyName;
+ }
+
+ return getUserName(profile) || getEmail(profile);
+}
+
+/**
+ * Converts an input into a string suitable for a username.
+ * If the input is a string, it will be returned as is.
+ * If the input is an array, elements will be joined with underscores.
+ * In case of undefined or other falsy values, a default value will be returned.
+ *
+ * @param {string | string[] | undefined} input - The input value to be converted into a username.
+ * @param {string} [defaultValue=''] - The default value to return if the input is falsy.
+ * @returns {string} The processed input as a string suitable for a username.
+ */
+function convertToUsername(input, defaultValue = '') {
+ if (typeof input === 'string') {
+ return input;
+ } else if (Array.isArray(input)) {
+ return input.join('_');
+ }
+
+ return defaultValue;
+}
+
+/**
+ * Creates a SAML authentication callback.
+ * @param {boolean} [existingUsersOnly=false] - If true, only existing users will be authenticated.
+ * @returns {Function} The SAML callback function for passport.
+ */
+function createSamlCallback(existingUsersOnly = false) {
+ return async (profile, done) => {
+ try {
+ logger.info(`[samlStrategy] SAML authentication received for NameID: ${profile.nameID}`);
+ logger.debug('[samlStrategy] SAML profile:', profile);
+
+ const userEmail = getEmail(profile) || '';
+
+ const baseConfig = await getAppConfig({ baseOnly: true });
+ if (!isEmailDomainAllowed(userEmail, baseConfig?.registration?.allowedDomains)) {
+ logger.error(
+ `[SAML Strategy] Authentication blocked - email domain not allowed [Email: ${userEmail}]`,
+ );
+ return done(null, false, { message: 'Email domain not allowed' });
+ }
+
+ let user = await findUser({ samlId: profile.nameID });
+ logger.info(
+ `[samlStrategy] User ${user ? 'found' : 'not found'} with SAML ID: ${profile.nameID}`,
+ );
+
+ if (!user) {
+ user = await findUser({ email: userEmail });
+ logger.info(`[samlStrategy] User ${user ? 'found' : 'not found'} with email: ${userEmail}`);
+ }
+
+ if (user && user.provider !== 'saml') {
+ logger.info(
+ `[samlStrategy] User ${user.email} already exists with provider ${user.provider}`,
+ );
+ return done(null, false, {
+ message: ErrorTypes.AUTH_FAILED,
+ });
+ }
+
+ const appConfig = user?.tenantId
+ ? await resolveAppConfigForUser(getAppConfig, user)
+ : baseConfig;
+
+ if (!isEmailDomainAllowed(userEmail, appConfig?.registration?.allowedDomains)) {
+ logger.error(
+ `[SAML Strategy] Authentication blocked - email domain not allowed [Email: ${userEmail}]`,
+ );
+ return done(null, false, { message: 'Email domain not allowed' });
+ }
+
+ const fullName = getFullName(profile);
+
+ const username = convertToUsername(
+ getUserName(profile) || getGivenName(profile) || getEmail(profile),
+ );
+
+ if (!user) {
+ if (existingUsersOnly) {
+ logger.error(
+ `[samlStrategy] Admin auth blocked - user does not exist [Email: ${userEmail}]`,
+ );
+ return done(null, false, { message: 'User does not exist' });
+ }
+
+ user = {
+ provider: 'saml',
+ samlId: profile.nameID,
+ username,
+ email: userEmail,
+ emailVerified: true,
+ name: fullName,
+ };
+ const balanceConfig = getBalanceConfig(appConfig);
+ user = await createUser(user, balanceConfig, true, true);
+ } else {
+ user.provider = 'saml';
+ user.samlId = profile.nameID;
+ user.username = username;
+ user.name = fullName;
+ }
+
+ const picture = getPicture(profile);
+ if (picture && !user.avatar?.includes('manual=true')) {
+ const userId = user._id.toString();
+ const imageBuffer = await resizeIdentityProviderAvatar(picture, userId);
+ if (imageBuffer) {
+ let fileName;
+ if (crypto) {
+ fileName = (await hashToken(profile.nameID)) + '.png';
+ } else {
+ fileName = profile.nameID + '.png';
+ }
+
+ const fileStrategy = getAvatarFileStrategy(appConfig, process.env.CDN_PROVIDER);
+ const { saveBuffer } = getStrategyFunctions(fileStrategy);
+ const imagePath = await saveBuffer(
+ getAvatarSaveParams(fileStrategy, {
+ fileName,
+ userId,
+ buffer: imageBuffer,
+ tenantId: user.tenantId,
+ }),
+ );
+ user.avatar = imagePath ?? '';
+ }
+ }
+
+ user = await updateUser(user._id, user);
+
+ logger.info(
+ `[samlStrategy] Login success SAML ID: ${user.samlId} | email: ${user.email} | username: ${user.username}`,
+ {
+ user: {
+ samlId: user.samlId,
+ username: user.username,
+ email: user.email,
+ name: user.name,
+ },
+ },
+ );
+
+ done(null, user);
+ } catch (err) {
+ logger.error('[samlStrategy] Login failed', err);
+ done(err);
+ }
+ };
+}
+
+/**
+ * Returns the base SAML configuration shared by both regular and admin strategies.
+ * @returns {object} The SAML configuration object.
+ */
+function getBaseSamlConfig() {
+ return {
+ entryPoint: process.env.SAML_ENTRY_POINT,
+ issuer: process.env.SAML_ISSUER,
+ idpCert: getCertificateContent(process.env.SAML_CERT),
+ wantAssertionsSigned: process.env.SAML_USE_AUTHN_RESPONSE_SIGNED === 'true' ? false : true,
+ wantAuthnResponseSigned: process.env.SAML_USE_AUTHN_RESPONSE_SIGNED === 'true' ? true : false,
+ };
+}
+
+async function setupSaml() {
+ try {
+ const baseConfig = getBaseSamlConfig();
+ const samlConfig = {
+ ...baseConfig,
+ callbackUrl: process.env.SAML_CALLBACK_URL,
+ };
+
+ passport.use('saml', new SamlStrategy(samlConfig, createSamlCallback(false)));
+ setupSamlAdmin(baseConfig);
+ } catch (err) {
+ logger.error('[samlStrategy]', err);
+ }
+}
+
+/**
+ * Sets up the SAML strategy specifically for admin authentication.
+ * Rejects users that don't already exist.
+ * @param {object} [baseConfig] - Pre-parsed base SAML config to avoid redundant cert parsing.
+ */
+function setupSamlAdmin(baseConfig) {
+ try {
+ const samlAdminConfig = {
+ ...(baseConfig ?? getBaseSamlConfig()),
+ callbackUrl: `${process.env.DOMAIN_SERVER}/api/admin/oauth/saml/callback`,
+ };
+
+ passport.use('samlAdmin', new SamlStrategy(samlAdminConfig, createSamlCallback(true)));
+ logger.info('[samlStrategy] Admin SAML strategy registered.');
+ } catch (err) {
+ logger.error('[samlStrategy] setupSamlAdmin', err);
+ }
+}
+
+module.exports = { setupSaml, getCertificateContent };
diff --git a/api/strategies/samlStrategy.spec.js b/api/strategies/samlStrategy.spec.js
new file mode 100644
index 0000000..301fee3
--- /dev/null
+++ b/api/strategies/samlStrategy.spec.js
@@ -0,0 +1,589 @@
+// --- Mocks ---
+jest.mock('fs');
+jest.mock('path');
+jest.mock('node-fetch');
+jest.mock('@node-saml/passport-saml');
+jest.mock('@librechat/data-schemas', () => ({
+ logger: {
+ info: jest.fn(),
+ debug: jest.fn(),
+ error: jest.fn(),
+ },
+ hashToken: jest.fn().mockResolvedValue('hashed-token'),
+}));
+jest.mock('~/models', () => ({
+ findUser: jest.fn(),
+ createUser: jest.fn(),
+ updateUser: jest.fn(),
+}));
+jest.mock('~/server/services/Config', () => ({
+ config: {
+ registration: {
+ socialLogins: ['saml'],
+ },
+ },
+ getAppConfig: jest.fn().mockResolvedValue({}),
+}));
+jest.mock('@librechat/api', () => ({
+ isEmailDomainAllowed: jest.fn(() => true),
+ getBalanceConfig: jest.fn(() => ({
+ tokenCredits: 1000,
+ startBalance: 1000,
+ })),
+ getAvatarFileStrategy: jest.fn((config, fallbackStrategy) => {
+ const { FileSources } = jest.requireActual('librechat-data-provider');
+ if (config?.fileStrategies) {
+ return config.fileStrategies.avatar ?? config.fileStrategies.default ?? config.fileStrategy;
+ }
+ return config?.fileStrategy ?? fallbackStrategy ?? FileSources.local;
+ }),
+ getAvatarSaveParams: jest.fn((strategy, params) => {
+ const { FileSources } = jest.requireActual('librechat-data-provider');
+ return strategy === FileSources.s3 || strategy === FileSources.cloudfront
+ ? { ...params, basePath: 'avatars' }
+ : params;
+ }),
+ resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
+}));
+jest.mock('~/server/services/Config/EndpointService', () => ({
+ config: {},
+}));
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn(() => ({
+ saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
+ })),
+}));
+jest.mock('~/server/services/Files/images/avatar', () => ({
+ resizeAvatar: jest.fn().mockResolvedValue(Buffer.from('safe avatar')),
+}));
+jest.mock('~/config/paths', () => ({
+ root: '/fake/root/path',
+}));
+
+const fs = require('fs');
+const path = require('path');
+const fetch = require('node-fetch');
+const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
+const { FileSources } = require('librechat-data-provider');
+const { findUser } = require('~/models');
+const { resolveAppConfigForUser } = require('@librechat/api');
+const { resizeAvatar } = require('~/server/services/Files/images/avatar');
+const { getAppConfig } = require('~/server/services/Config');
+const { setupSaml, getCertificateContent } = require('./samlStrategy');
+
+// Configure fs mock
+jest.mocked(fs).existsSync = jest.fn();
+jest.mocked(fs).statSync = jest.fn();
+jest.mocked(fs).readFileSync = jest.fn();
+
+// To capture the verify callback from the strategy, we grab it from the mock constructor.
+// setupSaml() registers both 'saml' (regular) and 'samlAdmin' strategies, so we capture
+// only the first callback per setupSaml() call (the regular one).
+let verifyCallback;
+SamlStrategy.mockImplementation((options, verify) => {
+ if (!verifyCallback) {
+ verifyCallback = verify;
+ }
+ return { name: 'saml', options, verify };
+});
+
+describe('getCertificateContent', () => {
+ const certWithHeader = `-----BEGIN CERTIFICATE-----
+MIIDazCCAlOgAwIBAgIUKhXaFJGJJPx466rlwYORIsqCq7MwDQYJKoZIhvcNAQEL
+BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
+GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTAzMDQwODUxNTJaFw0yNjAz
+MDQwODUxNTJaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
+HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQCWP09NZg0xaRiLpNygCVgV3M+4RFW2S0c5X/fg/uFT
+O5MfaVYzG5GxzhXzWRB8RtNPsxX/nlbPsoUroeHbz+SABkOsNEv6JuKRH4VXRH34
+VzjazVkPAwj+N4WqsC/Wo4EGGpKIGeGi8Zed4yvMqoTyE3mrS19fY0nMHT62wUwS
+GMm2pAQdAQePZ9WY7A5XOA1IoxW2Zh2Oxaf1p59epBkZDhoxSMu8GoSkvK27Km4A
+4UXftzdg/wHNPrNirmcYouioHdmrOtYxPjrhUBQ74AmE1/QK45B6wEgirKH1A1AW
+6C+ApLwpBMvy9+8Gbyvc8G18W3CjdEVKmAeWb9JUedSXAgMBAAGjUzBRMB0GA1Ud
+DgQWBBRxpaqBx8VDLLc8IkHATujj8IOs6jAfBgNVHSMEGDAWgBRxpaqBx8VDLLc8
+IkHATujj8IOs6jAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBc
+Puk6i+yowwGccB3LhfxZ+Fz6s6/Lfx6bP/Hy4NYOxmx2/awGBgyfp1tmotjaS9Cf
+FWd67LuEru4TYtz12RNMDBF5ypcEfibvb3I8O6igOSQX/Jl5D2pMChesZxhmCift
+Qp09T41MA8PmHf1G9oMG0A3ZnjKDG5ebaJNRFImJhMHsgh/TP7V3uZy7YHTgopKX
+Hv63V3Uo3Oihav29Q7urwmf7Ly7X7J2WE86/w3vRHi5dhaWWqEqxmnAXl+H+sG4V
+meeVRI332bg1Nuy8KnnX8v3ZeJzMBkAhzvSr6Ri96R0/Un/oEFwVC5jDTq8sXVn6
+u7wlOSk+oFzDIO/UILIA
+-----END CERTIFICATE-----`;
+
+ const certWithoutHeader = certWithHeader
+ .replace(/-----BEGIN CERTIFICATE-----/g, '')
+ .replace(/-----END CERTIFICATE-----/g, '')
+ .replace(/\s+/g, '');
+
+ it('should throw an error if SAML_CERT is not set', () => {
+ process.env.SAML_CERT;
+ expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
+ 'Invalid input: SAML_CERT must be a string.',
+ );
+ });
+
+ it('should throw an error if SAML_CERT is empty', () => {
+ process.env.SAML_CERT = '';
+ expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
+ 'Invalid cert: SAML_CERT must be a valid file path or certificate string.',
+ );
+ });
+
+ it('should load cert from an environment variable if it is a single-line string(with header)', () => {
+ process.env.SAML_CERT = certWithHeader;
+
+ const actual = getCertificateContent(process.env.SAML_CERT);
+ expect(actual).toBe(certWithHeader);
+ });
+
+ it('should load cert from an environment variable if it is a single-line string(with no header)', () => {
+ process.env.SAML_CERT = certWithoutHeader;
+
+ const actual = getCertificateContent(process.env.SAML_CERT);
+ expect(actual).toBe(certWithoutHeader);
+ });
+
+ it('should throw an error if SAML_CERT is a single-line string (with header, no newline characters)', () => {
+ process.env.SAML_CERT = certWithHeader.replace(/\n/g, '');
+ expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
+ 'Invalid cert: SAML_CERT must be a valid file path or certificate string.',
+ );
+ });
+
+ it('should load cert from a relative file path if SAML_CERT is valid', () => {
+ process.env.SAML_CERT = 'test.pem';
+ const resolvedPath = '/absolute/path/to/test.pem';
+
+ path.isAbsolute.mockReturnValue(false);
+ path.join.mockReturnValue(resolvedPath);
+ path.normalize.mockReturnValue(resolvedPath);
+
+ fs.existsSync.mockReturnValue(true);
+ fs.statSync.mockReturnValue({ isFile: () => true });
+ fs.readFileSync.mockReturnValue(certWithHeader);
+
+ const actual = getCertificateContent(process.env.SAML_CERT);
+ expect(actual).toBe(certWithHeader);
+ });
+
+ it('should load cert from an absolute file path if SAML_CERT is valid', () => {
+ process.env.SAML_CERT = '/absolute/path/to/test.pem';
+
+ path.isAbsolute.mockReturnValue(true);
+ path.normalize.mockReturnValue(process.env.SAML_CERT);
+
+ fs.existsSync.mockReturnValue(true);
+ fs.statSync.mockReturnValue({ isFile: () => true });
+ fs.readFileSync.mockReturnValue(certWithHeader);
+
+ const actual = getCertificateContent(process.env.SAML_CERT);
+ expect(actual).toBe(certWithHeader);
+ });
+
+ it('should throw an error if the file does not exist', () => {
+ process.env.SAML_CERT = 'missing.pem';
+ const resolvedPath = '/absolute/path/to/missing.pem';
+
+ path.isAbsolute.mockReturnValue(false);
+ path.join.mockReturnValue(resolvedPath);
+ path.normalize.mockReturnValue(resolvedPath);
+
+ fs.existsSync.mockReturnValue(false);
+
+ expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
+ 'Invalid cert: SAML_CERT must be a valid file path or certificate string.',
+ );
+ });
+
+ it('should throw an error if the file is not readable', () => {
+ process.env.SAML_CERT = 'unreadable.pem';
+ const resolvedPath = '/absolute/path/to/unreadable.pem';
+
+ path.isAbsolute.mockReturnValue(false);
+ path.join.mockReturnValue(resolvedPath);
+ path.normalize.mockReturnValue(resolvedPath);
+
+ fs.existsSync.mockReturnValue(true);
+ fs.statSync.mockReturnValue({ isFile: () => true });
+ fs.readFileSync.mockImplementation(() => {
+ throw new Error('Permission denied');
+ });
+
+ expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
+ 'Error reading certificate file: Permission denied',
+ );
+ });
+});
+
+describe('setupSaml', () => {
+ // Helper to wrap the verify callback in a promise
+ const validate = (profile) =>
+ new Promise((resolve, reject) => {
+ verifyCallback(profile, (err, user, details) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve({ user, details });
+ }
+ });
+ });
+
+ const baseProfile = {
+ nameID: 'saml-1234',
+ email: 'test@example.com',
+ given_name: 'First',
+ family_name: 'Last',
+ name: 'My Full Name',
+ username: 'flast',
+ picture: 'https://example.com/avatar.png',
+ custom_name: 'custom',
+ };
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ // Reset so the mock captures the regular (non-admin) callback on next setupSaml() call
+ verifyCallback = null;
+
+ // Configure mocks
+ const { findUser, createUser, updateUser } = require('~/models');
+ findUser.mockResolvedValue(null);
+ createUser.mockImplementation(async (userData) => ({
+ _id: 'mock-user-id',
+ ...userData,
+ }));
+ updateUser.mockImplementation(async (id, userData) => ({
+ _id: id,
+ ...userData,
+ }));
+
+ const cert = `
+-----BEGIN CERTIFICATE-----
+MIIDazCCAlOgAwIBAgIUKhXaFJGJJPx466rlwYORIsqCq7MwDQYJKoZIhvcNAQEL
+BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
+GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTAzMDQwODUxNTJaFw0yNjAz
+MDQwODUxNTJaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
+HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQCWP09NZg0xaRiLpNygCVgV3M+4RFW2S0c5X/fg/uFT
+O5MfaVYzG5GxzhXzWRB8RtNPsxX/nlbPsoUroeHbz+SABkOsNEv6JuKRH4VXRH34
+VzjazVkPAwj+N4WqsC/Wo4EGGpKIGeGi8Zed4yvMqoTyE3mrS19fY0nMHT62wUwS
+GMm2pAQdAQePZ9WY7A5XOA1IoxW2Zh2Oxaf1p59epBkZDhoxSMu8GoSkvK27Km4A
+4UXftzdg/wHNPrNirmcYouioHdmrOtYxPjrhUBQ74AmE1/QK45B6wEgirKH1A1AW
+6C+ApLwpBMvy9+8Gbyvc8G18W3CjdEVKmAeWb9JUedSXAgMBAAGjUzBRMB0GA1Ud
+DgQWBBRxpaqBx8VDLLc8IkHATujj8IOs6jAfBgNVHSMEGDAWgBRxpaqBx8VDLLc8
+IkHATujj8IOs6jAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBc
+Puk6i+yowwGccB3LhfxZ+Fz6s6/Lfx6bP/Hy4NYOxmx2/awGBgyfp1tmotjaS9Cf
+FWd67LuEru4TYtz12RNMDBF5ypcEfibvb3I8O6igOSQX/Jl5D2pMChesZxhmCift
+Qp09T41MA8PmHf1G9oMG0A3ZnjKDG5ebaJNRFImJhMHsgh/TP7V3uZy7YHTgopKX
+Hv63V3Uo3Oihav29Q7urwmf7Ly7X7J2WE86/w3vRHi5dhaWWqEqxmnAXl+H+sG4V
+meeVRI332bg1Nuy8KnnX8v3ZeJzMBkAhzvSr6Ri96R0/Un/oEFwVC5jDTq8sXVn6
+u7wlOSk+oFzDIO/UILIA
+-----END CERTIFICATE-----
+ `;
+
+ // Reset environment variables
+ process.env.SAML_ENTRY_POINT = 'https://example.com/saml';
+ process.env.SAML_ISSUER = 'saml-issuer';
+ process.env.SAML_CERT = cert;
+ process.env.SAML_CALLBACK_URL = '/oauth/saml/callback';
+ delete process.env.SAML_EMAIL_CLAIM;
+ delete process.env.SAML_USERNAME_CLAIM;
+ delete process.env.SAML_GIVEN_NAME_CLAIM;
+ delete process.env.SAML_FAMILY_NAME_CLAIM;
+ delete process.env.SAML_PICTURE_CLAIM;
+ delete process.env.SAML_NAME_CLAIM;
+
+ resizeAvatar.mockResolvedValue(Buffer.from('safe avatar'));
+
+ await setupSaml();
+ });
+
+ it('should create a new user with correct username when username claim exists', async () => {
+ const profile = { ...baseProfile };
+ const { user } = await validate(profile);
+
+ expect(user.username).toBe(profile.username);
+ expect(user.provider).toBe('saml');
+ expect(user.samlId).toBe(profile.nameID);
+ expect(user.email).toBe(profile.email);
+ expect(user.name).toBe(`${profile.given_name} ${profile.family_name}`);
+ });
+
+ it('should use given_name as username when username claim is missing', async () => {
+ const profile = { ...baseProfile };
+ delete profile.username;
+ const expectUsername = profile.given_name;
+
+ const { user } = await validate(profile);
+
+ expect(user.username).toBe(expectUsername);
+ expect(user.provider).toBe('saml');
+ });
+
+ it('should use email as username when username and given_name are missing', async () => {
+ const profile = { ...baseProfile };
+ delete profile.username;
+ delete profile.given_name;
+ const expectUsername = profile.email;
+
+ const { user } = await validate(profile);
+
+ expect(user.username).toBe(expectUsername);
+ expect(user.provider).toBe('saml');
+ });
+
+ it('should override username with SAML_USERNAME_CLAIM when set', async () => {
+ process.env.SAML_USERNAME_CLAIM = 'nameID';
+ const profile = { ...baseProfile };
+
+ const { user } = await validate(profile);
+
+ expect(user.username).toBe(profile.nameID);
+ expect(user.provider).toBe('saml');
+ });
+
+ it('should set the full name correctly when given_name and family_name exist', async () => {
+ const profile = { ...baseProfile };
+ const expectedFullName = `${profile.given_name} ${profile.family_name}`;
+
+ const { user } = await validate(profile);
+
+ expect(user.name).toBe(expectedFullName);
+ });
+
+ it('should set the full name correctly when given_name exist', async () => {
+ const profile = { ...baseProfile };
+ delete profile.family_name;
+ const expectedFullName = profile.given_name;
+
+ const { user } = await validate(profile);
+
+ expect(user.name).toBe(expectedFullName);
+ });
+
+ it('should set the full name correctly when family_name exist', async () => {
+ const profile = { ...baseProfile };
+ delete profile.given_name;
+ const expectedFullName = profile.family_name;
+
+ const { user } = await validate(profile);
+
+ expect(user.name).toBe(expectedFullName);
+ });
+
+ it('should set the full name correctly when username exist', async () => {
+ const profile = { ...baseProfile };
+ delete profile.family_name;
+ delete profile.given_name;
+ const expectedFullName = profile.username;
+
+ const { user } = await validate(profile);
+
+ expect(user.name).toBe(expectedFullName);
+ });
+
+ it('should set the full name correctly when email only exist', async () => {
+ const profile = { ...baseProfile };
+ delete profile.family_name;
+ delete profile.given_name;
+ delete profile.username;
+ const expectedFullName = profile.email;
+
+ const { user } = await validate(profile);
+
+ expect(user.name).toBe(expectedFullName);
+ });
+
+ it('should set the full name correctly with SAML_NAME_CLAIM when set', async () => {
+ process.env.SAML_NAME_CLAIM = 'custom_name';
+ const profile = { ...baseProfile };
+ const expectedFullName = profile.custom_name;
+
+ const { user } = await validate(profile);
+
+ expect(user.name).toBe(expectedFullName);
+ });
+
+ it('should update an existing user on login', async () => {
+ // Set up findUser to return an existing user with saml provider
+ const { findUser } = require('~/models');
+ const existingUser = {
+ _id: 'existing-user-id',
+ provider: 'saml',
+ email: baseProfile.email,
+ samlId: '',
+ username: 'oldusername',
+ name: 'Old Name',
+ };
+ findUser.mockResolvedValue(existingUser);
+
+ const profile = { ...baseProfile };
+ const { user } = await validate(profile);
+
+ expect(user.provider).toBe('saml');
+ expect(user.samlId).toBe(baseProfile.nameID);
+ expect(user.username).toBe(baseProfile.username);
+ expect(user.name).toBe(`${baseProfile.given_name} ${baseProfile.family_name}`);
+ expect(user.email).toBe(baseProfile.email);
+ });
+
+ it('should block login when email exists with different provider', async () => {
+ // Set up findUser to return a user with different provider
+ const { findUser } = require('~/models');
+ const existingUser = {
+ _id: 'existing-user-id',
+ provider: 'google',
+ email: baseProfile.email,
+ googleId: 'some-google-id',
+ username: 'existinguser',
+ name: 'Existing User',
+ };
+ findUser.mockResolvedValue(existingUser);
+
+ const profile = { ...baseProfile };
+ const result = await validate(profile);
+
+ expect(result.user).toBe(false);
+ expect(result.details.message).toBe(require('librechat-data-provider').ErrorTypes.AUTH_FAILED);
+ });
+
+ it('should process and save the avatar through the shared avatar path if picture is provided', async () => {
+ const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+ const profile = { ...baseProfile };
+
+ const { user } = await validate(profile);
+ const strategyResult =
+ getStrategyFunctions.mock.results[getStrategyFunctions.mock.results.length - 1];
+ const { saveBuffer } = strategyResult.value;
+ const [saveParams] = saveBuffer.mock.calls[0];
+
+ expect(resizeAvatar).toHaveBeenCalledWith({
+ userId: 'mock-user-id',
+ input: 'https://example.com/avatar.png',
+ });
+ expect(fetch).not.toHaveBeenCalled();
+ expect(saveParams).toEqual(
+ expect.objectContaining({
+ fileName: 'hashed-token.png',
+ userId: 'mock-user-id',
+ buffer: expect.any(Buffer),
+ }),
+ );
+ expect(saveParams).not.toHaveProperty('basePath');
+ expect(user.avatar).toBe('/fake/path/to/avatar.png');
+ });
+
+ it('continues login when shared avatar processing rejects the picture URL', async () => {
+ const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+ const profile = { ...baseProfile };
+ resizeAvatar.mockRejectedValueOnce(new Error('avatar processing failed'));
+
+ const { user } = await validate(profile);
+
+ expect(user).toBeTruthy();
+ expect(user.avatar).toBeUndefined();
+ expect(getStrategyFunctions).not.toHaveBeenCalled();
+ });
+
+ it('uses the configured SAML picture claim for shared avatar processing', async () => {
+ process.env.SAML_PICTURE_CLAIM = 'avatar_url';
+ verifyCallback = null;
+ await setupSaml();
+
+ const profile = {
+ ...baseProfile,
+ picture: 'https://example.com/ignored.png',
+ avatar_url: 'https://idp.example.com/custom-avatar.png',
+ };
+
+ await validate(profile);
+
+ expect(resizeAvatar).toHaveBeenCalledWith({
+ userId: 'mock-user-id',
+ input: 'https://idp.example.com/custom-avatar.png',
+ });
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it('should save CloudFront SAML avatars under the shared avatar prefix', async () => {
+ const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+ getAppConfig.mockResolvedValueOnce({ fileStrategies: { avatar: FileSources.cloudfront } });
+ const profile = { ...baseProfile };
+
+ const { user } = await validate(profile);
+ const strategyResult =
+ getStrategyFunctions.mock.results[getStrategyFunctions.mock.results.length - 1];
+ const { saveBuffer } = strategyResult.value;
+ const [saveParams] = saveBuffer.mock.calls[0];
+
+ expect(getStrategyFunctions).toHaveBeenLastCalledWith(FileSources.cloudfront);
+ expect(resizeAvatar).toHaveBeenCalledWith({
+ userId: 'mock-user-id',
+ input: 'https://example.com/avatar.png',
+ });
+ expect(fetch).not.toHaveBeenCalled();
+ expect(saveParams).toEqual(
+ expect.objectContaining({
+ basePath: 'avatars',
+ fileName: 'hashed-token.png',
+ userId: 'mock-user-id',
+ }),
+ );
+ expect(user.avatar).toBe('/fake/path/to/avatar.png');
+ });
+
+ it('should not attempt to download avatar if picture is not provided', async () => {
+ const profile = { ...baseProfile };
+ delete profile.picture;
+
+ await validate(profile);
+
+ expect(fetch).not.toHaveBeenCalled();
+ expect(resizeAvatar).not.toHaveBeenCalled();
+ });
+
+ it('should pass the found user to resolveAppConfigForUser', async () => {
+ const existingUser = {
+ _id: 'tenant-user-id',
+ provider: 'saml',
+ samlId: 'saml-1234',
+ email: 'test@example.com',
+ tenantId: 'tenant-c',
+ role: 'USER',
+ };
+ findUser.mockResolvedValue(existingUser);
+
+ const profile = { ...baseProfile };
+ await validate(profile);
+
+ expect(resolveAppConfigForUser).toHaveBeenCalledWith(getAppConfig, existingUser);
+ });
+
+ it('should use baseConfig for new SAML user without calling resolveAppConfigForUser', async () => {
+ const profile = { ...baseProfile };
+ await validate(profile);
+
+ expect(resolveAppConfigForUser).not.toHaveBeenCalled();
+ expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true });
+ });
+
+ it('should block login when tenant config restricts the domain', async () => {
+ const { isEmailDomainAllowed } = require('@librechat/api');
+ const existingUser = {
+ _id: 'tenant-blocked',
+ provider: 'saml',
+ samlId: 'saml-1234',
+ email: 'test@example.com',
+ tenantId: 'tenant-restrict',
+ role: 'USER',
+ };
+ findUser.mockResolvedValue(existingUser);
+ resolveAppConfigForUser.mockResolvedValue({
+ registration: { allowedDomains: ['other.com'] },
+ });
+ isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false);
+
+ const profile = { ...baseProfile };
+ const { user } = await validate(profile);
+ expect(user).toBe(false);
+ });
+});
diff --git a/api/strategies/socialLogin.js b/api/strategies/socialLogin.js
new file mode 100644
index 0000000..580e4f3
--- /dev/null
+++ b/api/strategies/socialLogin.js
@@ -0,0 +1,107 @@
+const { logger } = require('@librechat/data-schemas');
+const { ErrorTypes } = require('librechat-data-provider');
+const { isEnabled, isEmailDomainAllowed, resolveAppConfigForUser } = require('@librechat/api');
+const { createSocialUser, handleExistingUser } = require('./process');
+const { getAppConfig } = require('~/server/services/Config');
+const { findUser } = require('~/models');
+
+const socialLogin =
+ (provider, getProfileDetails, options = {}) =>
+ async (accessToken, refreshToken, idToken, profile, cb) => {
+ try {
+ const { email, id, avatarUrl, username, name, emailVerified } = getProfileDetails({
+ idToken,
+ profile,
+ });
+
+ const baseConfig = await getAppConfig({ baseOnly: true });
+ if (!isEmailDomainAllowed(email, baseConfig?.registration?.allowedDomains)) {
+ logger.error(
+ `[${provider}Login] Authentication blocked - email domain not allowed [Email: ${email}]`,
+ );
+ const error = new Error(ErrorTypes.AUTH_FAILED);
+ error.code = ErrorTypes.AUTH_FAILED;
+ error.message = 'Email domain not allowed';
+ return cb(error);
+ }
+
+ const providerKey = `${provider}Id`;
+ let existingUser = null;
+
+ /** First try to find user by provider ID (e.g., googleId, facebookId) */
+ if (id && typeof id === 'string') {
+ existingUser = await findUser({ [providerKey]: id });
+ }
+
+ /** If not found by provider ID, try finding by email */
+ if (!existingUser) {
+ existingUser = await findUser({ email: email?.trim() });
+ if (existingUser) {
+ logger.warn(`[${provider}Login] User found by email: ${email} but not by ${providerKey}`);
+ }
+ }
+
+ const appConfig = existingUser?.tenantId
+ ? await resolveAppConfigForUser(getAppConfig, existingUser)
+ : baseConfig;
+
+ if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
+ logger.error(
+ `[${provider}Login] Authentication blocked - email domain not allowed [Email: ${email}]`,
+ );
+ const error = new Error(ErrorTypes.AUTH_FAILED);
+ error.code = ErrorTypes.AUTH_FAILED;
+ error.message = 'Email domain not allowed';
+ return cb(error);
+ }
+
+ if (existingUser?.provider === provider) {
+ await handleExistingUser(existingUser, avatarUrl, appConfig, email);
+ return cb(null, existingUser);
+ } else if (existingUser) {
+ logger.info(
+ `[${provider}Login] User ${email} already exists with provider ${existingUser.provider}`,
+ );
+ const error = new Error(ErrorTypes.AUTH_FAILED);
+ error.code = ErrorTypes.AUTH_FAILED;
+ error.provider = existingUser.provider;
+ return cb(error);
+ }
+
+ if (options.existingUsersOnly) {
+ logger.error(
+ `[${provider}Login] Admin auth blocked - user does not exist [Email: ${email}]`,
+ );
+ return cb(null, false, { message: 'User does not exist' });
+ }
+
+ const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION);
+ if (!ALLOW_SOCIAL_REGISTRATION) {
+ logger.error(
+ `[${provider}Login] Registration blocked - social registration is disabled [Email: ${email}]`,
+ );
+ const error = new Error(ErrorTypes.AUTH_FAILED);
+ error.code = ErrorTypes.AUTH_FAILED;
+ error.message = 'Social registration is disabled';
+ return cb(error);
+ }
+
+ const newUser = await createSocialUser({
+ email,
+ avatarUrl,
+ provider,
+ providerKey: `${provider}Id`,
+ providerId: id,
+ username,
+ name,
+ emailVerified,
+ appConfig,
+ });
+ return cb(null, newUser);
+ } catch (err) {
+ logger.error(`[${provider}Login]`, err);
+ return cb(err);
+ }
+ };
+
+module.exports = socialLogin;
diff --git a/api/strategies/socialLogin.test.js b/api/strategies/socialLogin.test.js
new file mode 100644
index 0000000..4fde397
--- /dev/null
+++ b/api/strategies/socialLogin.test.js
@@ -0,0 +1,362 @@
+const { logger } = require('@librechat/data-schemas');
+const { ErrorTypes } = require('librechat-data-provider');
+const { createSocialUser, handleExistingUser } = require('./process');
+const socialLogin = require('./socialLogin');
+const { findUser } = require('~/models');
+const { resolveAppConfigForUser } = require('@librechat/api');
+const { getAppConfig } = require('~/server/services/Config');
+
+jest.mock('@librechat/data-schemas', () => {
+ const actualModule = jest.requireActual('@librechat/data-schemas');
+ return {
+ ...actualModule,
+ logger: {
+ error: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ },
+ };
+});
+
+jest.mock('./process', () => ({
+ createSocialUser: jest.fn(),
+ handleExistingUser: jest.fn(),
+}));
+
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ isEnabled: jest.fn().mockReturnValue(true),
+ isEmailDomainAllowed: jest.fn().mockReturnValue(true),
+ resolveAppConfigForUser: jest.fn().mockResolvedValue({
+ fileStrategy: 'local',
+ balance: { enabled: false },
+ }),
+}));
+
+jest.mock('~/models', () => ({
+ findUser: jest.fn(),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn().mockResolvedValue({
+ fileStrategy: 'local',
+ balance: { enabled: false },
+ }),
+}));
+
+describe('socialLogin', () => {
+ const mockGetProfileDetails = ({ profile }) => ({
+ email: profile.emails[0].value,
+ id: profile.id,
+ avatarUrl: profile.photos?.[0]?.value || null,
+ username: profile.name?.givenName || 'user',
+ name: `${profile.name?.givenName || ''} ${profile.name?.familyName || ''}`.trim(),
+ emailVerified: profile.emails[0].verified || false,
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('Finding users by provider ID', () => {
+ it('should find user by provider ID (googleId) when email has changed', async () => {
+ const provider = 'google';
+ const googleId = 'google-user-123';
+ const oldEmail = 'old@example.com';
+ const newEmail = 'new@example.com';
+
+ const existingUser = {
+ _id: 'user123',
+ email: oldEmail,
+ provider: 'google',
+ googleId: googleId,
+ };
+
+ findUser.mockResolvedValueOnce(existingUser).mockResolvedValueOnce(null);
+
+ const mockProfile = {
+ id: googleId,
+ emails: [{ value: newEmail, verified: true }],
+ photos: [{ value: 'https://example.com/avatar.png' }],
+ name: { givenName: 'John', familyName: 'Doe' },
+ };
+
+ const loginFn = socialLogin(provider, mockGetProfileDetails);
+ const callback = jest.fn();
+
+ await loginFn(null, null, null, mockProfile, callback);
+
+ expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
+ expect(findUser).toHaveBeenCalledTimes(1);
+
+ expect(handleExistingUser).toHaveBeenCalledWith(
+ existingUser,
+ 'https://example.com/avatar.png',
+ expect.any(Object),
+ newEmail,
+ );
+
+ expect(callback).toHaveBeenCalledWith(null, existingUser);
+ });
+
+ it('should find user by provider ID (facebookId) when using Facebook', async () => {
+ const provider = 'facebook';
+ const facebookId = 'fb-user-456';
+ const email = 'user@example.com';
+
+ const existingUser = {
+ _id: 'user456',
+ email: email,
+ provider: 'facebook',
+ facebookId: facebookId,
+ };
+
+ findUser.mockResolvedValue(existingUser);
+
+ const mockProfile = {
+ id: facebookId,
+ emails: [{ value: email, verified: true }],
+ photos: [{ value: 'https://example.com/fb-avatar.png' }],
+ name: { givenName: 'Jane', familyName: 'Smith' },
+ };
+
+ const loginFn = socialLogin(provider, mockGetProfileDetails);
+ const callback = jest.fn();
+
+ await loginFn(null, null, null, mockProfile, callback);
+
+ expect(findUser).toHaveBeenCalledWith({ facebookId: facebookId });
+ expect(findUser.mock.calls[0]).toEqual([{ facebookId: facebookId }]);
+
+ expect(handleExistingUser).toHaveBeenCalledWith(
+ existingUser,
+ 'https://example.com/fb-avatar.png',
+ expect.any(Object),
+ email,
+ );
+
+ expect(callback).toHaveBeenCalledWith(null, existingUser);
+ });
+
+ it('should fallback to finding user by email if not found by provider ID', async () => {
+ const provider = 'google';
+ const googleId = 'google-user-789';
+ const email = 'user@example.com';
+
+ const existingUser = {
+ _id: 'user789',
+ email: email,
+ provider: 'google',
+ googleId: 'old-google-id',
+ };
+
+ findUser.mockResolvedValueOnce(null).mockResolvedValueOnce(existingUser);
+
+ const mockProfile = {
+ id: googleId,
+ emails: [{ value: email, verified: true }],
+ photos: [{ value: 'https://example.com/avatar.png' }],
+ name: { givenName: 'Bob', familyName: 'Johnson' },
+ };
+
+ const loginFn = socialLogin(provider, mockGetProfileDetails);
+ const callback = jest.fn();
+
+ await loginFn(null, null, null, mockProfile, callback);
+
+ expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
+ expect(findUser).toHaveBeenNthCalledWith(2, { email: email });
+ expect(findUser).toHaveBeenCalledTimes(2);
+
+ expect(logger.warn).toHaveBeenCalledWith(
+ `[${provider}Login] User found by email: ${email} but not by ${provider}Id`,
+ );
+
+ expect(handleExistingUser).toHaveBeenCalled();
+ expect(callback).toHaveBeenCalledWith(null, existingUser);
+ });
+
+ it('should create new user if not found by provider ID or email', async () => {
+ const provider = 'google';
+ const googleId = 'google-new-user';
+ const email = 'newuser@example.com';
+
+ const newUser = {
+ _id: 'newuser123',
+ email: email,
+ provider: 'google',
+ googleId: googleId,
+ };
+
+ findUser.mockResolvedValue(null);
+ createSocialUser.mockResolvedValue(newUser);
+
+ const mockProfile = {
+ id: googleId,
+ emails: [{ value: email, verified: true }],
+ photos: [{ value: 'https://example.com/avatar.png' }],
+ name: { givenName: 'New', familyName: 'User' },
+ };
+
+ const loginFn = socialLogin(provider, mockGetProfileDetails);
+ const callback = jest.fn();
+
+ await loginFn(null, null, null, mockProfile, callback);
+
+ expect(findUser).toHaveBeenCalledTimes(2);
+
+ expect(createSocialUser).toHaveBeenCalledWith({
+ email: email,
+ avatarUrl: 'https://example.com/avatar.png',
+ provider: provider,
+ providerKey: 'googleId',
+ providerId: googleId,
+ username: 'New',
+ name: 'New User',
+ emailVerified: true,
+ appConfig: expect.any(Object),
+ });
+
+ expect(callback).toHaveBeenCalledWith(null, newUser);
+ });
+ });
+
+ describe('Error handling', () => {
+ it('should return error if user exists with different provider', async () => {
+ const provider = 'google';
+ const googleId = 'google-user-123';
+ const email = 'user@example.com';
+
+ const existingUser = {
+ _id: 'user123',
+ email: email,
+ provider: 'local',
+ };
+
+ findUser.mockResolvedValueOnce(null).mockResolvedValueOnce(existingUser);
+
+ const mockProfile = {
+ id: googleId,
+ emails: [{ value: email, verified: true }],
+ photos: [{ value: 'https://example.com/avatar.png' }],
+ name: { givenName: 'John', familyName: 'Doe' },
+ };
+
+ const loginFn = socialLogin(provider, mockGetProfileDetails);
+ const callback = jest.fn();
+
+ await loginFn(null, null, null, mockProfile, callback);
+
+ expect(callback).toHaveBeenCalledWith(
+ expect.objectContaining({
+ code: ErrorTypes.AUTH_FAILED,
+ provider: 'local',
+ }),
+ );
+
+ expect(logger.info).toHaveBeenCalledWith(
+ `[${provider}Login] User ${email} already exists with provider local`,
+ );
+ });
+ });
+
+ describe('Tenant-scoped config', () => {
+ it('should call resolveAppConfigForUser for tenant user', async () => {
+ const provider = 'google';
+ const googleId = 'google-tenant-user';
+ const email = 'tenant@example.com';
+
+ const existingUser = {
+ _id: 'userTenant',
+ email,
+ provider: 'google',
+ googleId,
+ tenantId: 'tenant-b',
+ role: 'USER',
+ };
+
+ findUser.mockResolvedValue(existingUser);
+
+ const mockProfile = {
+ id: googleId,
+ emails: [{ value: email, verified: true }],
+ photos: [{ value: 'https://example.com/avatar.png' }],
+ name: { givenName: 'Tenant', familyName: 'User' },
+ };
+
+ const loginFn = socialLogin(provider, mockGetProfileDetails);
+ const callback = jest.fn();
+
+ await loginFn(null, null, null, mockProfile, callback);
+
+ expect(resolveAppConfigForUser).toHaveBeenCalledWith(getAppConfig, existingUser);
+ });
+
+ it('should use baseConfig for non-tenant user without calling resolveAppConfigForUser', async () => {
+ const provider = 'google';
+ const googleId = 'google-new-tenant';
+ const email = 'new@example.com';
+
+ findUser.mockResolvedValue(null);
+ createSocialUser.mockResolvedValue({
+ _id: 'newUser',
+ email,
+ provider: 'google',
+ googleId,
+ });
+
+ const mockProfile = {
+ id: googleId,
+ emails: [{ value: email, verified: true }],
+ photos: [{ value: 'https://example.com/avatar.png' }],
+ name: { givenName: 'New', familyName: 'User' },
+ };
+
+ const loginFn = socialLogin(provider, mockGetProfileDetails);
+ const callback = jest.fn();
+
+ await loginFn(null, null, null, mockProfile, callback);
+
+ expect(resolveAppConfigForUser).not.toHaveBeenCalled();
+ expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true });
+ });
+
+ it('should block login when tenant config restricts the domain', async () => {
+ const { isEmailDomainAllowed } = require('@librechat/api');
+ const provider = 'google';
+ const googleId = 'google-tenant-blocked';
+ const email = 'blocked@example.com';
+
+ const existingUser = {
+ _id: 'userBlocked',
+ email,
+ provider: 'google',
+ googleId,
+ tenantId: 'tenant-restrict',
+ role: 'USER',
+ };
+
+ findUser.mockResolvedValue(existingUser);
+ resolveAppConfigForUser.mockResolvedValue({
+ registration: { allowedDomains: ['other.com'] },
+ });
+ isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false);
+
+ const mockProfile = {
+ id: googleId,
+ emails: [{ value: email, verified: true }],
+ photos: [{ value: 'https://example.com/avatar.png' }],
+ name: { givenName: 'Blocked', familyName: 'User' },
+ };
+
+ const loginFn = socialLogin(provider, mockGetProfileDetails);
+ const callback = jest.fn();
+
+ await loginFn(null, null, null, mockProfile, callback);
+
+ expect(callback).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'Email domain not allowed' }),
+ );
+ });
+ });
+});
diff --git a/api/strategies/validators.js b/api/strategies/validators.js
new file mode 100644
index 0000000..87fa758
--- /dev/null
+++ b/api/strategies/validators.js
@@ -0,0 +1,80 @@
+const { z } = require('zod');
+
+const MIN_PASSWORD_LENGTH = parseInt(process.env.MIN_PASSWORD_LENGTH, 10) || 8;
+
+const allowedCharactersRegex = new RegExp(
+ '^[' +
+ 'a-zA-Z0-9_.@#$%&*()' + // Basic Latin characters and symbols
+ '\\p{Script=Latin}' + // Latin script characters
+ '\\p{Script=Common}' + // Characters common across scripts
+ '\\p{Script=Cyrillic}' + // Cyrillic script for Russian, etc.
+ '\\p{Script=Devanagari}' + // Devanagari script for Hindi, etc.
+ '\\p{Script=Han}' + // Han script for Chinese characters, etc.
+ '\\p{Script=Arabic}' + // Arabic script
+ '\\p{Script=Hiragana}' + // Hiragana script for Japanese
+ '\\p{Script=Katakana}' + // Katakana script for Japanese
+ '\\p{Script=Hangul}' + // Hangul script for Korean
+ ']+$', // End of string
+ 'u', // Use Unicode mode
+);
+const injectionPatternsRegex = /('|--|\$ne|\$gt|\$lt|\$or|\{|\}|\*|;|<|>|\/|=)/i;
+
+const usernameSchema = z
+ .string()
+ .min(2)
+ .max(80)
+ .refine((value) => allowedCharactersRegex.test(value), {
+ message: 'Invalid characters in username',
+ })
+ .refine((value) => !injectionPatternsRegex.test(value), {
+ message: 'Potential injection attack detected',
+ });
+
+const loginSchema = z.object({
+ email: z.string().email(),
+ password: z
+ .string()
+ .min(MIN_PASSWORD_LENGTH)
+ .max(128)
+ .refine((value) => value.trim().length > 0, {
+ message: 'Password cannot be only spaces',
+ }),
+});
+
+const registerSchema = z
+ .object({
+ name: z.string().min(3).max(80),
+ username: z
+ .union([z.literal(''), usernameSchema])
+ .transform((value) => (value === '' ? null : value))
+ .optional()
+ .nullable(),
+ email: z.string().email(),
+ password: z
+ .string()
+ .min(MIN_PASSWORD_LENGTH)
+ .max(128)
+ .refine((value) => value.trim().length > 0, {
+ message: 'Password cannot be only spaces',
+ }),
+ confirm_password: z
+ .string()
+ .min(MIN_PASSWORD_LENGTH)
+ .max(128)
+ .refine((value) => value.trim().length > 0, {
+ message: 'Password cannot be only spaces',
+ }),
+ })
+ .superRefine(({ confirm_password, password }, ctx) => {
+ if (confirm_password !== password) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'The passwords did not match',
+ });
+ }
+ });
+
+module.exports = {
+ loginSchema,
+ registerSchema,
+};
diff --git a/api/strategies/validators.spec.js b/api/strategies/validators.spec.js
new file mode 100644
index 0000000..b15cf05
--- /dev/null
+++ b/api/strategies/validators.spec.js
@@ -0,0 +1,516 @@
+// file deepcode ignore NoHardcodedPasswords: No hard-coded passwords in tests
+const { errorsToString } = require('librechat-data-provider');
+const { loginSchema, registerSchema } = require('./validators');
+
+describe('Zod Schemas', () => {
+ describe('loginSchema', () => {
+ it('should validate a correct login object', () => {
+ const result = loginSchema.safeParse({
+ email: 'test@example.com',
+ password: 'password123',
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it('should invalidate an incorrect email', () => {
+ const result = loginSchema.safeParse({
+ email: 'testexample.com',
+ password: 'password123',
+ });
+
+ expect(result.success).toBe(false);
+ });
+
+ it('should invalidate a short password', () => {
+ const result = loginSchema.safeParse({
+ email: 'test@example.com',
+ password: 'pass',
+ });
+
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle email with unusual characters', () => {
+ const emails = ['test+alias@example.com', 'test@subdomain.example.co.uk'];
+ emails.forEach((email) => {
+ const result = loginSchema.safeParse({
+ email,
+ password: 'password123',
+ });
+ expect(result.success).toBe(true);
+ });
+ });
+
+ it('should invalidate email without a domain', () => {
+ const result = loginSchema.safeParse({
+ email: 'test@.com',
+ password: 'password123',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should invalidate password with only spaces', () => {
+ const result = loginSchema.safeParse({
+ email: 'test@example.com',
+ password: ' ',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should invalidate password that is too long', () => {
+ const result = loginSchema.safeParse({
+ email: 'test@example.com',
+ password: 'a'.repeat(129),
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should invalidate empty email or password', () => {
+ const result = loginSchema.safeParse({
+ email: '',
+ password: '',
+ });
+ expect(result.success).toBe(false);
+ });
+ });
+
+ describe('registerSchema', () => {
+ it('should validate a correct register object', () => {
+ const result = registerSchema.safeParse({
+ name: 'John Doe',
+ username: 'john_doe',
+ email: 'john@example.com',
+ password: 'password123',
+ confirm_password: 'password123',
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it('should allow the username to be omitted', () => {
+ const result = registerSchema.safeParse({
+ name: 'John Doe',
+ email: 'john@example.com',
+ password: 'password123',
+ confirm_password: 'password123',
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it('should invalidate a short name', () => {
+ const result = registerSchema.safeParse({
+ name: 'Jo',
+ username: 'john_doe',
+ email: 'john@example.com',
+ password: 'password123',
+ confirm_password: 'password123',
+ });
+
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle empty username by transforming to null', () => {
+ const result = registerSchema.safeParse({
+ name: 'John Doe',
+ username: '',
+ email: 'john@example.com',
+ password: 'password123',
+ confirm_password: 'password123',
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.data.username).toBe(null);
+ });
+
+ it('should handle name with special characters', () => {
+ const names = ['Jöhn Dœ', 'John '];
+ names.forEach((name) => {
+ const result = registerSchema.safeParse({
+ name,
+ username: 'john_doe',
+ email: 'john@example.com',
+ password: 'password123',
+ confirm_password: 'password123',
+ });
+ expect(result.success).toBe(true);
+ });
+ });
+
+ it('should handle username with special characters', () => {
+ const usernames = ['john.doe@', 'john..doe'];
+ usernames.forEach((username) => {
+ const result = registerSchema.safeParse({
+ name: 'John Doe',
+ username,
+ email: 'john@example.com',
+ password: 'password123',
+ confirm_password: 'password123',
+ });
+ expect(result.success).toBe(true);
+ });
+ });
+
+ it('should invalidate mismatched password and confirm_password', () => {
+ const result = registerSchema.safeParse({
+ name: 'John Doe',
+ username: 'john_doe',
+ email: 'john@example.com',
+ password: 'password123',
+ confirm_password: 'password124',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle email without a TLD', () => {
+ const result = registerSchema.safeParse({
+ name: 'John Doe',
+ username: 'john_doe',
+ email: 'john@domain',
+ password: 'password123',
+ confirm_password: 'password123',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle email with multiple @ symbols', () => {
+ const result = registerSchema.safeParse({
+ name: 'John Doe',
+ username: 'john_doe',
+ email: 'john@domain@com',
+ password: 'password123',
+ confirm_password: 'password123',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle name that is too long', () => {
+ const result = registerSchema.safeParse({
+ name: 'a'.repeat(81),
+ username: 'john_doe',
+ email: 'john@example.com',
+ password: 'password123',
+ confirm_password: 'password123',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle username that is too long', () => {
+ const result = registerSchema.safeParse({
+ name: 'John Doe',
+ username: 'a'.repeat(81),
+ email: 'john@example.com',
+ password: 'password123',
+ confirm_password: 'password123',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle password or confirm_password that is too long', () => {
+ const result = registerSchema.safeParse({
+ name: 'John Doe',
+ username: 'john_doe',
+ email: 'john@example.com',
+ password: 'a'.repeat(129),
+ confirm_password: 'a'.repeat(129),
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle password or confirm_password that is just spaces', () => {
+ const result = registerSchema.safeParse({
+ name: 'John Doe',
+ username: 'john_doe',
+ email: 'john@example.com',
+ password: ' ',
+ confirm_password: ' ',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle null values for fields', () => {
+ const result = registerSchema.safeParse({
+ name: null,
+ username: null,
+ email: null,
+ password: null,
+ confirm_password: null,
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle undefined values for fields', () => {
+ const result = registerSchema.safeParse({
+ name: undefined,
+ username: undefined,
+ email: undefined,
+ password: undefined,
+ confirm_password: undefined,
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should handle extra fields not defined in the schema', () => {
+ const result = registerSchema.safeParse({
+ name: 'John Doe',
+ username: 'john_doe',
+ email: 'john@example.com',
+ password: 'password123',
+ confirm_password: 'password123',
+ extraField: "I shouldn't be here",
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('should handle username with special characters from various languages', () => {
+ const usernames = [
+ // General
+ 'éèäöü',
+
+ // German
+ 'Jöhn.Döe@',
+ 'Jöhn_Ü',
+ 'Jöhnß',
+
+ // French
+ 'Jéan-Piérre',
+ 'Élève',
+ 'Fiançée',
+ 'Mère',
+
+ // Spanish
+ 'Niño',
+ 'Señor',
+ 'Muñoz',
+
+ // Portuguese
+ 'João',
+ 'Coração',
+ 'Pão',
+
+ // Italian
+ 'Pietro',
+ 'Bambino',
+ 'Forlì',
+
+ // Romanian
+ 'Mâncare',
+ 'Școală',
+ 'Țară',
+
+ // Catalan
+ 'Niç',
+ 'Màquina',
+ 'Çap',
+
+ // Swedish
+ 'Fjärran',
+ 'Skål',
+ 'Öland',
+
+ // Norwegian
+ 'Blåbær',
+ 'Fjord',
+ 'Årstid',
+
+ // Danish
+ 'Flød',
+ 'Søster',
+ 'Århus',
+
+ // Icelandic
+ 'Þór',
+ 'Ætt',
+ 'Öx',
+
+ // Turkish
+ 'Şehir',
+ 'Çocuk',
+ 'Gözlük',
+
+ // Polish
+ 'Łódź',
+ 'Część',
+ 'Świat',
+
+ // Czech
+ 'Čaj',
+ 'Řeka',
+ 'Život',
+
+ // Slovak
+ 'Kočka',
+ 'Ľudia',
+ 'Žaba',
+
+ // Croatian
+ 'Čovjek',
+ 'Šuma',
+ 'Žaba',
+
+ // Hungarian
+ 'Tűz',
+ 'Ősz',
+ 'Ünnep',
+
+ // Finnish
+ 'Mäki',
+ 'Yö',
+ 'Äiti',
+
+ // Estonian
+ 'Tänav',
+ 'Öö',
+ 'Ülikool',
+
+ // Latvian
+ 'Ēka',
+ 'Ūdens',
+ 'Čempions',
+
+ // Lithuanian
+ 'Ūsas',
+ 'Ąžuolas',
+ 'Čia',
+
+ // Dutch
+ 'Maïs',
+ 'Geërfd',
+ 'Coördinatie',
+ ];
+
+ const failingUsernames = usernames.reduce((acc, username) => {
+ const result = registerSchema.safeParse({
+ name: 'John Doe',
+ username,
+ email: 'john@example.com',
+ password: 'password123',
+ confirm_password: 'password123',
+ });
+
+ if (!result.success) {
+ acc.push({ username, error: result.error });
+ }
+
+ return acc;
+ }, []);
+
+ if (failingUsernames.length > 0) {
+ console.log('Failing Usernames:', failingUsernames);
+ }
+ expect(failingUsernames).toEqual([]);
+ });
+
+ it('should reject invalid usernames', () => {
+ const invalidUsernames = [
+ 'john{doe}', // Contains `{` and `}`
+ 'j', // Only one character
+ 'a'.repeat(81), // More than 80 characters
+ "' OR '1'='1'; --", // SQL Injection
+ '{$ne: null}', // MongoDB Injection
+ '', // Basic XSS
+ '">', // XSS breaking out of an attribute
+ '"> ', // XSS using an image tag
+ ];
+
+ const passingUsernames = [];
+ const failingUsernames = invalidUsernames.reduce((acc, username) => {
+ const result = registerSchema.safeParse({
+ name: 'John Doe',
+ username,
+ email: 'john@example.com',
+ password: 'password123',
+ confirm_password: 'password123',
+ });
+
+ if (!result.success) {
+ acc.push({ username, error: result.error });
+ }
+
+ if (result.success) {
+ passingUsernames.push({ username });
+ }
+
+ return acc;
+ }, []);
+
+ expect(failingUsernames.length).toEqual(invalidUsernames.length); // They should match since all invalidUsernames should fail.
+ });
+ });
+
+ describe('errorsToString', () => {
+ it('should convert errors to string', () => {
+ const { error } = registerSchema.safeParse({
+ name: 'Jo',
+ username: 'john_doe',
+ email: 'john@example.com',
+ password: 'password123',
+ confirm_password: 'password123',
+ });
+
+ const result = errorsToString(error.errors);
+ expect(result).toBe('name: String must contain at least 3 character(s)');
+ });
+ });
+
+ describe('MIN_PASSWORD_LENGTH environment variable', () => {
+ // Note: These tests verify the behavior based on whatever MIN_PASSWORD_LENGTH
+ // was set when the validators module was loaded
+ const minLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10) || 8;
+
+ it('should respect the configured minimum password length for login', () => {
+ // Test password exactly at minimum length
+ const resultValid = loginSchema.safeParse({
+ email: 'test@example.com',
+ password: 'a'.repeat(minLength),
+ });
+ expect(resultValid.success).toBe(true);
+
+ // Test password one character below minimum
+ if (minLength > 1) {
+ const resultInvalid = loginSchema.safeParse({
+ email: 'test@example.com',
+ password: 'a'.repeat(minLength - 1),
+ });
+ expect(resultInvalid.success).toBe(false);
+ }
+ });
+
+ it('should respect the configured minimum password length for registration', () => {
+ // Test password exactly at minimum length
+ const resultValid = registerSchema.safeParse({
+ name: 'John Doe',
+ email: 'john@example.com',
+ password: 'a'.repeat(minLength),
+ confirm_password: 'a'.repeat(minLength),
+ });
+ expect(resultValid.success).toBe(true);
+
+ // Test password one character below minimum
+ if (minLength > 1) {
+ const resultInvalid = registerSchema.safeParse({
+ name: 'John Doe',
+ email: 'john@example.com',
+ password: 'a'.repeat(minLength - 1),
+ confirm_password: 'a'.repeat(minLength - 1),
+ });
+ expect(resultInvalid.success).toBe(false);
+ }
+ });
+
+ it('should handle edge case of very short minimum password length', () => {
+ // This test is meaningful only if MIN_PASSWORD_LENGTH is set to a very low value
+ if (minLength <= 3) {
+ const result = loginSchema.safeParse({
+ email: 'test@example.com',
+ password: 'abc',
+ });
+ expect(result.success).toBe(minLength <= 3);
+ } else {
+ // Skip this test if minimum length is > 3
+ expect(true).toBe(true);
+ }
+ });
+ });
+});
diff --git a/api/test/.env.test.example b/api/test/.env.test.example
new file mode 100644
index 0000000..587a739
--- /dev/null
+++ b/api/test/.env.test.example
@@ -0,0 +1,17 @@
+# Test DB URI. You can use your actual MONGO_URI if you don't mind it potentially including test data.
+MONGO_URI=mongodb://127.0.0.1:27017/chatgpt-jest
+
+# Credential encryption/decryption for testing
+CREDS_KEY=c3301ad2f69681295e022fb135e92787afb6ecfeaa012a10f8bb4ddf6b669e6d
+CREDS_IV=cd02538f4be2fa37aba9420b5924389f
+
+# For testing the ChatAgent
+OPENAI_API_KEY=your-api-key
+
+BAN_VIOLATIONS=true
+BAN_DURATION=7200000
+BAN_INTERVAL=20
+
+# NODE_MAX_OLD_SPACE_SIZE is only used as a Docker build argument.
+# Node.js does NOT recognize this environment variable for heap size.
+NODE_MAX_OLD_SPACE_SIZE=6144
diff --git a/api/test/__mocks__/auth.mock.json b/api/test/__mocks__/auth.mock.json
new file mode 100644
index 0000000..2b99c4c
--- /dev/null
+++ b/api/test/__mocks__/auth.mock.json
@@ -0,0 +1,13 @@
+{
+ "type": "service_account",
+ "project_id": "",
+ "private_key_id": "",
+ "private_key": "",
+ "client_email": "",
+ "client_id": "",
+ "auth_uri": "",
+ "token_uri": "",
+ "auth_provider_x509_cert_url": "",
+ "client_x509_cert_url": "",
+ "universe_domain": ""
+}
diff --git a/api/test/__mocks__/logger.js b/api/test/__mocks__/logger.js
new file mode 100644
index 0000000..699a948
--- /dev/null
+++ b/api/test/__mocks__/logger.js
@@ -0,0 +1,60 @@
+jest.mock('winston', () => {
+ // Real `winston.format(fn)` returns a Format constructor whose instances
+ // expose a `.transform(info, opts)` method that winston's pipeline calls.
+ // The previous mock `(fn) => fn` collapsed this — `parsers.redactFormat()`
+ // (called at @librechat/data-schemas dist module-load) ended up invoking
+ // the inner transform fn with no `info` argument, throwing on `info.level`.
+ // Returning a thunk that yields `{ transform: fn }` matches real winston's
+ // shape just enough that module-load completes cleanly; the inner fn is
+ // only ever invoked by winston's pipeline (never at load time).
+ const mockFormatFunction = jest.fn((fn) => () => ({ transform: fn }));
+
+ mockFormatFunction.colorize = jest.fn();
+ mockFormatFunction.combine = jest.fn();
+ mockFormatFunction.label = jest.fn();
+ mockFormatFunction.timestamp = jest.fn();
+ mockFormatFunction.printf = jest.fn();
+ mockFormatFunction.errors = jest.fn();
+ mockFormatFunction.splat = jest.fn();
+ mockFormatFunction.json = jest.fn();
+ return {
+ format: mockFormatFunction,
+ createLogger: jest.fn().mockReturnValue({
+ info: jest.fn(),
+ warn: jest.fn(),
+ debug: jest.fn(),
+ error: jest.fn(),
+ }),
+ transports: {
+ Console: jest.fn(),
+ DailyRotateFile: jest.fn(),
+ File: jest.fn(),
+ },
+ addColors: jest.fn(),
+ };
+});
+
+jest.mock('winston-daily-rotate-file', () => {
+ return jest.fn().mockImplementation(() => {
+ return {
+ level: 'error',
+ filename: '../logs/error-%DATE%.log',
+ datePattern: 'YYYY-MM-DD',
+ zippedArchive: true,
+ maxSize: '20m',
+ maxFiles: '14d',
+ format: 'format',
+ };
+ });
+});
+
+jest.mock('~/config', () => {
+ return {
+ logger: {
+ info: jest.fn(),
+ warn: jest.fn(),
+ debug: jest.fn(),
+ error: jest.fn(),
+ },
+ };
+});
diff --git a/api/test/__mocks__/openid-client-passport.js b/api/test/__mocks__/openid-client-passport.js
new file mode 100644
index 0000000..8a6f60e
--- /dev/null
+++ b/api/test/__mocks__/openid-client-passport.js
@@ -0,0 +1,6 @@
+// api/test/__mocks__/openid-client-passport.js
+const Strategy = jest.fn().mockImplementation((options, verify) => {
+ return { name: 'mocked-openid-passport-strategy', options, verify };
+});
+
+module.exports = { Strategy };
diff --git a/api/test/__mocks__/openid-client.js b/api/test/__mocks__/openid-client.js
new file mode 100644
index 0000000..766d8a3
--- /dev/null
+++ b/api/test/__mocks__/openid-client.js
@@ -0,0 +1,71 @@
+// api/test/__mocks__/openid-client.js
+module.exports = {
+ Issuer: {
+ discover: jest.fn().mockResolvedValue({
+ Client: jest.fn().mockImplementation(() => ({
+ authorizationUrl: jest.fn().mockReturnValue('mock_auth_url'),
+ callback: jest.fn().mockResolvedValue({
+ access_token: 'mock_access_token',
+ id_token: 'mock_id_token',
+ claims: () => ({
+ sub: 'mock_sub',
+ email: 'mock@example.com',
+ }),
+ }),
+ userinfo: jest.fn().mockResolvedValue({
+ sub: 'mock_sub',
+ email: 'mock@example.com',
+ }),
+ })),
+ }),
+ },
+ Strategy: jest.fn().mockImplementation((options, verify) => {
+ // Store verify to call it if needed, or just mock the strategy behavior
+ return { name: 'openid-mock-strategy' };
+ }),
+ custom: {
+ setHttpOptionsDefaults: jest.fn(),
+ },
+ // Add any other exports from openid-client that are used directly
+ // For example, if your code uses `client.Issuer.discover`, then mock `Issuer`
+ // If it uses `new Strategy()`, then mock `Strategy`
+ // Based on openidStrategy.js, it uses:
+ // const client = require('openid-client'); -> client.discovery, client.fetchUserInfo, client.genericGrantRequest
+ // const { Strategy: OpenIDStrategy } = require('openid-client/passport');
+ // So the mock needs to cover these.
+ // The provided mock in openidStrategy.spec.js is a good reference.
+
+ // Simpler mock based on the spec file:
+ discovery: jest.fn().mockResolvedValue({
+ clientId: 'fake_client_id',
+ clientSecret: 'fake_client_secret',
+ issuer: 'https://fake-issuer.com',
+ serverMetadata: jest.fn().mockReturnValue({
+ jwks_uri: 'https://fake-issuer.com/.well-known/jwks.json',
+ end_session_endpoint: 'https://fake-issuer.com/logout',
+ }),
+ Client: jest.fn().mockImplementation(() => ({
+ authorizationUrl: jest.fn().mockReturnValue('mock_auth_url'),
+ callback: jest.fn().mockResolvedValue({
+ access_token: 'mock_access_token',
+ id_token: 'mock_id_token',
+ claims: () => ({
+ sub: 'mock_sub',
+ email: 'mock@example.com',
+ }),
+ }),
+ userinfo: jest.fn().mockResolvedValue({
+ sub: 'mock_sub',
+ email: 'mock@example.com',
+ }),
+ grant: jest.fn().mockResolvedValue({ access_token: 'mock_grant_token' }), // For genericGrantRequest
+ })),
+ }),
+ fetchUserInfo: jest.fn().mockResolvedValue({
+ preferred_username: 'preferred_username',
+ }),
+ genericGrantRequest: jest
+ .fn()
+ .mockResolvedValue({ access_token: 'mock_grant_access_token', expires_in: 3600 }),
+ customFetch: Symbol('customFetch'),
+};
diff --git a/api/test/app/clients/tools/structured/OpenAIImageTools.test.js b/api/test/app/clients/tools/structured/OpenAIImageTools.test.js
new file mode 100644
index 0000000..b83ed53
--- /dev/null
+++ b/api/test/app/clients/tools/structured/OpenAIImageTools.test.js
@@ -0,0 +1,164 @@
+const OpenAI = require('openai');
+const createOpenAIImageTools = require('~/app/clients/tools/structured/OpenAIImageTools');
+
+jest.mock('openai');
+jest.mock('@librechat/data-schemas', () => ({
+ logger: {
+ warn: jest.fn(),
+ error: jest.fn(),
+ debug: jest.fn(),
+ },
+}));
+
+jest.mock('@librechat/api', () => ({
+ logAxiosError: jest.fn(),
+ oaiToolkit: {
+ image_gen_oai: {
+ name: 'image_gen_oai',
+ description: 'Generate an image',
+ schema: {},
+ },
+ image_edit_oai: {
+ name: 'image_edit_oai',
+ description: 'Edit an image',
+ schema: {},
+ },
+ },
+ extractBaseURL: jest.fn((url) => url),
+ getProxyDispatcher: jest.fn(() => undefined),
+ applyAxiosProxyConfig: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn(),
+}));
+
+jest.mock('~/models', () => ({
+ getFiles: jest.fn().mockResolvedValue([]),
+}));
+
+describe('OpenAIImageTools - IMAGE_GEN_OAI_MODEL environment variable', () => {
+ let originalEnv;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ originalEnv = { ...process.env };
+
+ process.env.IMAGE_GEN_OAI_API_KEY = 'test-api-key';
+
+ OpenAI.mockImplementation(() => ({
+ images: {
+ generate: jest.fn().mockResolvedValue({
+ data: [
+ {
+ b64_json: 'base64-encoded-image-data',
+ },
+ ],
+ }),
+ },
+ }));
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ it('should use default model "gpt-image-1" when IMAGE_GEN_OAI_MODEL is not set', async () => {
+ delete process.env.IMAGE_GEN_OAI_MODEL;
+
+ const [imageGenTool] = createOpenAIImageTools({
+ isAgent: true,
+ override: false,
+ req: { user: { id: 'test-user' } },
+ });
+
+ const mockGenerate = jest.fn().mockResolvedValue({
+ data: [
+ {
+ b64_json: 'base64-encoded-image-data',
+ },
+ ],
+ });
+
+ OpenAI.mockImplementation(() => ({
+ images: {
+ generate: mockGenerate,
+ },
+ }));
+
+ await imageGenTool.func({ prompt: 'test prompt' });
+
+ expect(mockGenerate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ model: 'gpt-image-1',
+ }),
+ expect.any(Object),
+ );
+ });
+
+ it('should use "gpt-image-1.5" when IMAGE_GEN_OAI_MODEL is set to "gpt-image-1.5"', async () => {
+ process.env.IMAGE_GEN_OAI_MODEL = 'gpt-image-1.5';
+
+ const mockGenerate = jest.fn().mockResolvedValue({
+ data: [
+ {
+ b64_json: 'base64-encoded-image-data',
+ },
+ ],
+ });
+
+ OpenAI.mockImplementation(() => ({
+ images: {
+ generate: mockGenerate,
+ },
+ }));
+
+ const [imageGenTool] = createOpenAIImageTools({
+ isAgent: true,
+ override: false,
+ req: { user: { id: 'test-user' } },
+ });
+
+ await imageGenTool.func({ prompt: 'test prompt' });
+
+ expect(mockGenerate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ model: 'gpt-image-1.5',
+ }),
+ expect.any(Object),
+ );
+ });
+
+ it('should use custom model name from IMAGE_GEN_OAI_MODEL environment variable', async () => {
+ process.env.IMAGE_GEN_OAI_MODEL = 'custom-image-model';
+
+ const mockGenerate = jest.fn().mockResolvedValue({
+ data: [
+ {
+ b64_json: 'base64-encoded-image-data',
+ },
+ ],
+ });
+
+ OpenAI.mockImplementation(() => ({
+ images: {
+ generate: mockGenerate,
+ },
+ }));
+
+ const [imageGenTool] = createOpenAIImageTools({
+ isAgent: true,
+ override: false,
+ req: { user: { id: 'test-user' } },
+ });
+
+ await imageGenTool.func({ prompt: 'test prompt' });
+
+ expect(mockGenerate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ model: 'custom-image-model',
+ }),
+ expect.any(Object),
+ );
+ });
+});
diff --git a/api/test/app/clients/tools/util/fileSearch.test.js b/api/test/app/clients/tools/util/fileSearch.test.js
new file mode 100644
index 0000000..fc4000b
--- /dev/null
+++ b/api/test/app/clients/tools/util/fileSearch.test.js
@@ -0,0 +1,293 @@
+const axios = require('axios');
+
+jest.mock('axios');
+jest.mock('@librechat/api', () => ({
+ generateShortLivedToken: jest.fn(),
+ logAxiosError: jest.fn(),
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: {
+ warn: jest.fn(),
+ error: jest.fn(),
+ debug: jest.fn(),
+ },
+}));
+
+jest.mock('~/models', () => ({
+ getFiles: jest.fn().mockResolvedValue([]),
+}));
+
+jest.mock('~/server/services/Files/permissions', () => ({
+ filterFilesByAgentAccess: jest.fn((options) => Promise.resolve(options.files)),
+}));
+
+const { createFileSearchTool } = require('~/app/clients/tools/util/fileSearch');
+const { generateShortLivedToken } = require('@librechat/api');
+
+describe('fileSearch.js - tuple return validation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ process.env.RAG_API_URL = 'http://localhost:8000';
+ });
+
+ describe('error cases should return tuple with undefined as second value', () => {
+ it('should return tuple when no files provided', async () => {
+ const fileSearchTool = await createFileSearchTool({
+ userId: 'user1',
+ files: [],
+ });
+
+ const result = await fileSearchTool.func({ query: 'test query' });
+
+ expect(Array.isArray(result)).toBe(true);
+ expect(result).toHaveLength(2);
+ expect(result[0]).toBe('No files to search. Instruct the user to add files for the search.');
+ expect(result[1]).toBeUndefined();
+ });
+
+ it('should return tuple when JWT token generation fails', async () => {
+ generateShortLivedToken.mockReturnValue(null);
+
+ const fileSearchTool = await createFileSearchTool({
+ userId: 'user1',
+ files: [{ file_id: 'file-1', filename: 'test.pdf' }],
+ });
+
+ const result = await fileSearchTool.func({ query: 'test query' });
+
+ expect(Array.isArray(result)).toBe(true);
+ expect(result).toHaveLength(2);
+ expect(result[0]).toBe('There was an error authenticating the file search request.');
+ expect(result[1]).toBeUndefined();
+ });
+
+ it('should return tuple when no valid results found', async () => {
+ generateShortLivedToken.mockReturnValue('mock-jwt-token');
+ axios.post.mockRejectedValue(new Error('API Error'));
+
+ const fileSearchTool = await createFileSearchTool({
+ userId: 'user1',
+ files: [{ file_id: 'file-1', filename: 'test.pdf' }],
+ });
+
+ const result = await fileSearchTool.func({ query: 'test query' });
+
+ expect(Array.isArray(result)).toBe(true);
+ expect(result).toHaveLength(2);
+ expect(result[0]).toBe('No results found or errors occurred while searching the files.');
+ expect(result[1]).toBeUndefined();
+ });
+ });
+
+ describe('success cases should return tuple with artifact object', () => {
+ it('should return tuple with formatted results and sources artifact', async () => {
+ generateShortLivedToken.mockReturnValue('mock-jwt-token');
+
+ const mockApiResponse = {
+ data: [
+ [
+ {
+ page_content: 'This is test content from the document',
+ metadata: { source: '/path/to/test.pdf', page: 1 },
+ },
+ 0.2,
+ ],
+ [
+ {
+ page_content: 'Additional relevant content',
+ metadata: { source: '/path/to/test.pdf', page: 2 },
+ },
+ 0.35,
+ ],
+ ],
+ };
+
+ axios.post.mockResolvedValue(mockApiResponse);
+
+ const fileSearchTool = await createFileSearchTool({
+ userId: 'user1',
+ files: [{ file_id: 'file-123', filename: 'test.pdf' }],
+ entity_id: 'agent-456',
+ });
+
+ const result = await fileSearchTool.func({ query: 'test query' });
+
+ expect(Array.isArray(result)).toBe(true);
+ expect(result).toHaveLength(2);
+
+ const [formattedString, artifact] = result;
+
+ expect(typeof formattedString).toBe('string');
+ expect(formattedString).toContain('File: test.pdf');
+ expect(formattedString).toContain('Relevance:');
+ expect(formattedString).toContain('This is test content from the document');
+ expect(formattedString).toContain('Additional relevant content');
+
+ expect(artifact).toBeDefined();
+ expect(artifact).toHaveProperty('file_search');
+ expect(artifact.file_search).toHaveProperty('sources');
+ expect(artifact.file_search).toHaveProperty('fileCitations', false);
+ expect(Array.isArray(artifact.file_search.sources)).toBe(true);
+ expect(artifact.file_search.sources.length).toBe(2);
+
+ const source = artifact.file_search.sources[0];
+ expect(source).toMatchObject({
+ type: 'file',
+ fileId: 'file-123',
+ fileName: 'test.pdf',
+ content: expect.any(String),
+ relevance: expect.any(Number),
+ pages: [1],
+ pageRelevance: { 1: expect.any(Number) },
+ });
+ });
+
+ it('should include file citations in description when enabled', async () => {
+ generateShortLivedToken.mockReturnValue('mock-jwt-token');
+
+ const mockApiResponse = {
+ data: [
+ [
+ {
+ page_content: 'Content with citations',
+ metadata: { source: '/path/to/doc.pdf', page: 3 },
+ },
+ 0.15,
+ ],
+ ],
+ };
+
+ axios.post.mockResolvedValue(mockApiResponse);
+
+ const fileSearchTool = await createFileSearchTool({
+ userId: 'user1',
+ files: [{ file_id: 'file-789', filename: 'doc.pdf' }],
+ fileCitations: true,
+ });
+
+ const result = await fileSearchTool.func({ query: 'test query' });
+
+ expect(Array.isArray(result)).toBe(true);
+ expect(result).toHaveLength(2);
+
+ const [formattedString, artifact] = result;
+
+ expect(formattedString).toContain('Anchor:');
+ expect(formattedString).toContain('\\ue202turn0file0');
+ expect(artifact.file_search.fileCitations).toBe(true);
+ });
+
+ it('should handle multiple files correctly', async () => {
+ generateShortLivedToken.mockReturnValue('mock-jwt-token');
+
+ const mockResponse1 = {
+ data: [
+ [
+ {
+ page_content: 'Content from file 1',
+ metadata: { source: '/path/to/file1.pdf', page: 1 },
+ },
+ 0.25,
+ ],
+ ],
+ };
+
+ const mockResponse2 = {
+ data: [
+ [
+ {
+ page_content: 'Content from file 2',
+ metadata: { source: '/path/to/file2.pdf', page: 1 },
+ },
+ 0.15,
+ ],
+ ],
+ };
+
+ axios.post.mockResolvedValueOnce(mockResponse1).mockResolvedValueOnce(mockResponse2);
+
+ const fileSearchTool = await createFileSearchTool({
+ userId: 'user1',
+ files: [
+ { file_id: 'file-1', filename: 'file1.pdf' },
+ { file_id: 'file-2', filename: 'file2.pdf' },
+ ],
+ });
+
+ const result = await fileSearchTool.func({ query: 'test query' });
+
+ expect(Array.isArray(result)).toBe(true);
+ expect(result).toHaveLength(2);
+
+ const [formattedString, artifact] = result;
+
+ expect(formattedString).toContain('file1.pdf');
+ expect(formattedString).toContain('file2.pdf');
+ expect(artifact.file_search.sources).toHaveLength(2);
+ // Results are sorted by distance (ascending), so file-2 (0.15) comes before file-1 (0.25)
+ expect(artifact.file_search.sources[0].fileId).toBe('file-2');
+ expect(artifact.file_search.sources[1].fileId).toBe('file-1');
+ });
+ });
+});
+
+describe('entity_id scoping by file origin', () => {
+ const ORIGINAL_RAG_API_URL = process.env.RAG_API_URL;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ process.env.RAG_API_URL = 'http://localhost:8000';
+ generateShortLivedToken.mockReturnValue('mock-jwt-token');
+ axios.post.mockResolvedValue({ data: [] });
+ });
+
+ afterEach(() => {
+ if (ORIGINAL_RAG_API_URL === undefined) {
+ delete process.env.RAG_API_URL;
+ } else {
+ process.env.RAG_API_URL = ORIGINAL_RAG_API_URL;
+ }
+ });
+
+ function bodiesSent() {
+ return axios.post.mock.calls
+ .filter(([url]) => String(url).endsWith('/query'))
+ .map(([, body]) => body);
+ }
+
+ it('sends entity_id only for agent knowledge-base files', async () => {
+ const tool = await createFileSearchTool({
+ userId: 'user1',
+ entity_id: 'agent_123',
+ files: [
+ { file_id: 'kb-1', filename: 'kb.pdf', fromAgent: true },
+ { file_id: 'user-1', filename: 'attachment.txt', fromAgent: false },
+ ],
+ });
+ await tool.func({ query: 'q' });
+
+ const bodies = bodiesSent();
+ expect(bodies.find((b) => b.file_id === 'kb-1').entity_id).toBe('agent_123');
+ expect(bodies.find((b) => b.file_id === 'user-1').entity_id).toBeUndefined();
+ });
+
+ it('omits entity_id when fromAgent is not set (safe default)', async () => {
+ const tool = await createFileSearchTool({
+ userId: 'user1',
+ entity_id: 'agent_123',
+ files: [{ file_id: 'legacy-1', filename: 'legacy.pdf' }],
+ });
+ await tool.func({ query: 'q' });
+ expect(bodiesSent()[0].entity_id).toBeUndefined();
+ });
+
+ it('sends no entity_id when none is provided', async () => {
+ const tool = await createFileSearchTool({
+ userId: 'user1',
+ files: [{ file_id: 'f1', filename: 'a.txt', fromAgent: true }],
+ });
+ await tool.func({ query: 'q' });
+ expect(bodiesSent()[0].entity_id).toBeUndefined();
+ });
+});
diff --git a/api/test/jestSetup.js b/api/test/jestSetup.js
new file mode 100644
index 0000000..f358027
--- /dev/null
+++ b/api/test/jestSetup.js
@@ -0,0 +1,39 @@
+/**
+ * `undici` (transitive dep of `@librechat/agents` and others) references
+ * `globalThis.File` from `node:buffer`. Node 20+ exposes it as a global;
+ * Node 18 / certain WSL toolchains do not, which surfaces as a
+ * `ReferenceError: File is not defined` at module-load time the first
+ * time a test imports `@librechat/agents`. Mirror the polyfill in
+ * `packages/api/jest.setup.cjs` so this Jest suite boots on the same
+ * Node versions; production code never relies on this — only Jest does.
+ */
+if (typeof globalThis.File === 'undefined') {
+ try {
+ const { File } = require('node:buffer');
+ if (File != null) {
+ globalThis.File = File;
+ }
+ } catch {
+ // Older Node versions without `node:buffer.File`. LibreChat doesn't
+ // support those anyway; let the test fail loudly.
+ }
+}
+
+// See .env.test.example for an example of the '.env.test' file.
+require('dotenv').config({ path: './test/.env.test' });
+
+process.env.MONGO_URI = 'mongodb://127.0.0.1:27017/dummy-uri';
+process.env.BAN_VIOLATIONS = 'true';
+process.env.BAN_DURATION = '7200000';
+process.env.BAN_INTERVAL = '20';
+process.env.CI = 'true';
+process.env.JWT_SECRET = 'test';
+process.env.JWT_REFRESH_SECRET = 'test';
+process.env.CREDS_KEY = 'test';
+process.env.CREDS_IV = 'test';
+process.env.ALLOW_EMAIL_LOGIN = 'true';
+
+// Set global test timeout to 30 seconds
+// This can be overridden in individual tests if needed
+jest.setTimeout(30000);
+process.env.OPENAI_API_KEY = 'test';
diff --git a/api/test/migrate-orphaned-agent-files.spec.js b/api/test/migrate-orphaned-agent-files.spec.js
new file mode 100644
index 0000000..f8333ec
--- /dev/null
+++ b/api/test/migrate-orphaned-agent-files.spec.js
@@ -0,0 +1,184 @@
+/**
+ * Integration test for the orphan-cleanup migration script used to heal
+ * agents corrupted before the delete-time and save-time fixes for issue
+ * #12776 shipped. Exercises the full module end-to-end:
+ * - dry-run reports orphans without writing
+ * - apply mode removes them
+ * - re-running on a cleaned database is a no-op (idempotent)
+ * - the DETAIL_SAMPLE_LIMIT truncation kicks in on wide corruption
+ */
+
+// Replace the migration's `./connect` helper — it opens its own connection
+// via the mongo URI env var, but the test already owns the mongoose instance.
+jest.mock('../../config/connect', () => jest.fn(async () => undefined));
+
+jest.mock('@librechat/data-schemas', () => {
+ const actual = jest.requireActual('@librechat/data-schemas');
+ return {
+ ...actual,
+ logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn(), info: jest.fn() },
+ };
+});
+
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { agentSchema, fileSchema } = require('@librechat/data-schemas');
+const { FileSources } = require('librechat-data-provider');
+
+const { migrateOrphanedAgentFiles } = require('../../config/migrate-orphaned-agent-files');
+
+describe('migrate-orphaned-agent-files (issue #12776)', () => {
+ let mongoServer;
+ let Agent;
+ let File;
+ const userId = () => new mongoose.Types.ObjectId();
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ await mongoose.connect(mongoServer.getUri());
+ Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
+ File = mongoose.models.File || mongoose.model('File', fileSchema);
+ }, 30000);
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await Agent.deleteMany({});
+ await File.deleteMany({});
+ });
+
+ const seedFile = (file_id) =>
+ File.create({
+ file_id,
+ user: userId(),
+ filename: `${file_id}.txt`,
+ filepath: `/tmp/${file_id}`,
+ object: 'file',
+ type: 'text/plain',
+ bytes: 1,
+ source: FileSources.text,
+ });
+
+ const seedAgent = (tool_resources) =>
+ Agent.create({
+ id: `agent_${Math.random().toString(36).slice(2, 10)}`,
+ name: `Test Agent ${Math.random().toString(36).slice(2, 6)}`,
+ provider: 'test',
+ model: 'test-model',
+ author: userId(),
+ tool_resources,
+ });
+
+ test('dry-run reports orphans without mutating any agent', async () => {
+ const keeperId = 'keeper';
+ await seedFile(keeperId);
+ const agent = await seedAgent({
+ file_search: { file_ids: [keeperId, 'orphan_1', 'orphan_2'] },
+ });
+
+ const result = await migrateOrphanedAgentFiles({ dryRun: true });
+
+ expect(result.dryRun).toBe(true);
+ expect(result.scannedAgents).toBe(1);
+ expect(result.agentsWithOrphans).toBe(1);
+ expect(result.totalOrphansRemoved).toBe(2);
+ // Dry-run reports what would change without writing — no updates counted.
+ expect(result.agentsUpdated).toBe(0);
+
+ const after = await Agent.findOne({ id: agent.id }).lean();
+ expect(after.tool_resources.file_search.file_ids).toEqual([keeperId, 'orphan_1', 'orphan_2']);
+ });
+
+ test('apply mode removes orphans across every tool_resource category', async () => {
+ const keeperA = 'k_a';
+ const keeperB = 'k_b';
+ await seedFile(keeperA);
+ await seedFile(keeperB);
+
+ const agent = await seedAgent({
+ file_search: { file_ids: [keeperA, 'o1'] },
+ execute_code: { file_ids: ['o2', keeperB] },
+ context: { file_ids: ['o3'] },
+ });
+
+ const result = await migrateOrphanedAgentFiles({ dryRun: false });
+
+ expect(result.dryRun).toBe(false);
+ expect(result.agentsWithOrphans).toBe(1);
+ expect(result.agentsUpdated).toBe(1);
+ expect(result.totalOrphansRemoved).toBe(3);
+
+ const after = await Agent.findOne({ id: agent.id }).lean();
+ expect(after.tool_resources.file_search.file_ids).toEqual([keeperA]);
+ expect(after.tool_resources.execute_code.file_ids).toEqual([keeperB]);
+ expect(after.tool_resources.context.file_ids).toEqual([]);
+ });
+
+ test('is idempotent — re-running on a clean database is a no-op', async () => {
+ await seedFile('keeper');
+ await seedAgent({ file_search: { file_ids: ['keeper', 'orphan'] } });
+
+ await migrateOrphanedAgentFiles({ dryRun: false });
+ const second = await migrateOrphanedAgentFiles({ dryRun: false });
+
+ expect(second.agentsWithOrphans).toBe(0);
+ expect(second.agentsUpdated).toBe(0);
+ expect(second.totalOrphansRemoved).toBe(0);
+ });
+
+ test('leaves agents without orphans completely alone', async () => {
+ await seedFile('only');
+ const agent = await seedAgent({ file_search: { file_ids: ['only'] } });
+
+ const result = await migrateOrphanedAgentFiles({ dryRun: false });
+
+ expect(result.scannedAgents).toBe(1);
+ expect(result.agentsWithOrphans).toBe(0);
+ const after = await Agent.findOne({ id: agent.id }).lean();
+ expect(after.tool_resources.file_search.file_ids).toEqual(['only']);
+ });
+
+ test('sample array is bounded on wide corruption (DETAIL_SAMPLE_LIMIT)', async () => {
+ // Seed more than the cap (50) so the truncation branch is exercised.
+ const agents = [];
+ for (let i = 0; i < 55; i++) {
+ agents.push(
+ await seedAgent({
+ file_search: { file_ids: [`orphan_${i}`] },
+ }),
+ );
+ }
+
+ const result = await migrateOrphanedAgentFiles({ dryRun: true });
+
+ expect(result.agentsWithOrphans).toBe(55);
+ expect(result.details.length).toBeLessThanOrEqual(50);
+ expect(result.details.length).toBeGreaterThan(0);
+ });
+
+ test('runs the body inside a system tenant context (strict-mode safe)', async () => {
+ // Pins the runAsSystem wrap: without it the migration throws under
+ // TENANT_ISOLATION_STRICT=true on the very first Agent.countDocuments(),
+ // blocking the intended remediation path for corrupted agents.
+ const { SYSTEM_TENANT_ID, tenantStorage } = require('@librechat/data-schemas');
+ await seedFile('keeper');
+ await seedAgent({ file_search: { file_ids: ['keeper', 'orphan'] } });
+
+ const contextsObserved = [];
+ const originalCountDocuments = Agent.countDocuments.bind(Agent);
+ Agent.countDocuments = jest.fn((...args) => {
+ contextsObserved.push(tenantStorage.getStore()?.tenantId);
+ return originalCountDocuments(...args);
+ });
+
+ try {
+ await migrateOrphanedAgentFiles({ dryRun: false });
+ expect(contextsObserved).toContain(SYSTEM_TENANT_ID);
+ } finally {
+ Agent.countDocuments = originalCountDocuments;
+ }
+ });
+});
diff --git a/api/test/server/middleware/checkBan.test.js b/api/test/server/middleware/checkBan.test.js
new file mode 100644
index 0000000..518153b
--- /dev/null
+++ b/api/test/server/middleware/checkBan.test.js
@@ -0,0 +1,426 @@
+const mockBanCacheGet = jest.fn().mockResolvedValue(undefined);
+const mockBanCacheSet = jest.fn().mockResolvedValue(undefined);
+
+jest.mock('keyv', () => ({
+ Keyv: jest.fn().mockImplementation(() => ({
+ get: mockBanCacheGet,
+ set: mockBanCacheSet,
+ })),
+}));
+
+const mockBanLogsGet = jest.fn().mockResolvedValue(undefined);
+const mockBanLogsDelete = jest.fn().mockResolvedValue(true);
+const mockBanLogs = {
+ get: mockBanLogsGet,
+ delete: mockBanLogsDelete,
+ opts: { ttl: 7200000 },
+};
+
+jest.mock('~/cache', () => ({
+ getLogStores: jest.fn(() => mockBanLogs),
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: {
+ info: jest.fn(),
+ warn: jest.fn(),
+ debug: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
+jest.mock('@librechat/api', () => ({
+ isEnabled: (value) => {
+ if (typeof value === 'boolean') {
+ return value;
+ }
+ if (typeof value === 'string') {
+ return value.toLowerCase().trim() === 'true';
+ }
+ return false;
+ },
+ keyvMongo: {},
+ removePorts: jest.fn((req) => req.ip),
+}));
+
+jest.mock('~/models', () => ({
+ findUser: jest.fn(),
+}));
+
+jest.mock('~/server/middleware/denyRequest', () => jest.fn().mockResolvedValue(undefined));
+
+jest.mock('ua-parser-js', () => jest.fn(() => ({ browser: { name: 'Chrome' } })));
+
+const checkBan = require('~/server/middleware/checkBan');
+const { logger } = require('@librechat/data-schemas');
+const { findUser } = require('~/models');
+
+const createReq = (overrides = {}) => ({
+ ip: '192.168.1.1',
+ user: { id: 'user123' },
+ headers: { 'user-agent': 'Mozilla/5.0' },
+ body: {},
+ baseUrl: '/api',
+ originalUrl: '/api/test',
+ ...overrides,
+});
+
+const createRes = () => ({
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+});
+
+describe('checkBan middleware', () => {
+ let originalEnv;
+
+ beforeEach(() => {
+ originalEnv = { ...process.env };
+ process.env.BAN_VIOLATIONS = 'true';
+ delete process.env.USE_REDIS;
+ mockBanLogs.opts.ttl = 7200000;
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ jest.clearAllMocks();
+ });
+
+ describe('early exits', () => {
+ it('calls next() when BAN_VIOLATIONS is disabled', async () => {
+ process.env.BAN_VIOLATIONS = 'false';
+ const next = jest.fn();
+
+ await checkBan(createReq(), createRes(), next);
+
+ expect(next).toHaveBeenCalledWith();
+ expect(mockBanCacheGet).not.toHaveBeenCalled();
+ });
+
+ it('calls next() when BAN_VIOLATIONS is unset', async () => {
+ delete process.env.BAN_VIOLATIONS;
+ const next = jest.fn();
+
+ await checkBan(createReq(), createRes(), next);
+
+ expect(next).toHaveBeenCalledWith();
+ });
+
+ it('calls next() when neither userId nor IP is available', async () => {
+ const next = jest.fn();
+ const req = createReq({ ip: null, user: null });
+
+ await checkBan(req, createRes(), next);
+
+ expect(next).toHaveBeenCalledWith();
+ });
+
+ it('calls next() when ban duration is <= 0', async () => {
+ mockBanLogs.opts.ttl = 0;
+ const next = jest.fn();
+
+ await checkBan(createReq(), createRes(), next);
+
+ expect(next).toHaveBeenCalledWith();
+ });
+
+ it('calls next() when no ban exists in cache or DB', async () => {
+ const next = jest.fn();
+
+ await checkBan(createReq(), createRes(), next);
+
+ expect(next).toHaveBeenCalledWith();
+ expect(mockBanCacheGet).toHaveBeenCalled();
+ expect(mockBanLogsGet).toHaveBeenCalled();
+ });
+ });
+
+ describe('cache hit path', () => {
+ it('returns 403 when IP ban is cached', async () => {
+ mockBanCacheGet.mockResolvedValueOnce({ expiresAt: Date.now() + 60000 });
+ const next = jest.fn();
+ const req = createReq();
+ const res = createRes();
+
+ await checkBan(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(req.banned).toBe(true);
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ it('returns 403 when user ban is cached (IP miss)', async () => {
+ mockBanCacheGet
+ .mockResolvedValueOnce(undefined)
+ .mockResolvedValueOnce({ expiresAt: Date.now() + 60000 });
+ const next = jest.fn();
+ const req = createReq();
+ const res = createRes();
+
+ await checkBan(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(req.banned).toBe(true);
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ it('does not query banLogs when cache hit occurs', async () => {
+ mockBanCacheGet.mockResolvedValueOnce({ expiresAt: Date.now() + 60000 });
+
+ await checkBan(createReq(), createRes(), jest.fn());
+
+ expect(mockBanLogsGet).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('active ban (positive timeLeft)', () => {
+ it('caches ban with correct TTL and returns 403', async () => {
+ const expiresAt = Date.now() + 3600000;
+ const banRecord = { expiresAt, type: 'ban', violation_count: 3 };
+ mockBanLogsGet.mockResolvedValueOnce(banRecord);
+ const next = jest.fn();
+ const req = createReq();
+ const res = createRes();
+
+ await checkBan(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(req.banned).toBe(true);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(mockBanCacheSet).toHaveBeenCalledTimes(2);
+
+ const [ipCacheCall, userCacheCall] = mockBanCacheSet.mock.calls;
+ expect(ipCacheCall[0]).toBe('192.168.1.1');
+ expect(ipCacheCall[1]).toBe(banRecord);
+ expect(ipCacheCall[2]).toBeGreaterThan(0);
+ expect(ipCacheCall[2]).toBeLessThanOrEqual(3600000);
+
+ expect(userCacheCall[0]).toBe('user123');
+ expect(userCacheCall[1]).toBe(banRecord);
+ });
+
+ it('caches only IP when no userId is present', async () => {
+ const expiresAt = Date.now() + 3600000;
+ mockBanLogsGet.mockResolvedValueOnce({ expiresAt, type: 'ban' });
+ const req = createReq({ user: null });
+
+ await checkBan(req, createRes(), jest.fn());
+
+ expect(mockBanCacheSet).toHaveBeenCalledTimes(1);
+ expect(mockBanCacheSet).toHaveBeenCalledWith(
+ '192.168.1.1',
+ expect.any(Object),
+ expect.any(Number),
+ );
+ });
+ });
+
+ describe('expired ban cleanup', () => {
+ it('cleans up and calls next() for expired user-key ban', async () => {
+ const expiresAt = Date.now() - 1000;
+ mockBanLogsGet
+ .mockResolvedValueOnce(undefined)
+ .mockResolvedValueOnce({ expiresAt, type: 'ban' });
+ const next = jest.fn();
+ const req = createReq();
+
+ await checkBan(req, createRes(), next);
+
+ expect(next).toHaveBeenCalledWith();
+ expect(req.banned).toBeUndefined();
+ expect(mockBanLogsDelete).toHaveBeenCalledWith('user123');
+ expect(mockBanCacheSet).not.toHaveBeenCalled();
+ });
+
+ it('cleans up and calls next() for expired IP-only ban (Finding 1 regression)', async () => {
+ const expiresAt = Date.now() - 1000;
+ mockBanLogsGet.mockResolvedValueOnce({ expiresAt, type: 'ban' });
+ const next = jest.fn();
+ const req = createReq({ user: null });
+
+ await checkBan(req, createRes(), next);
+
+ expect(next).toHaveBeenCalledWith();
+ expect(req.banned).toBeUndefined();
+ expect(mockBanLogsDelete).toHaveBeenCalledWith('192.168.1.1');
+ expect(mockBanCacheSet).not.toHaveBeenCalled();
+ });
+
+ it('cleans up both IP and user bans when both are expired', async () => {
+ const expiresAt = Date.now() - 1000;
+ mockBanLogsGet
+ .mockResolvedValueOnce({ expiresAt, type: 'ban' })
+ .mockResolvedValueOnce({ expiresAt, type: 'ban' });
+ const next = jest.fn();
+
+ await checkBan(createReq(), createRes(), next);
+
+ expect(next).toHaveBeenCalledWith();
+ expect(mockBanLogsDelete).toHaveBeenCalledTimes(2);
+ expect(mockBanLogsDelete).toHaveBeenCalledWith('192.168.1.1');
+ expect(mockBanLogsDelete).toHaveBeenCalledWith('user123');
+ });
+
+ it('does not write to banCache when ban is expired', async () => {
+ const expiresAt = Date.now() - 60000;
+ mockBanLogsGet.mockResolvedValueOnce({ expiresAt, type: 'ban' });
+
+ await checkBan(createReq({ user: null }), createRes(), jest.fn());
+
+ expect(mockBanCacheSet).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Redis key paths (Finding 2 regression)', () => {
+ beforeEach(() => {
+ process.env.USE_REDIS = 'true';
+ });
+
+ it('uses cache-prefixed keys for banCache.get', async () => {
+ await checkBan(createReq(), createRes(), jest.fn());
+
+ expect(mockBanCacheGet).toHaveBeenCalledWith('ban_cache:ip:192.168.1.1');
+ expect(mockBanCacheGet).toHaveBeenCalledWith('ban_cache:user:user123');
+ });
+
+ it('uses raw keys (not cache-prefixed) for banLogs.delete on cleanup', async () => {
+ const expiresAt = Date.now() - 1000;
+ mockBanLogsGet
+ .mockResolvedValueOnce({ expiresAt, type: 'ban' })
+ .mockResolvedValueOnce({ expiresAt, type: 'ban' });
+
+ await checkBan(createReq(), createRes(), jest.fn());
+
+ expect(mockBanLogsDelete).toHaveBeenCalledWith('192.168.1.1');
+ expect(mockBanLogsDelete).toHaveBeenCalledWith('user123');
+ for (const call of mockBanLogsDelete.mock.calls) {
+ expect(call[0]).not.toMatch(/^ban_cache:/);
+ }
+ });
+
+ it('uses cache-prefixed keys for banCache.set on active ban', async () => {
+ const expiresAt = Date.now() + 3600000;
+ mockBanLogsGet.mockResolvedValueOnce({ expiresAt, type: 'ban' });
+
+ await checkBan(createReq(), createRes(), jest.fn());
+
+ expect(mockBanCacheSet).toHaveBeenCalledWith(
+ 'ban_cache:ip:192.168.1.1',
+ expect.any(Object),
+ expect.any(Number),
+ );
+ expect(mockBanCacheSet).toHaveBeenCalledWith(
+ 'ban_cache:user:user123',
+ expect.any(Object),
+ expect.any(Number),
+ );
+ });
+ });
+
+ describe('missing expiresAt guard (Finding 5)', () => {
+ it('returns 403 without caching when expiresAt is missing', async () => {
+ mockBanLogsGet.mockResolvedValueOnce({ type: 'ban' });
+ const next = jest.fn();
+ const req = createReq();
+ const res = createRes();
+
+ await checkBan(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(req.banned).toBe(true);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(mockBanCacheSet).not.toHaveBeenCalled();
+ });
+
+ it('returns 403 without caching when expiresAt is NaN-producing', async () => {
+ mockBanLogsGet.mockResolvedValueOnce({ type: 'ban', expiresAt: 'not-a-number' });
+ const next = jest.fn();
+ const res = createRes();
+
+ await checkBan(createReq(), res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(mockBanCacheSet).not.toHaveBeenCalled();
+ });
+
+ it('returns 403 without caching when expiresAt is null', async () => {
+ mockBanLogsGet.mockResolvedValueOnce({ type: 'ban', expiresAt: null });
+ const next = jest.fn();
+ const res = createRes();
+
+ await checkBan(createReq(), res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(mockBanCacheSet).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('cache write error handling (Finding 4)', () => {
+ it('still returns 403 when banCache.set rejects', async () => {
+ const expiresAt = Date.now() + 3600000;
+ mockBanLogsGet.mockResolvedValueOnce({ expiresAt, type: 'ban' });
+ mockBanCacheSet.mockRejectedValue(new Error('MongoDB write failure'));
+ const next = jest.fn();
+ const req = createReq();
+ const res = createRes();
+
+ await checkBan(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(req.banned).toBe(true);
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ it('logs a warning when banCache.set fails', async () => {
+ const expiresAt = Date.now() + 3600000;
+ mockBanLogsGet.mockResolvedValueOnce({ expiresAt, type: 'ban' });
+ mockBanCacheSet.mockRejectedValue(new Error('write failed'));
+
+ await checkBan(createReq(), createRes(), jest.fn());
+
+ expect(logger.warn).toHaveBeenCalledWith(
+ '[checkBan] Failed to write ban cache:',
+ expect.any(Error),
+ );
+ });
+ });
+
+ describe('user lookup by email', () => {
+ it('resolves userId from email when not on request', async () => {
+ const req = createReq({ user: null, body: { email: 'test@example.com' } });
+ findUser.mockResolvedValueOnce({ _id: 'resolved-user-id' });
+ const expiresAt = Date.now() + 3600000;
+ mockBanLogsGet
+ .mockResolvedValueOnce(undefined)
+ .mockResolvedValueOnce({ expiresAt, type: 'ban' });
+
+ await checkBan(req, createRes(), jest.fn());
+
+ expect(findUser).toHaveBeenCalledWith({ email: 'test@example.com' }, '_id');
+ expect(req.banned).toBe(true);
+ });
+
+ it('continues with IP-only check when email lookup finds no user', async () => {
+ const req = createReq({ user: null, body: { email: 'unknown@example.com' } });
+ findUser.mockResolvedValueOnce(null);
+ const next = jest.fn();
+
+ await checkBan(req, createRes(), next);
+
+ expect(next).toHaveBeenCalledWith();
+ });
+ });
+
+ describe('error handling', () => {
+ it('calls next(error) when an unexpected error occurs', async () => {
+ mockBanCacheGet.mockRejectedValueOnce(new Error('connection lost'));
+ const next = jest.fn();
+
+ await checkBan(createReq(), createRes(), next);
+
+ expect(next).toHaveBeenCalledWith(expect.any(Error));
+ expect(logger.error).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/api/test/services/Files/processFileCitations.test.js b/api/test/services/Files/processFileCitations.test.js
new file mode 100644
index 0000000..8dd588a
--- /dev/null
+++ b/api/test/services/Files/processFileCitations.test.js
@@ -0,0 +1,347 @@
+const { Tools } = require('librechat-data-provider');
+const {
+ processFileCitations,
+ applyCitationLimits,
+ enhanceSourcesWithMetadata,
+} = require('~/server/services/Files/Citations');
+
+// Mock dependencies
+jest.mock('~/models', () => ({
+ getFiles: jest.fn().mockResolvedValue([]),
+ getRoleByName: jest.fn(),
+}));
+
+jest.mock('@librechat/api', () => ({
+ checkAccess: jest.fn().mockResolvedValue(true),
+}));
+
+jest.mock('~/cache/getLogStores', () => () => ({
+ get: jest.fn().mockResolvedValue({
+ agents: {
+ maxCitations: 30,
+ maxCitationsPerFile: 5,
+ minRelevanceScore: 0.45,
+ },
+ fileStrategy: 'local',
+ }),
+ set: jest.fn(),
+ delete: jest.fn(),
+}));
+
+jest.mock('~/config', () => ({
+ logger: {
+ debug: jest.fn(),
+ error: jest.fn(),
+ warn: jest.fn(),
+ },
+}));
+
+describe('processFileCitations', () => {
+ const mockReq = {
+ user: {
+ id: 'user123',
+ },
+ };
+
+ const mockAppConfig = {
+ endpoints: {
+ agents: {
+ maxCitations: 30,
+ maxCitationsPerFile: 5,
+ minRelevanceScore: 0.45,
+ },
+ },
+ fileStrategy: 'local',
+ };
+
+ const mockMetadata = {
+ run_id: 'run123',
+ thread_id: 'conv123',
+ };
+
+ describe('file search artifact processing', () => {
+ it('should process file search artifacts correctly', async () => {
+ const toolArtifact = {
+ [Tools.file_search]: {
+ sources: [
+ {
+ fileId: 'file_123',
+ fileName: 'example.pdf',
+ pages: [5],
+ relevance: 0.85,
+ type: 'file',
+ pageRelevance: { 5: 0.85 },
+ content: 'This is the content',
+ },
+ {
+ fileId: 'file_456',
+ fileName: 'document.txt',
+ pages: [],
+ relevance: 0.72,
+ type: 'file',
+ pageRelevance: {},
+ content: 'Another document',
+ },
+ ],
+ },
+ };
+
+ const result = await processFileCitations({
+ toolArtifact,
+ toolCallId: 'call_123',
+ metadata: mockMetadata,
+ user: mockReq.user,
+ appConfig: mockAppConfig,
+ });
+
+ expect(result).toBeTruthy();
+ expect(result.type).toBe('file_search');
+ expect(result.file_search.sources).toHaveLength(2);
+ expect(result.file_search.sources[0].fileId).toBe('file_123');
+ expect(result.file_search.sources[0].relevance).toBe(0.85);
+ });
+
+ it('should return null for non-file_search tools', async () => {
+ const result = await processFileCitations({
+ toolArtifact: { other_tool: {} },
+ toolCallId: 'call_123',
+ metadata: mockMetadata,
+ user: mockReq.user,
+ appConfig: mockAppConfig,
+ });
+
+ expect(result).toBeNull();
+ });
+
+ it('should filter results below relevance threshold', async () => {
+ const toolArtifact = {
+ [Tools.file_search]: {
+ sources: [
+ {
+ fileId: 'file_789',
+ fileName: 'low_relevance.pdf',
+ pages: [],
+ relevance: 0.2,
+ type: 'file',
+ pageRelevance: {},
+ content: 'Low relevance content',
+ },
+ ],
+ },
+ };
+
+ const result = await processFileCitations({
+ toolArtifact,
+ toolCallId: 'call_123',
+ metadata: mockMetadata,
+ user: mockReq.user,
+ appConfig: mockAppConfig,
+ });
+
+ expect(result).toBeNull();
+ });
+
+ it('should return null when artifact is missing file_search data', async () => {
+ const result = await processFileCitations({
+ toolArtifact: {},
+ toolCallId: 'call_123',
+ metadata: mockMetadata,
+ user: mockReq.user,
+ appConfig: mockAppConfig,
+ });
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('applyCitationLimits', () => {
+ it('should limit citations per file and total', () => {
+ const sources = [
+ { fileId: 'file1', relevance: 0.9 },
+ { fileId: 'file1', relevance: 0.8 },
+ { fileId: 'file1', relevance: 0.7 },
+ { fileId: 'file2', relevance: 0.85 },
+ { fileId: 'file2', relevance: 0.75 },
+ ];
+
+ const result = applyCitationLimits(sources, 3, 2);
+
+ expect(result).toHaveLength(3);
+ expect(result[0].relevance).toBe(0.9);
+ expect(result[1].relevance).toBe(0.85);
+ expect(result[2].relevance).toBe(0.8);
+ });
+ });
+
+ describe('enhanceSourcesWithMetadata', () => {
+ const { getFiles } = require('~/models');
+ const mockCustomConfig = {
+ fileStrategy: 'local',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should enhance sources with file metadata from database', async () => {
+ const sources = [
+ {
+ fileId: 'file_123',
+ fileName: 'example.pdf',
+ relevance: 0.85,
+ type: 'file',
+ },
+ {
+ fileId: 'file_456',
+ fileName: 'document.txt',
+ relevance: 0.72,
+ type: 'file',
+ },
+ ];
+
+ getFiles.mockResolvedValue([
+ {
+ file_id: 'file_123',
+ filename: 'example_from_db.pdf',
+ source: 's3',
+ },
+ {
+ file_id: 'file_456',
+ filename: 'document_from_db.txt',
+ source: 'local',
+ },
+ ]);
+
+ const result = await enhanceSourcesWithMetadata(sources, mockCustomConfig);
+
+ expect(getFiles).toHaveBeenCalledWith({ file_id: { $in: ['file_123', 'file_456'] } });
+ expect(result).toHaveLength(2);
+
+ expect(result[0]).toEqual({
+ fileId: 'file_123',
+ fileName: 'example_from_db.pdf',
+ relevance: 0.85,
+ type: 'file',
+ metadata: {
+ storageType: 's3',
+ },
+ });
+
+ expect(result[1]).toEqual({
+ fileId: 'file_456',
+ fileName: 'document_from_db.txt',
+ relevance: 0.72,
+ type: 'file',
+ metadata: {
+ storageType: 'local',
+ },
+ });
+ });
+
+ it('should preserve existing metadata and source data', async () => {
+ const sources = [
+ {
+ fileId: 'file_123',
+ fileName: 'example.pdf',
+ relevance: 0.85,
+ type: 'file',
+ pages: [1, 2, 3],
+ content: 'Some content',
+ metadata: {
+ existingField: 'value',
+ },
+ },
+ ];
+
+ getFiles.mockResolvedValue([
+ {
+ file_id: 'file_123',
+ filename: 'example_from_db.pdf',
+ source: 'gcs',
+ },
+ ]);
+
+ const result = await enhanceSourcesWithMetadata(sources, mockCustomConfig);
+
+ expect(result[0]).toEqual({
+ fileId: 'file_123',
+ fileName: 'example_from_db.pdf',
+ relevance: 0.85,
+ type: 'file',
+ pages: [1, 2, 3],
+ content: 'Some content',
+ metadata: {
+ existingField: 'value',
+ storageType: 'gcs',
+ },
+ });
+ });
+
+ it('should handle missing file metadata gracefully', async () => {
+ const sources = [
+ {
+ fileId: 'file_789',
+ fileName: 'missing.pdf',
+ relevance: 0.9,
+ type: 'file',
+ },
+ ];
+
+ getFiles.mockResolvedValue([]);
+
+ const result = await enhanceSourcesWithMetadata(sources, mockCustomConfig);
+
+ expect(result[0]).toEqual({
+ fileId: 'file_789',
+ fileName: 'missing.pdf',
+ relevance: 0.9,
+ type: 'file',
+ metadata: {
+ storageType: 'local', // Falls back to customConfig.fileStrategy
+ },
+ });
+ });
+
+ it('should handle database errors gracefully', async () => {
+ const sources = [
+ {
+ fileId: 'file_123',
+ fileName: 'example.pdf',
+ relevance: 0.85,
+ type: 'file',
+ },
+ ];
+
+ getFiles.mockRejectedValue(new Error('Database error'));
+
+ const result = await enhanceSourcesWithMetadata(sources, mockCustomConfig);
+
+ expect(result[0]).toEqual({
+ fileId: 'file_123',
+ fileName: 'example.pdf',
+ relevance: 0.85,
+ type: 'file',
+ metadata: {
+ storageType: 'local',
+ },
+ });
+ });
+
+ it('should deduplicate file IDs when querying database', async () => {
+ const sources = [
+ { fileId: 'file_123', fileName: 'doc1.pdf', relevance: 0.9, type: 'file' },
+ { fileId: 'file_123', fileName: 'doc1.pdf', relevance: 0.8, type: 'file' },
+ { fileId: 'file_456', fileName: 'doc2.pdf', relevance: 0.7, type: 'file' },
+ ];
+
+ getFiles.mockResolvedValue([
+ { file_id: 'file_123', filename: 'document1.pdf', source: 's3' },
+ { file_id: 'file_456', filename: 'document2.pdf', source: 'local' },
+ ]);
+
+ await enhanceSourcesWithMetadata(sources, mockCustomConfig);
+
+ expect(getFiles).toHaveBeenCalledWith({ file_id: { $in: ['file_123', 'file_456'] } });
+ });
+ });
+});
diff --git a/api/typedefs.js b/api/typedefs.js
new file mode 100644
index 0000000..45c6efd
--- /dev/null
+++ b/api/typedefs.js
@@ -0,0 +1,1986 @@
+/**
+ * @namespace typedefs
+ */
+
+/**
+ * @exports OpenAI
+ * @typedef {import('openai').OpenAI} OpenAI
+ * @memberof typedefs
+ */
+/**
+ * @exports OpenAIImagesResponse
+ * @typedef {Promise} OpenAIImagesResponse
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ServerRequest
+ * @typedef {import('express').Request & {
+ * user?: IUser;
+ * config?: AppConfig;
+ * }} ServerRequest
+ * @memberof typedefs
+ */
+
+/**
+ * @template T
+ * @typedef {ReadableStream | NodeJS.ReadableStream} NodeStream
+ * @memberof typedefs
+ */
+
+/**
+ * @template T
+ * @typedef {(req: ServerRequest, filepath: string) => Promise>} NodeStreamDownloader
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ServerResponse
+ * @typedef {import('express').Response} ServerResponse
+ * @memberof typedefs
+ */
+
+/**
+ * @exports NextFunction
+ * @typedef {import('express').NextFunction} NextFunction
+ * @memberof typedefs
+ */
+
+/**
+ * @exports Graph
+ * @typedef {import('@librechat/agents').Graph} Graph
+ * @memberof typedefs
+ */
+
+/**
+ * @exports StandardGraph
+ * @typedef {import('@librechat/agents').StandardGraph} StandardGraph
+ * @memberof typedefs
+ */
+
+/**
+ * @exports MessageContentComplex
+ * @typedef {import('@librechat/agents').MessageContentComplex} MessageContentComplex
+ * @memberof typedefs
+ */
+
+/**
+ * @exports EventHandler
+ * @typedef {import('@librechat/agents').EventHandler} EventHandler
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ModelEndData
+ * @typedef {import('@librechat/agents').ModelEndData} ModelEndData
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ToolEndData
+ * @typedef {import('@librechat/agents').ToolEndData} ToolEndData
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ToolEndCallback
+ * @typedef {import('@librechat/agents').ToolEndCallback} ToolEndCallback
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ChatModelStreamHandler
+ * @typedef {import('@librechat/agents').ChatModelStreamHandler} ChatModelStreamHandler
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ContentAggregator
+ * @typedef {import('@librechat/agents').ContentAggregatorResult['aggregateContent']} ContentAggregator
+ * @memberof typedefs
+ */
+
+/**
+ * @exports GraphEvents
+ * @typedef {import('@librechat/agents').GraphEvents} GraphEvents
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AgentRun
+ * @typedef {import('@librechat/agents').Run} AgentRun
+ * @memberof typedefs
+ */
+
+/**
+ * @exports IState
+ * @typedef {import('@librechat/agents').IState} IState
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ClientCallbacks
+ * @typedef {import('@librechat/agents').ClientCallbacks} ClientCallbacks
+ * @memberof typedefs
+ */
+
+/**
+ * @exports OpenAIClientOptions
+ * @typedef {import('@librechat/agents').OpenAIClientOptions} OpenAIClientOptions
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AnthropicClientOptions
+ * @typedef {import('@librechat/agents').AnthropicClientOptions} AnthropicClientOptions
+ * @memberof typedefs
+ */
+
+/**
+ * @exports BedrockClientOptions
+ * @typedef {import('@librechat/agents').BedrockConverseClientOptions} BedrockClientOptions
+ * @memberof typedefs
+ */
+
+/**
+ * @exports VertexAIClientOptions
+ * @typedef {import('@librechat/agents').VertexAIClientOptions} VertexAIClientOptions
+ * @memberof typedefs
+ */
+
+/**
+ * @exports GoogleClientOptions
+ * @typedef {import('@librechat/agents').GoogleClientOptions} GoogleClientOptions
+ * @memberof typedefs
+ */
+
+/**
+ * @exports StreamEventData
+ * @typedef {import('@librechat/agents').StreamEventData} StreamEventData
+ * @memberof typedefs
+ */
+
+/**
+ * @exports BaseMessage
+ * @typedef {import('@librechat/agents/langchain/messages').BaseMessage} BaseMessage
+ * @memberof typedefs
+ */
+
+/**
+ * @exports UsageMetadata
+ * @typedef {import('@librechat/agents/langchain/messages').UsageMetadata} UsageMetadata
+ * @memberof typedefs
+ */
+
+/**
+ * @exports LangChainToolCall
+ * @typedef {import('@librechat/agents/langchain/messages/tool').ToolCall} LangChainToolCall
+ * @memberof typedefs
+ */
+
+/**
+ * @exports GraphRunnableConfig
+ * @typedef {import('@librechat/agents/langchain/runnables').RunnableConfig<{
+ * req: ServerRequest;
+ * thread_id: string;
+ * run_id: string;
+ * agent_id: string;
+ * name: string;
+ * agent_index: number;
+ * last_agent_index: number;
+ * hide_sequential_outputs: boolean;
+ * version?: 'v1' | 'v2';
+ * streamMode?: string
+ * }> & {
+ * toolCall?: LangChainToolCall & { stepId?: string };
+ * }} GraphRunnableConfig
+ * @memberof typedefs
+ */
+
+/**
+ * @exports Ollama
+ * @typedef {import('ollama').Ollama} Ollama
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AxiosResponse
+ * @typedef {import('axios').AxiosResponse} AxiosResponse
+ * @memberof typedefs
+ */
+
+/**
+ * @exports Anthropic
+ * @typedef {import('@anthropic-ai/sdk').default} Anthropic
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AnthropicMessage
+ * @typedef {import('@anthropic-ai/sdk').default.MessageParam} AnthropicMessage
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AnthropicMessageStartEvent
+ * @typedef {import('@anthropic-ai/sdk').default.MessageStartEvent} AnthropicMessageStartEvent
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AnthropicMessageDeltaEvent
+ * @typedef {import('@anthropic-ai/sdk').default.MessageDeltaEvent} AnthropicMessageDeltaEvent
+ * @memberof typedefs
+ */
+
+/**
+ * @exports GenerativeModel
+ * @typedef {import('@google/generative-ai').GenerativeModel} GenerativeModel
+ * @memberof typedefs
+ */
+
+/**
+ * @exports GenerateContentRequest
+ * @typedef {import('@google/generative-ai').GenerateContentRequest} GenerateContentRequest
+ * @memberof typedefs
+ */
+
+/**
+ * @exports GenAIUsageMetadata
+ * @typedef {import('@google/generative-ai').UsageMetadata} GenAIUsageMetadata
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AssistantStreamEvent
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent} AssistantStreamEvent
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AssistantStream
+ * @typedef {AsyncIterable} AssistantStream
+ * @memberof typedefs
+ */
+
+/**
+ * @exports RunCreateAndStreamParams
+ * @typedef {import('openai').OpenAI.Beta.Threads.RunCreateAndStreamParams} RunCreateAndStreamParams
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ChatCompletionContentPartImage
+ * @typedef {import('openai').OpenAI.ChatCompletionContentPartImage} ChatCompletionContentPartImage
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ChatCompletion
+ * @typedef {import('openai').OpenAI.ChatCompletion} ChatCompletion
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ChatCompletionPayload
+ * @typedef {import('openai').OpenAI.ChatCompletionCreateParams} ChatCompletionPayload
+ * @memberof typedefs
+ */
+
+/**
+ * @exports OllamaMessage
+ * @typedef {import('ollama').Message} OllamaMessage
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ChatCompletionMessage
+ * @typedef {import('openai').OpenAI.ChatCompletionMessageParam} ChatCompletionMessage
+ * @memberof typedefs
+ */
+
+/**
+ * @exports CohereChatStreamRequest
+ * @typedef {import('cohere-ai').Cohere.ChatStreamRequest} CohereChatStreamRequest
+ * @memberof typedefs
+ */
+
+/**
+ * @exports CohereChatRequest
+ * @typedef {import('cohere-ai').Cohere.ChatRequest} CohereChatRequest
+ * @memberof typedefs
+ */
+
+/**
+ * @exports OpenAIRequestOptions
+ * @typedef {import('openai').OpenAI.RequestOptions} OpenAIRequestOptions
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadCreated
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadCreated} ThreadCreated
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadRunCreated
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadRunCreated} ThreadRunCreated
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadRunQueued
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadRunQueued} ThreadRunQueued
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadRunInProgress
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadRunInProgress} ThreadRunInProgress
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadRunRequiresAction
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadRunRequiresAction} ThreadRunRequiresAction
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadRunCompleted
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadRunCompleted} ThreadRunCompleted
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadRunFailed
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadRunFailed} ThreadRunFailed
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadRunCancelling
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadRunCancelling} ThreadRunCancelling
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadRunCancelled
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadRunCancelled} ThreadRunCancelled
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadRunExpired
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadRunExpired} ThreadRunExpired
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadRunStepCreated
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadRunStepCreated} ThreadRunStepCreated
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadRunStepInProgress
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadRunStepInProgress} ThreadRunStepInProgress
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadRunStepDelta
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadRunStepDelta} ThreadRunStepDelta
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadRunStepCompleted
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadRunStepCompleted} ThreadRunStepCompleted
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadRunStepFailed
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadRunStepFailed} ThreadRunStepFailed
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadRunStepCancelled
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadRunStepCancelled} ThreadRunStepCancelled
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadRunStepExpired
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadRunStepExpired} ThreadRunStepExpired
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadMessageCreated
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadMessageCreated} ThreadMessageCreated
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadMessageInProgress
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadMessageInProgress} ThreadMessageInProgress
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadMessageDelta
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadMessageDelta} ThreadMessageDelta
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadMessageCompleted
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadMessageCompleted} ThreadMessageCompleted
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadMessageIncomplete
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ThreadMessageIncomplete} ThreadMessageIncomplete
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ErrorEvent
+ * @typedef {import('openai').default.Beta.AssistantStreamEvent.ErrorEvent} ErrorEvent
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ToolCallDeltaObject
+ * @typedef {import('openai').default.Beta.Threads.Runs.Steps.ToolCallDeltaObject} ToolCallDeltaObject
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ToolCallDelta
+ * @typedef {import('openai').default.Beta.Threads.Runs.Steps.ToolCallDelta} ToolCallDelta
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AgentToolCallDelta
+ * @typedef {import('librechat-data-provider').Agents.ToolCallDelta} AgentToolCallDelta
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ToolCallChunk
+ * @typedef {import('librechat-data-provider').Agents.ToolCallChunk} ToolCallChunk
+ * @memberof typedefs
+ */
+
+/**
+ * @exports MessageContentImageUrl
+ * @typedef {import('librechat-data-provider').Agents.MessageContentImageUrl} MessageContentImageUrl
+ * @memberof typedefs
+ */
+
+/** Web Search */
+
+/**
+ * @exports SearchResult
+ * @typedef {import('@librechat/agents').SearchResult} SearchResult
+ * @memberof typedefs
+ */
+
+/**
+ * @exports SearchResultData
+ * @typedef {import('@librechat/agents').SearchResultData} SearchResultData
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ValidSource
+ * @typedef {import('librechat-data-provider').ValidSource} ValidSource
+ * @memberof typedefs
+ */
+
+/** Prompts */
+/**
+ * @exports TPrompt
+ * @typedef {import('librechat-data-provider').TPrompt} TPrompt
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TPromptGroup
+ * @typedef {import('librechat-data-provider').TPromptGroup} TPromptGroup
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TCreatePrompt
+ * @typedef {import('librechat-data-provider').TCreatePrompt} TCreatePrompt
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TCreatePromptRecord
+ * @typedef {import('librechat-data-provider').TCreatePromptRecord} TCreatePromptRecord
+ * @memberof typedefs
+ */
+/**
+ * @exports TCreatePromptResponse
+ * @typedef {import('librechat-data-provider').TCreatePromptResponse} TCreatePromptResponse
+ * @memberof typedefs
+ */
+/**
+ * @exports TUpdatePromptGroupResponse
+ * @typedef {import('librechat-data-provider').TUpdatePromptGroupResponse} TUpdatePromptGroupResponse
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TPromptGroupsWithFilterRequest
+ * @typedef {import('librechat-data-provider').TPromptGroupsWithFilterRequest } TPromptGroupsWithFilterRequest
+ * @memberof typedefs
+ */
+
+/**
+ * @exports PromptGroupListResponse
+ * @typedef {import('librechat-data-provider').PromptGroupListResponse } PromptGroupListResponse
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TGetCategoriesResponse
+ * @typedef {import('librechat-data-provider').TGetCategoriesResponse } TGetCategoriesResponse
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TGetRandomPromptsResponse
+ * @typedef {import('librechat-data-provider').TGetRandomPromptsResponse } TGetRandomPromptsResponse
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TGetRandomPromptsRequest
+ * @typedef {import('librechat-data-provider').TGetRandomPromptsRequest } TGetRandomPromptsRequest
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TUpdatePromptGroupPayload
+ * @typedef {import('librechat-data-provider').TUpdatePromptGroupPayload } TUpdatePromptGroupPayload
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TDeletePromptVariables
+ * @typedef {import('librechat-data-provider').TDeletePromptVariables } TDeletePromptVariables
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TDeletePromptResponse
+ * @typedef {import('librechat-data-provider').TDeletePromptResponse } TDeletePromptResponse
+ * @memberof typedefs
+ */
+
+/* Roles */
+
+/**
+ * @exports TRole
+ * @typedef {import('librechat-data-provider').TRole } TRole
+ * @memberof typedefs
+ */
+
+/**
+ * @exports PermissionTypes
+ * @typedef {import('librechat-data-provider').PermissionTypes } PermissionTypes
+ * @memberof typedefs
+ */
+
+/**
+ * @exports Permissions
+ * @typedef {import('librechat-data-provider').Permissions } Permissions
+ * @memberof typedefs
+ */
+
+/** Assistants */
+/**
+ * @exports Assistant
+ * @typedef {import('librechat-data-provider').Assistant} Assistant
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AssistantDocument
+ * @typedef {import('librechat-data-provider').AssistantDocument} AssistantDocument
+ * @memberof typedefs
+ */
+
+/**
+ * @exports OpenAIFile
+ * @typedef {import('librechat-data-provider').File} OpenAIFile
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TConfig
+ * @typedef {import('librechat-data-provider').TConfig} TConfig
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TPayload
+ * @typedef {import('librechat-data-provider').TPayload} TPayload
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TAzureModelConfig
+ * @typedef {import('librechat-data-provider').TAzureModelConfig} TAzureModelConfig
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TAzureGroup
+ * @typedef {import('librechat-data-provider').TAzureGroup} TAzureGroup
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TAzureGroups
+ * @typedef {import('librechat-data-provider').TAzureGroups} TAzureGroups
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TAzureModelGroupMap
+ * @typedef {import('librechat-data-provider').TAzureModelGroupMap} TAzureModelGroupMap
+ * @memberof typedefs
+ */
+/**
+ * @exports TAzureGroupMap
+ * @typedef {import('librechat-data-provider').TAzureGroupMap} TAzureGroupMap
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TAzureConfig
+ * @typedef {import('librechat-data-provider').TAzureConfig} TAzureConfig
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TModelsConfig
+ * @typedef {import('librechat-data-provider').TModelsConfig} TModelsConfig
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TStartupConfig
+ * @typedef {import('librechat-data-provider').TStartupConfig} TStartupConfig
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TConfigDefaults
+ * @typedef {import('librechat-data-provider').TConfigDefaults} TConfigDefaults
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TPlugin
+ * @typedef {import('librechat-data-provider').TPlugin} TPlugin
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TAzureConfigValidationResult
+ * @typedef {import('librechat-data-provider').TAzureConfigValidationResult} TAzureConfigValidationResult
+ * @memberof typedefs
+ */
+
+/**
+ * @exports EImageOutputType
+ * @typedef {import('librechat-data-provider').EImageOutputType} EImageOutputType
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TCustomConfig
+ * @typedef {import('librechat-data-provider').TCustomConfig} TCustomConfig
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TProviderSchema
+ * @typedef {import('librechat-data-provider').TProviderSchema} TProviderSchema
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TBaseEndpoint
+ * @typedef {import('librechat-data-provider').TBaseEndpoint} TBaseEndpoint
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TEndpoint
+ * @typedef {import('librechat-data-provider').TEndpoint} TEndpoint
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TEndpointsConfig
+ * @typedef {import('librechat-data-provider').TEndpointsConfig} TEndpointsConfig
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TMessage
+ * @typedef {import('librechat-data-provider').TMessage} TMessage
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TConversation
+ * @typedef {import('librechat-data-provider').TConversation} TConversation
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TModelSpec
+ * @typedef {import('librechat-data-provider').TModelSpec} TModelSpec
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TPlugin
+ * @typedef {import('librechat-data-provider').TPlugin} TPlugin
+ * @memberof typedefs
+ */
+
+/**
+ * @exports FileSources
+ * @typedef {import('librechat-data-provider').FileSources} FileSources
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TMessage
+ * @typedef {import('librechat-data-provider').TMessage} TMessage
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ImageFile
+ * @typedef {import('librechat-data-provider').ImageFile} ImageFile
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TMessageContentParts
+ * @typedef {import('librechat-data-provider').TMessageContentParts} TMessageContentParts
+ * @memberof typedefs
+ */
+
+/**
+ * @exports StreamContentData
+ * @typedef {import('librechat-data-provider').StreamContentData} StreamContentData
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ActionRequest
+ * @typedef {import('librechat-data-provider').ActionRequest} ActionRequest
+ * @memberof typedefs
+ */
+
+/**
+ * @exports Action
+ * @typedef {import('librechat-data-provider').Action} Action
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ActionMetadata
+ * @typedef {import('librechat-data-provider').ActionMetadata} ActionMetadata
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ActionAuth
+ * @typedef {import('librechat-data-provider').ActionAuth} ActionAuth
+ * @memberof typedefs
+ */
+
+/**
+ * @exports DeleteFilesBody
+ * @typedef {import('librechat-data-provider').DeleteFilesBody} DeleteFilesBody
+ * @memberof typedefs
+ */
+
+/**
+ * @exports FileMetadata
+ * @typedef {Object} FileMetadata
+ * @property {string} file_id - The identifier of the file.
+ * @property {string} [temp_file_id] - The temporary identifier of the file.
+ * @property {string} endpoint - The conversation endpoint origin for the file upload.
+ * @property {string} [assistant_id] - The assistant ID if file upload is in the `knowledge` context.
+ * @property {string} [tool_resource] - The relevant tool resource for the file upload.
+ * @memberof typedefs
+ */
+
+/**
+ * @exports FileObject
+ * @typedef {{file_id: string, filepath: string, source: string, bytes?: number, width?: number, height?: number}} FileObject
+ * @memberof typedefs
+ *
+
+/**
+ * @exports ArtifactPromises
+ * @typedef {Promise[]} ArtifactPromises
+ * @memberof typedefs
+ *
+
+/**
+ * @typedef {Object} ImageOnlyMetadata
+ * @property {number} width - The width of the image.
+ * @property {number} height - The height of the image.
+ *
+ * @typedef {FileMetadata & ImageOnlyMetadata} ImageMetadata
+ * @memberof typedefs
+ */
+
+/**
+ * @exports MongooseSchema
+ * @typedef {import('mongoose').Schema} MongooseSchema
+ * @memberof typedefs
+ */
+
+/**
+ * @exports MongoFile
+ * @typedef {import('@librechat/data-schemas').IMongoFile} MongoFile
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ISession
+ * @typedef {import('@librechat/data-schemas').ISession} ISession
+ * @memberof typedefs
+ */
+
+/**
+ * @exports IBalance
+ * @typedef {import('@librechat/data-schemas').IBalance} IBalance
+ * @memberof typedefs
+ */
+
+/**
+ * @exports IUser
+ * @typedef {import('@librechat/data-schemas').IUser} IUser
+ * @memberof typedefs
+ */
+
+/**
+ * @exports IPluginAuth
+ * @typedef {import('@librechat/data-schemas').IPluginAuth} IPluginAuth
+ * @memberof typedefs
+ */
+
+/**
+ * @exports IRole
+ * @typedef {import('@librechat/data-schemas').IRole} IRole
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ObjectId
+ * @typedef {import('mongoose').Types.ObjectId} ObjectId
+ * @memberof typedefs
+ */
+
+/**
+ * @exports uploadImageBuffer
+ * @typedef {import('~/server/services/Files/process').uploadImageBuffer} uploadImageBuffer
+ * @memberof typedefs
+ */
+
+/**
+ * @exports processFileURL
+ * @typedef {import('~/server/services/Files/process').processFileURL} processFileURL
+ * @memberof typedefs
+ */
+
+/**
+ *
+ * @typedef {Object} ImageGenOptions
+ * @property {ServerRequest} req - The request object.
+ * @property {boolean} isAgent - Whether the request is from an agent.
+ * @property {processFileURL} processFileURL - The function to process a file URL.
+ * @property {boolean} returnMetadata - Whether to return metadata.
+ * @property {uploadImageBuffer} uploadImageBuffer - The function to upload an image buffer.
+ * @memberof typedefs
+ */
+
+/**
+ * @typedef {Partial & {
+ * message?: string,
+ * signal?: AbortSignal,
+ * memory?: ConversationSummaryBufferMemory,
+ * tool_resources?: AgentToolResources,
+ * web_search?: ReturnType,
+ * }} LoadToolOptions
+ * @memberof typedefs
+ */
+
+/**
+ * @exports EModelEndpoint
+ * @typedef {import('librechat-data-provider').EModelEndpoint} EModelEndpoint
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TEndpointOption
+ * @typedef {import('librechat-data-provider').TEndpointOption} TEndpointOption
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TAttachment
+ * @typedef {import('librechat-data-provider').TAttachment} TAttachment
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AssistantCreateParams
+ * @typedef {import('librechat-data-provider').AssistantCreateParams} AssistantCreateParams
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AssistantUpdateParams
+ * @typedef {import('librechat-data-provider').AssistantUpdateParams} AssistantUpdateParams
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AssistantListParams
+ * @typedef {import('librechat-data-provider').AssistantListParams} AssistantListParams
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AssistantListResponse
+ * @typedef {import('librechat-data-provider').AssistantListResponse} AssistantListResponse
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ContentPart
+ * @typedef {import('librechat-data-provider').ContentPart} ContentPart
+ * @memberof typedefs
+ */
+
+/**
+ * @exports StepTypes
+ * @typedef {import('librechat-data-provider').StepTypes} StepTypes
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TContentData
+ * @typedef {import('librechat-data-provider').TContentData} TContentData
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ContentPart
+ * @typedef {import('librechat-data-provider').ContentPart} ContentPart
+ * @memberof typedefs
+ */
+
+/**
+ * @exports PartMetadata
+ * @typedef {import('librechat-data-provider').PartMetadata} PartMetadata
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ThreadMessage
+ * @typedef {import('openai').OpenAI.Beta.Threads.Message} ThreadMessage
+ * @memberof typedefs
+ */
+
+/**
+ * @exports Annotation
+ * @typedef {import('openai').OpenAI.Beta.Threads.Messages.Annotation} Annotation
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TAssistantEndpoint
+ * @typedef {import('librechat-data-provider').TAssistantEndpoint} TAssistantEndpoint
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TAgentsEndpoint
+ * @typedef {import('librechat-data-provider').TAgentsEndpoint} TAgentsEndpoint
+ * @memberof typedefs
+ */
+
+/**
+ * @exports Agent
+ * @typedef {import('librechat-data-provider').Agent} Agent
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TEphemeralAgent
+ * @typedef {import('librechat-data-provider').TEphemeralAgent} TEphemeralAgent
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TWebSearchKeys
+ * @typedef {import('@librechat/data-schemas').TWebSearchKeys} TWebSearchKeys
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AgentToolResources
+ * @typedef {import('librechat-data-provider').AgentToolResources} AgentToolResources
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AgentCreateParams
+ * @typedef {import('librechat-data-provider').AgentCreateParams} AgentCreateParams
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AgentUpdateParams
+ * @typedef {import('librechat-data-provider').AgentUpdateParams} AgentUpdateParams
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AgentListParams
+ * @typedef {import('librechat-data-provider').AgentListParams} AgentListParams
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AgentListResponse
+ * @typedef {import('librechat-data-provider').AgentListResponse} AgentListResponse
+ * @memberof typedefs
+ */
+
+/** Permissions */
+/**
+ * @exports TUpdateResourcePermissionsRequest
+ * @typedef {import('librechat-data-provider').TUpdateResourcePermissionsRequest} TUpdateResourcePermissionsRequest
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TUpdateResourcePermissionsResponse
+ * @typedef {import('librechat-data-provider').TUpdateResourcePermissionsResponse} TUpdateResourcePermissionsResponse
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AppConfig
+ * @typedef {import('@librechat/data-schemas').AppConfig} AppConfig
+ * @memberof typedefs
+ */
+
+/**
+ * @exports JsonSchemaType
+ * @typedef {import('@librechat/data-schemas').JsonSchemaType} JsonSchemaType
+ * @memberof typedefs
+ */
+
+/**
+ * @exports MCPServers
+ * @typedef {import('@librechat/api').MCPServers} MCPServers
+ * @memberof typedefs
+ */
+
+/**
+ * @exports Keyv
+ * @typedef {import('keyv')} Keyv
+ * @memberof typedefs
+ */
+
+/**
+ * @exports MCPManager
+ * @typedef {import('@librechat/api').MCPManager} MCPManager
+ * @memberof typedefs
+ */
+
+/**
+ * @exports MCPConnection
+ * @typedef {import('@librechat/api').MCPConnection} MCPConnection
+ * @memberof typedefs
+ */
+
+/**
+ * @exports LCFunctionTool
+ * @typedef {import('@librechat/api').LCFunctionTool} LCFunctionTool
+ * @memberof typedefs
+ */
+
+/**
+ * @exports FlowStateManager
+ * @typedef {import('@librechat/api').FlowStateManager} FlowStateManager
+ * @memberof typedefs
+ */
+
+/**
+ * @exports LCAvailableTools
+ * @typedef {import('@librechat/api').LCAvailableTools} LCAvailableTools
+ * @memberof typedefs
+ */
+
+/**
+ * @exports LCTool
+ * @typedef {import('@librechat/api').LCTool} LCTool
+ * @memberof typedefs
+ */
+
+/**
+ * @exports FormattedContent
+ * @typedef {import('@librechat/api').FormattedContent} FormattedContent
+ * @memberof typedefs
+ */
+
+/**
+ * Represents details of the message creation by the run step, including the ID of the created message.
+ *
+ * @exports MessageCreationStepDetails
+ * @typedef {Object} MessageCreationStepDetails
+ * @property {Object} message_creation - Details of the message creation.
+ * @property {string} message_creation.message_id - The ID of the message that was created by this run step.
+ * @property {'message_creation'} type - Always 'message_creation'.
+ * @memberof typedefs
+ */
+
+/**
+ * Represents a text log output from the Code Interpreter tool call.
+ * @typedef {Object} CodeLogOutput
+ * @property {'logs'} type - Always 'logs'.
+ * @property {string} logs - The text output from the Code Interpreter tool call.
+ */
+
+/**
+ * Represents an image output from the Code Interpreter tool call.
+ * @typedef {Object} CodeImageOutput
+ * @property {'image'} type - Always 'image'.
+ * @property {Object} image - The image object.
+ * @property {string} image.file_id - The file ID of the image.
+ */
+
+/**
+ * Details of the Code Interpreter tool call the run step was involved in.
+ * Includes the tool call ID, the code interpreter definition, and the type of tool call.
+ *
+ * @typedef {Object} CodeToolCall
+ * @property {string} id - The ID of the tool call.
+ * @property {Object} code_interpreter - The Code Interpreter tool call definition.
+ * @property {string} code_interpreter.input - The input to the Code Interpreter tool call.
+ * @property {Array<(CodeLogOutput | CodeImageOutput)>} code_interpreter.outputs - The outputs from the Code Interpreter tool call.
+ * @property {'code_interpreter'} type - The type of tool call, always 'code_interpreter'.
+ * @memberof typedefs
+ */
+
+/**
+ * Details of a Function tool call the run step was involved in.
+ * Includes the tool call ID, the function definition, and the type of tool call.
+ *
+ * @typedef {Object} FunctionToolCall
+ * @property {string} id - The ID of the tool call object.
+ * @property {Object} function - The definition of the function that was called.
+ * @property {string} function.arguments - The arguments passed to the function.
+ * @property {string} function.name - The name of the function.
+ * @property {string|null} function.output - The output of the function, null if not submitted.
+ * @property {'function'} type - The type of tool call, always 'function'.
+ * @memberof typedefs
+ */
+
+/**
+ * Details of a Retrieval tool call the run step was involved in.
+ * Includes the tool call ID and the type of tool call.
+ *
+ * @typedef {Object} RetrievalToolCall
+ * @property {string} id - The ID of the tool call object.
+ * @property {unknown} retrieval - An empty object for now.
+ * @property {'retrieval'} type - The type of tool call, always 'retrieval'.
+ * @memberof typedefs
+ */
+
+/**
+ * Details of the tool calls involved in a run step.
+ * Can be associated with one of three types of tools: `code_interpreter`, `retrieval`, or `function`.
+ *
+ * @typedef {Object} ToolCallsStepDetails
+ * @property {Array} tool_calls - An array of tool calls the run step was involved in.
+ * @property {'tool_calls'} type - Always 'tool_calls'.
+ * @memberof typedefs
+ */
+
+/**
+ * Details of the tool calls involved in a run step.
+ * Can be associated with one of three types of tools: `code_interpreter`, `retrieval`, or `function`.
+ *
+ * @exports StepToolCall
+ * @typedef {(CodeToolCall | RetrievalToolCall | FunctionToolCall) & PartMetadata} StepToolCall
+ * @memberof typedefs
+ */
+
+/**
+ * Represents a tool call object required for certain actions in the OpenAI API,
+ * including the function definition and type of the tool call.
+ *
+ * @exports RequiredActionFunctionToolCall
+ * @typedef {Object} RequiredActionFunctionToolCall
+ * @property {string} id - The ID of the tool call, referenced when submitting tool outputs.
+ * @property {Object} function - The function definition associated with the tool call.
+ * @property {string} function.arguments - The arguments that the model expects to be passed to the function.
+ * @property {string} function.name - The name of the function.
+ * @property {'function'} type - The type of tool call the output is required for, currently always 'function'.
+ * @memberof typedefs
+ */
+
+/**
+ * @exports RunManager
+ * @typedef {import('./server/services/Runs/RunManager.js').RunManager} RunManager
+ * @memberof typedefs
+ */
+
+/**
+ * @exports TAgentClient
+ * @typedef {import('./server/controllers/agents/client')} TAgentClient
+ * @memberof typedefs
+ */
+
+/**
+ * @typedef {Object} AgentClientOptions
+ * @property {Agent} agent - The agent configuration object
+ * @property {string} endpoint - The endpoint identifier for the agent
+ * @property {ServerRequest} req - The request object
+ * @property {string} [name] - The username
+ * @property {string} [modelLabel] - The label for the model being used
+ * @property {number} [maxContextTokens] - Maximum number of tokens allowed in context
+ * @property {Object} [endpointTokenConfig] - Token configuration for the endpoint
+ * @property {boolean} [resendFiles] - Whether to resend files
+ * @property {string} [imageDetail] - Detail level for image processing
+ * @property {Object} [spec] - Specification object
+ * @property {Promise} [attachments] - Promise resolving to file attachments
+ * @property {Object} [headers] - Additional headers for requests
+ * @property {string} [proxy] - Proxy configuration
+ * @property {Object} [tools] - Available tools for the agent
+ * @property {Object} [eventHandlers] - Custom event handlers
+ * @property {Object} [addParams] - Additional parameters to add to requests
+ * @property {string[]} [dropParams] - Parameters to remove from requests
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ImportBatchBuilder
+ * @typedef {import('./server/utils/import/importBatchBuilder.js').ImportBatchBuilder} ImportBatchBuilder
+ * @memberof typedefs
+ */
+
+/**
+ * @exports Thread
+ * @typedef {Object} Thread
+ * @property {string} id - The identifier of the thread.
+ * @property {'thread'} object - The object type, always 'thread'.
+ * @property {number} created_at - The Unix timestamp (in seconds) for when the thread was created.
+ * @property {Object} [metadata] - Optional metadata associated with the thread.
+ * @property {Message[]} [messages] - An array of messages associated with the thread.
+ * @memberof typedefs
+ */
+
+/**
+ * @exports Message
+ * @typedef {Object} Message
+ * @property {string} id - The identifier of the message.
+ * @property {'thread.message'} object - The object type, always 'thread.message'.
+ * @property {number} created_at - The Unix timestamp (in seconds) for when the message was created.
+ * @property {string} thread_id - The thread ID that this message belongs to.
+ * @property {'user'|'assistant'} role - The entity that produced the message. One of 'user' or 'assistant'.
+ * @property {Object[]} content - The content of the message in an array of text and/or images.
+ * @property {'text'|'image_file'} content[].type - The type of content, either 'text' or 'image_file'.
+ * @property {Object} [content[].text] - The text content, present if type is 'text'.
+ * @property {string} content[].text.value - The data that makes up the text.
+ * @property {Object[]} [content[].text.annotations] - Annotations for the text content.
+ * @property {Object} [content[].image_file] - The image file content, present if type is 'image_file'.
+ * @property {string} content[].image_file.file_id - The File ID of the image in the message content.
+ * @property {string[]} [file_ids] - Optional list of File IDs for the message.
+ * @property {string|null} [assistant_id] - If applicable, the ID of the assistant that authored this message.
+ * @property {string|null} [run_id] - If applicable, the ID of the run associated with the authoring of this message.
+ * @property {Object} [metadata] - Optional metadata for the message, a map of key-value pairs.
+ * @memberof typedefs
+ */
+
+/**
+ * @exports UserMessageContent
+ * @typedef {Object} UserMessageContent
+ * @property {Object[]} content - The content of the message in an array of text and/or images.
+ * @property {string} content[].type - The type of content, either 'text' or 'image_file'.
+ * @property {Object} [content[].text] - The text content, present if type is 'text'.
+ * @property {string} content[].text.value - The data that makes up the text.
+ * @property {Object} [content[].image_url] - The image file content, present if type is 'image_file'.
+ * @property {string} content[].image_url.url - The File ID of the image in the message content.
+ * @property {'auto' | 'low' | 'high'} content[].image_url.detail: 'auto' - the quality to use for the image, either 'auto', 'low', or 'high'.
+ * @memberof typedefs
+ */
+
+/**
+ * Represents a message payload with various potential properties,
+ * including roles, sender information, and content.
+ *
+ * @typedef {Object} PayloadMessage
+ * @property {string} [role] - The role of the message sender (e.g., 'user', 'assistant').
+ * @property {string} [name] - The name associated with the message.
+ * @property {string} [sender] - The sender of the message.
+ * @property {string} [text] - The text content of the message.
+ * @property {(string|Array)} [content] - The content of the message, which could be a string or an array of the 'content' property from the Message type.
+ * @memberof typedefs
+ */
+
+/**
+ * @exports FunctionTool
+ * @typedef {import('@librechat/data-schemas').FunctionTool} FunctionTool
+ * @memberof typedefs
+ */
+
+/**
+ * @exports Tool
+ * @typedef {Object} Tool
+ * @property {'code_interpreter'|'retrieval'|'function'} type - The type of tool, can be 'code_interpreter', 'retrieval', or 'function'.
+ * @property {FunctionTool} [function] - The function tool, present if type is 'function'.
+ * @memberof typedefs
+ */
+
+/**
+ * @exports Run
+ * @typedef {Object} Run
+ * @property {string} id - The identifier of the run.
+ * @property {string} object - The object type, always 'thread.run'.
+ * @property {number} created_at - The Unix timestamp (in seconds) for when the run was created.
+ * @property {string} thread_id - The ID of the thread that was executed on as a part of this run.
+ * @property {string} assistant_id - The ID of the assistant used for execution of this run.
+ * @property {'queued'|'in_progress'|'requires_action'|'cancelling'|'cancelled'|'failed'|'completed'|'expired'} status - The status of the run: queued, in_progress, requires_action, cancelling, cancelled, failed, completed, or expired.
+ * @property {Object} [required_action] - Details on the action required to continue the run.
+ * @property {string} required_action.type - The type of required action, always 'submit_tool_outputs'.
+ * @property {Object} required_action.submit_tool_outputs - Details on the tool outputs needed for the run to continue.
+ * @property {Object[]} required_action.submit_tool_outputs.tool_calls - A list of the relevant tool calls.
+ * @property {string} required_action.submit_tool_outputs.tool_calls[].id - The ID of the tool call.
+ * @property {string} required_action.submit_tool_outputs.tool_calls[].type - The type of tool call the output is required for, always 'function'.
+ * @property {Object} required_action.submit_tool_outputs.tool_calls[].function - The function definition.
+ * @property {string} required_action.submit_tool_outputs.tool_calls[].function.name - The name of the function.
+ * @property {string} required_action.submit_tool_outputs.tool_calls[].function.arguments - The arguments that the model expects you to pass to the function.
+ * @property {Object} [last_error] - The last error associated with this run.
+ * @property {string} last_error.code - One of 'server_error' or 'rate_limit_exceeded'.
+ * @property {string} last_error.message - A human-readable description of the error.
+ * @property {number} [expires_at] - The Unix timestamp (in seconds) for when the run will expire.
+ * @property {number} [started_at] - The Unix timestamp (in seconds) for when the run was started.
+ * @property {number} [cancelled_at] - The Unix timestamp (in seconds) for when the run was cancelled.
+ * @property {number} [failed_at] - The Unix timestamp (in seconds) for when the run failed.
+ * @property {number} [completed_at] - The Unix timestamp (in seconds) for when the run was completed.
+ * @property {string} [model] - The model that the assistant used for this run.
+ * @property {string} [instructions] - The instructions that the assistant used for this run.
+ * @property {string} [additional_instructions] - Optional. Appends additional instructions
+ * at the end of the instructions for the run. This is useful for modifying
+ * @property {Tool[]} [tools] - The list of tools used for this run.
+ * @property {string[]} [file_ids] - The list of File IDs used for this run.
+ * @property {Object} [metadata] - Metadata associated with this run.
+ * @property {Object} [usage] - Usage statistics related to the run. This value will be `null` if the run is not in a terminal state (i.e. `in_progress`, `queued`, etc.).
+ * @property {number} [usage.completion_tokens] - Number of completion tokens used over the course of the run.
+ * @property {number} [usage.prompt_tokens] - Number of prompt tokens used over the course of the run.
+ * @property {number} [usage.total_tokens] - Total number of tokens used (prompt + completion).
+ * @memberof typedefs
+ */
+
+/**
+ * @exports RunStep
+ * @typedef {Object} RunStep
+ * @property {string} id - The identifier of the run step.
+ * @property {string} object - The object type, always 'thread.run.step'.
+ * @property {number} created_at - The Unix timestamp (in seconds) for when the run step was created.
+ * @property {string} assistant_id - The ID of the assistant associated with the run step.
+ * @property {string} thread_id - The ID of the thread that was run.
+ * @property {string} run_id - The ID of the run that this run step is a part of.
+ * @property {'message_creation' | 'tool_calls'} type - The type of run step.
+ * @property {'in_progress' | 'cancelled' | 'failed' | 'completed' | 'expired'} status - The status of the run step.
+ * @property {MessageCreationStepDetails | ToolCallsStepDetails} step_details - The details of the run step.
+ * @property {Object} [last_error] - The last error associated with this run step.
+ * @property {'server_error' | 'rate_limit_exceeded'} last_error.code - One of 'server_error' or 'rate_limit_exceeded'.
+ * @property {string} last_error.message - A human-readable description of the error.
+ * @property {number} [expired_at] - The Unix timestamp (in seconds) for when the run step expired.
+ * @property {number} [cancelled_at] - The Unix timestamp (in seconds) for when the run step was cancelled.
+ * @property {number} [failed_at] - The Unix timestamp (in seconds) for when the run step failed.
+ * @property {number} [completed_at] - The Unix timestamp (in seconds) for when the run step completed.
+ * @property {Object} [metadata] - Metadata associated with this run step, a map of up to 16 key-value pairs.
+ * @memberof typedefs
+ */
+
+/**
+ * @exports StepMessage
+ * @typedef {Object} StepMessage
+ * @property {Message} message - The complete message object created by the step.
+ * @property {string} id - The identifier of the run step.
+ * @property {string} object - The object type, always 'thread.run.step'.
+ * @property {number} created_at - The Unix timestamp (in seconds) for when the run step was created.
+ * @property {string} assistant_id - The ID of the assistant associated with the run step.
+ * @property {string} thread_id - The ID of the thread that was run.
+ * @property {string} run_id - The ID of the run that this run step is a part of.
+ * @property {'message_creation'|'tool_calls'} type - The type of run step, either 'message_creation' or 'tool_calls'.
+ * @property {'in_progress'|'cancelled'|'failed'|'completed'|'expired'} status - The status of the run step, can be 'in_progress', 'cancelled', 'failed', 'completed', or 'expired'.
+ * @property {Object} step_details - The details of the run step.
+ * @property {Object} [last_error] - The last error associated with this run step.
+ * @property {string} last_error.code - One of 'server_error' or 'rate_limit_exceeded'.
+ * @property {string} last_error.message - A human-readable description of the error.
+ * @property {number} [expired_at] - The Unix timestamp (in seconds) for when the run step expired.
+ * @property {number} [cancelled_at] - The Unix timestamp (in seconds) for when the run step was cancelled.
+ * @property {number} [failed_at] - The Unix timestamp (in seconds) for when the run step failed.
+ * @property {number} [completed_at] - The Unix timestamp (in seconds) for when the run step completed.
+ * @property {Object} [metadata] - Metadata associated with this run step, a map of up to 16 key-value pairs.
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AgentAction
+ * @typedef {Object} AgentAction
+ * @property {string} tool - The name of the tool used.
+ * @property {string} toolInput - The input provided to the tool.
+ * @property {string} log - A log or message associated with the action.
+ * @memberof typedefs
+ */
+
+/**
+ * @exports AgentFinish
+ * @typedef {Object} AgentFinish
+ * @property {Record} returnValues - The return values of the agent's execution.
+ * @property {string} log - A log or message associated with the finish.
+ * @memberof typedefs
+ */
+
+/**
+ * @exports OpenAIAssistantFinish
+ * @typedef {AgentFinish & { run_id: string; thread_id: string; }} OpenAIAssistantFinish
+ * @memberof typedefs
+ */
+
+/**
+ * @exports OpenAIAssistantAction
+ * @typedef {AgentAction & { toolCallId: string; run_id: string; thread_id: string; }} OpenAIAssistantAction
+ * @memberof typedefs
+ */
+
+/**
+ * @exports EndpointServiceConfig
+ * @typedef {Object} EndpointServiceConfig
+ * @property {string} openAIApiKey - The API key for OpenAI.
+ * @property {string} azureOpenAIApiKey - The API key for Azure OpenAI.
+ * @property {boolean} userProvidedOpenAI - Flag to indicate if OpenAI API key is user provided.
+ * @property {string} googleKey - The Palm key.
+ * @property {boolean|{userProvide: boolean}} [openAI] - Flag to indicate if OpenAI endpoint is user provided, or its configuration.
+ * @property {boolean|{userProvide: boolean}} [assistant] - Flag to indicate if Assistant endpoint is user provided, or its configuration.
+ * @property {boolean|{userProvide: boolean}} [azureOpenAI] - Flag to indicate if Azure OpenAI endpoint is user provided, or its configuration.
+ * @property {boolean|{userProvide: boolean}} [anthropic] - Flag to indicate if Anthropic endpoint is user provided, or its configuration.
+ * @property {boolean|{userProvide: boolean}} [google] - Flag to indicate if Google endpoint is user provided, or its configuration.
+ * @property {boolean|{userProvide: boolean, userProvideURL: boolean, name: string}} [custom] - Custom Endpoint configuration.
+ * @memberof typedefs
+ */
+
+/**
+ * @exports Plugin
+ * @typedef {Object} Plugin
+ * @property {string} pluginKey - The key of the plugin.
+ * @property {string} name - The name of the plugin.
+ * @memberof typedefs
+ */
+
+/**
+ * @exports DefaultConfig
+ * @typedef {Object} DefaultConfig
+ * @property {boolean|{userProvide: boolean}} [openAI] - Flag to indicate if OpenAI endpoint is user provided, or its configuration.
+ * @property {boolean|{userProvide: boolean}} [assistant] - Flag to indicate if Assistant endpoint is user provided, or its configuration.
+ * @property {boolean|{userProvide: boolean}} [azureOpenAI] - Flag to indicate if Azure OpenAI endpoint is user provided, or its configuration.
+ * @property {boolean|{userProvide: boolean}} [anthropic] - Flag to indicate if Anthropic endpoint is user provided, or its configuration.
+ * @property {boolean|{userProvide: boolean}} [google] - Flag to indicate if Google endpoint is user provided, or its configuration.
+ * @property {boolean|{userProvide: boolean, userProvideURL: boolean, name: string}} [custom] - Custom Endpoint configuration.
+ * @memberof typedefs
+ */
+
+/**
+ * @exports EndpointConfig
+ * @typedef {boolean|TConfig} EndpointConfig
+ * @memberof typedefs
+ */
+
+/**
+ * @exports EndpointWithOrder
+ * @typedef {Object} EndpointWithOrder
+ * @property {EndpointConfig} config - The configuration of the endpoint.
+ * @property {number} order - The order of the endpoint.
+ * @memberof typedefs
+ */
+
+/**
+ * @exports RequiredAction
+ * @typedef {Object} RequiredAction
+ * @property {string} tool - The name of the function.
+ * @property {Object} toolInput - The args to invoke the function with.
+ * @property {string} toolCallId - The ID of the tool call.
+ * @property {Run['id']} run_id - Run identifier.
+ * @property {Thread['id']} thread_id - Thread identifier.
+ * @memberof typedefs
+ */
+
+/**
+ * @exports StructuredTool
+ * @typedef {Object} StructuredTool
+ * @property {string} name - The name of the function.
+ * @property {string} description - The description of the function.
+ * @property {import('zod').ZodTypeAny} schema - The structured zod schema.
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ToolOutput
+ * @typedef {Object} ToolOutput
+ * @property {string} tool_call_id - The ID of the tool call.
+ * @property {Object} output - The output of the tool, which can vary in structure.
+ * @memberof typedefs
+ */
+
+/**
+ * @exports ToolOutputs
+ * @typedef {Object} ToolOutputs
+ * @property {ToolOutput[]} tool_outputs - Array of tool outputs.
+ * @memberof typedefs
+ */
+
+/**
+ * @typedef {Object} ModelOptions
+ * @property {string} modelName - The name of the model.
+ * @property {number} [temperature] - The temperature setting for the model.
+ * @property {number} [presence_penalty] - The presence penalty setting.
+ * @property {number} [frequency_penalty] - The frequency penalty setting.
+ * @property {number} [max_tokens] - The maximum number of tokens to generate.
+ * @memberof typedefs
+ */
+
+/**
+ * @typedef {Object} ConfigOptions
+ * @property {string} [basePath] - The base path for the API requests.
+ * @property {Object} [baseOptions] - Base options for the API requests, including headers.
+ * @property {Object} [httpAgent] - The HTTP agent for the request.
+ * @property {Object} [httpsAgent] - The HTTPS agent for the request.
+ * @memberof typedefs
+ */
+
+/**
+ * @typedef {Object} Callbacks
+ * @property {Function} [handleChatModelStart] - A callback function for handleChatModelStart
+ * @property {Function} [handleLLMEnd] - A callback function for handleLLMEnd
+ * @property {Function} [handleLLMError] - A callback function for handleLLMError
+ * @memberof typedefs
+ */
+
+/**
+ * @typedef {Object} AzureOptions
+ * @property {string} [azureOpenAIApiKey] - The Azure OpenAI API key.
+ * @property {string} [azureOpenAIApiInstanceName] - The Azure OpenAI API instance name.
+ * @property {string} [azureOpenAIApiDeploymentName] - The Azure OpenAI API deployment name.
+ * @property {string} [azureOpenAIApiVersion] - The Azure OpenAI API version.
+ * @memberof typedefs
+ */
+
+/**
+ * @typedef {Object} TokenConfig
+ * A configuration object mapping model keys to their respective prompt, completion rates, and context limit.
+ * @property {number} prompt - The prompt rate
+ * @property {number} completion - The completion rate
+ * @property {number} context - The maximum context length supported by the model.
+ * @memberof typedefs
+ */
+
+/**
+ * @typedef {Record} EndpointTokenConfig
+ * An endpoint's config object mapping model keys to their respective prompt, completion rates, and context limit.
+ * @memberof typedefs
+ */
+
+/**
+ * @typedef {Object} ResponseMessage
+ * @property {string} conversationId - The ID of the conversation.
+ * @property {string} thread_id - The ID of the thread.
+ * @property {string} messageId - The ID of the message (from LibreChat).
+ * @property {string} parentMessageId - The ID of the parent message.
+ * @property {string} user - The ID of the user.
+ * @property {string} assistant_id - The ID of the assistant.
+ * @property {string} role - The role of the response.
+ * @property {string} model - The model used in the response.
+ * @property {ContentPart[]} content - The content parts accumulated from the run.
+ * @memberof typedefs
+ */
+
+/**
+ * @typedef {Object} RunResponse
+ * @property {Run} run - The detailed information about the run.
+ * @property {RunStep[]} steps - An array of steps taken during the run.
+ * @property {StepMessage[]} messages - An array of messages related to the run.
+ * @property {ResponseMessage} finalMessage - The final response message, with all content parts.
+ * @property {string} text - The final response text, accumulated from message parts
+ * @memberof typedefs
+ */
+
+/**
+ * @callback InProgressFunction
+ * @param {Object} params - The parameters for the in progress step.
+ * @param {RunStep} params.step - The step object with details about the message creation.
+ * @returns {Promise} - A promise that resolves when the step is processed.
+ * @memberof typedefs
+ */
+
+/**
+ * @typedef {Object} RunClient
+ *
+ * @property {ServerRequest} req - The Express request object.
+ * @property {Express.Response} res - The Express response object.
+ * @property {?import('https-proxy-agent').HttpsProxyAgent} httpAgent - An optional HTTP proxy agent for the request.
+
+ * @property {() => string} getPartialText - Retrieves the current tokens accumulated by `progressCallback`.
+ *
+ * Note: not used until real streaming is implemented by OpenAI.
+ *
+ * @property {string} responseText -The accumulated text values for the current run.
+ * @property {Set} processedFileIds - A set of IDs for processed files.
+ * @property {Map} mappedOrder - A map to maintain the order of individual `tool_calls` and `steps`.
+ * @property {Set} [attachedFileIds] - A set of user attached file ids; necessary to track which files are downloadable.
+ * @property {Set} completeToolCallSteps - A set of completed tool call steps.
+ * @property {Set} seenCompletedMessages - A set of completed messages that have been seen/processed.
+ * @property {Map} seenToolCalls - A map of tool calls that have been seen/processed.
+ * @property {object | undefined} locals - Local variables for the request.
+ * @property {AzureOptions} locals.azureOptions - Local Azure options for the request.
+ * @property {(data: TContentData) => void} addContentData - Updates the response message's relevant
+ * @property {InProgressFunction} in_progress - Updates the response message's relevant
+ * content array with the part by index & sends intermediate SSE message with content data.
+ *
+ * Note: does not send intermediate SSE message for messages, which are streamed
+ * (may soon be streamed) directly from OpenAI API.
+ *
+ * @property {ResponseMessage} responseMessage - A message object for responses.
+ *
+ * @typedef {OpenAI & RunClient} OpenAIClient
+ */
+
+/**
+ * The body of the request to create a run, specifying the assistant, model,
+ * instructions, and any additional parameters needed for the run.
+ *
+ * @typedef {Object} CreateRunBody
+ * @property {string} assistant_id - The ID of the assistant to use for this run.
+ * @property {string} [model] - Optional. The ID of the model to be used for this run.
+ * @property {string} [instructions] - Optional. Override the default system message of the assistant.
+ * @property {string} [additional_instructions] - Optional. Appends additional instructions
+ * at the end of the instructions for the run. Useful for modifying behavior on a per-run basis without overriding other instructions.
+ * @property {Object[]} [tools] - Optional. Override the tools the assistant can use for this run. Should include tool call ID and the type of tool call.
+ * @property {string[]} [file_ids] - Optional. List of File IDs the assistant can use for this run.
+ * **Note:** The API seems to prefer files added to messages, not runs.
+ * @property {Object} [metadata] - Optional. Metadata for the run.
+ * @memberof typedefs
+ */
+
+/**
+ * @typedef {Object} StreamRunManager
+ * Manages streaming and processing of run steps, messages, and tool calls within a thread.
+ *
+ * @property {number} index - Tracks the current index for step or message processing.
+ * @property {Map} steps - Stores run steps by their IDs.
+ * @property {Map} mappedOrder - Maps step or message IDs to their processing order index.
+ * @property {Map} orderedRunSteps - Stores run steps in order of processing.
+ * @property {Set} processedFileIds - Keeps track of file IDs that have been processed.
+ * @property {Map} progressCallbacks - Stores callbacks for reporting progress on step or message processing.
+ * @property {boolean} submittedToolOutputs - Indicates whether tool outputs have been submitted.
+ * @property {Object|null} run - Holds the current run object.
+ * @property {Object} req - The HTTP request object associated with the run.
+ * @property {Object} res - The HTTP response object for sending back data.
+ * @property {Object} openai - The OpenAI client instance.
+ * @property {string} apiKey - The API key used for OpenAI requests.
+ * @property {string} thread_id - The ID of the thread associated with the run.
+ * @property {Object} initialRunBody - The initial body of the run request.
+ * @property {Object.} clientHandlers - Custom handlers provided by the client.
+ * @property {Object} streamOptions - Options for streaming the run.
+ * @property {Object} finalMessage - The final message object to be constructed and sent.
+ * @property {Array} messages - An array of messages processed during the run.
+ * @property {string} text - Accumulated text from text content data.
+ * @property {Object.} handlers - Internal event handlers for different types of streaming events.
+ *
+ * @method addContentData Adds content data to the final message or sends it immediately depending on type.
+ * @method runAssistant Initializes and manages the streaming of a thread run.
+ * @method handleEvent Dispatches streaming events to the appropriate handlers.
+ * @method handleThreadCreated Handles the event when a thread is created.
+ * @method handleRunEvent Handles various run state events.
+ * @method handleRunStepEvent Handles events related to individual run steps.
+ * @method handleCodeImageOutput Processes and handles code-generated image outputs.
+ * @method createToolCallStream Initializes streaming for tool call outputs.
+ * @method handleNewToolCall Handles the creation of a new tool call within a run step.
+ * @method handleCompletedToolCall Handles the completion of tool call processing.
+ * @method handleRunStepDeltaEvent Handles updates (deltas) for run steps.
+ * @method handleMessageDeltaEvent Handles updates (deltas) for messages.
+ * @method handleErrorEvent Handles error events during streaming.
+ * @method getStepIndex Retrieves or assigns an index for a given step or message key.
+ * @method generateToolCallKey Generates a unique key for a tool call within a step.
+ * @method onRunRequiresAction Handles actions required by a run to proceed.
+ * @method onRunStepCreated Handles the creation of a new run step.
+ * @method onRunStepCompleted Handles the completion of a run step.
+ * @method handleMessageEvent Handles events related to messages within the run.
+ * @method messageCompleted Handles the completion of a message processing.
+ */
+
+/* TX Types */
+
+/**
+ * @typedef {object} txData - Transaction data.
+ * @property {mongoose.Schema.Types.ObjectId} user - The user ID.
+ * @property {String} conversationId - The ID of the conversation.
+ * @property {String} model - The model name.
+ * @property {String} context - The context in which the transaction is made.
+ * @property {AppConfig['balance']} [balance] - The balance config
+ * @property {EndpointTokenConfig} [endpointTokenConfig] - The current endpoint token config.
+ * @property {String} [valueKey] - The value key (optional).
+ * @memberof typedefs
+ */
+
+/**
+ * https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#pricing
+ * @typedef {object} AnthropicStreamUsage - Stream usage for Anthropic
+ * @property {number} [input_tokens] - The number of input tokens used.
+ * @property {number} [cache_creation_input_tokens] - The number of cache creation input tokens used (write).
+ * @property {number} [cache_read_input_tokens] - The number of cache input tokens used (read).
+ * @property {number} [output_tokens] - The number of output tokens used.
+ */
+
+/**
+ * @exports OpenAIUsageMetadata
+ * @typedef {Object} OpenAIUsageMetadata - Usage statistics related to the run. This value will be `null` if the run is not in a terminal state (i.e. `in_progress`, `queued`, etc.).
+ * @property {number} [usage.completion_tokens] - Number of completion tokens used over the course of the run.
+ * @property {number} [usage.prompt_tokens] - Number of prompt tokens used over the course of the run.
+ * @property {number} [usage.total_tokens] - Total number of tokens used (prompt + completion).
+ * @property {number} [usage.reasoning_tokens] - Total number of tokens used for reasoning (OpenAI o1 models).
+ * @property {Object} [usage.completion_tokens_details] - Further details on the completion tokens used (OpenAI o1 models).
+ * @property {number} [usage.completion_tokens_details.reasoning_tokens] - Total number of tokens used for reasoning (OpenAI o1 models).
+ * @memberof typedefs
+ */
+
+/**
+ * @typedef {AnthropicStreamUsage | OpenAIUsageMetadata | UsageMetadata} StreamUsage - Stream usage for all providers (currently only Anthropic, OpenAI, LangChain)
+ */
+
+/* Native app/client methods */
+
+/**
+ * Accumulates tokens and sends them to the client for processing.
+ * @callback onTokenProgress
+ * @param {string} token - The current token generated by the model.
+ * @returns {Promise