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 @@ +

+ + + +

+ LibreChat +

+

+ +

+ English · + 中文 +

+ +

+ + + + + + + + + + + + +

+ +

+ + Deploy on Railway + + + Deploy on Zeabur + + + Deploy on Sealos + +

+ +

+ + Translation Progress + +

+ + +# ✨ 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 + +

+ + Star History Chart + +

+

+ + danny-avila%2FLibreChat | Trendshift + + + ROSS Index - Fastest Growing Open-Source Startups in Q1 2024 | Runa Capital + +

+ +--- + +## ✨ 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. + +

+ + Locize Logo + +

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 @@ + + +

+ + + +

+ LibreChat +

+

+ +

+ English · + 中文 +

+ +

+ + + + + + + + + + + + +

+ +

+ + Deploy on Railway + + + Deploy on Zeabur + + + Deploy on Sealos + +

+ +

+ + 翻译进度 + +

+ + +# ✨ 功能 + +- 🖥️ **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 历史 + +

+ + Star History Chart + +

+

+ + danny-avila%2FLibreChat | Trendshift + + + ROSS Index - 2024年第一季度增长最快的开源初创公司 | Runa Capital + +

+ +--- + +## ✨ 贡献 + +欢迎任何形式的贡献、建议、错误报告和修复! + +对于新功能、组件或扩展,请在发送 PR 前开启 issue 进行讨论。 + +如果您想帮助我们将 LibreChat 翻译成您的母语,我们非常欢迎!改进翻译不仅能让全球用户更轻松地使用 LibreChat,还能提升整体用户体验。请查看我们的[翻译指南](https://www.librechat.ai/docs/translation)。 + +--- + +## 💖 感谢所有贡献者 + + + + + +--- + +## 🎉 特别鸣谢 + +感谢 [Locize](https://locize.com) 提供的翻译管理工具,支持 LibreChat 的多语言功能。 + +

+ + Locize Logo + +

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 \`placeholder\` + - 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 \`placeholder\` + - 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 \`placeholder\` + - 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 \`placeholder\` + - 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}

+ +
+ ); + } + \`\`\`\` + ::: + + 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 + + + +
+

My First Blog Post

+
+
+
+

This is the content of my blog post. It's short and sweet!

+
+
+
+

© 2023 My Blog

+
+ + + \`\`\`\` + ::: + + 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 \`placeholder\` + - 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 \`placeholder\` + - 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}

+ +
+ ); + } + \`\`\`\` + ::: + + 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 + + + +
+

My First Blog Post

+
+
+
+

This is the content of my blog post. It's short and sweet!

+
+
+
+

© 2023 My Blog

+
+ + + \`\`\`\` + ::: + + 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: ` +`, + }, + 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: '', + }, + radioGroup: { + componentName: 'RadioGroup', + importDocs: ` +import { Label } from "/components/ui/label" +import { RadioGroup, RadioGroupItem } from "/components/ui/radio-group"`, + usageDocs: ` + +
+ + +
+
+ + +
+
`, + }, + select: { + componentName: 'Select', + importDocs: ` +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "/components/ui/select"`, + usageDocs: ` +`, + }, + textarea: { + componentName: 'Textarea', + importDocs: 'import { Textarea } from "/components/ui/textarea"', + usageDocs: '