Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 65 additions & 3 deletions .github/workflows/docker-build-workflow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Registry names must be lowercase, and the repository owner is not guaranteed to be.
- name: Resolve image name
env:
IMAGE_NAME: ${{ matrix.image_name }}
run: printf 'GHCR_IMAGE=ghcr.io/%s/%s\n' "${GITHUB_REPOSITORY_OWNER,,}" "$IMAGE_NAME" >> "$GITHUB_ENV"

# Build and push Docker images for each target
- name: Build and push Docker images
uses: docker/build-push-action@v5
Expand All @@ -53,7 +59,63 @@ jobs:
file: ${{ matrix.file }}
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ inputs.tag }}
${{ env.GHCR_IMAGE }}:${{ github.sha }}
${{ env.GHCR_IMAGE }}:${{ inputs.tag }}
platforms: linux/amd64,linux/arm64
target: ${{ matrix.target }}
target: ${{ matrix.target }}

# After the repository moved to another owner, deployments still pull the old GHCR
# namespace. Copying each published tag there keeps them updating until the deprecation
# window closes. Copies are cross-repo blob mounts within GHCR, so no image data is
# rebuilt or re-uploaded.
#
# It runs in its own job so the credential that can write to the legacy namespace lives
# in an environment, out of reach of workflows on other branches, and so a mirror failure
# cannot fail a publish.
#
# Set the LEGACY_GHCR_OWNER variable and the LEGACY_GHCR_TOKEN secret (a token with
# write:packages for that owner) to turn this on.
mirror:
name: Mirror ${{ matrix.image_name }} to the legacy namespace
if: ${{ vars.LEGACY_GHCR_OWNER != '' }}
needs: build
runs-on: ubuntu-latest
timeout-minutes: 15
continue-on-error: true
environment: legacy-mirror
strategy:
fail-fast: false
matrix:
image_name: [librechat-rag-api-dev, librechat-rag-api-dev-lite]

steps:
- name: Copy the published tags
env:
LEGACY_OWNER: ${{ vars.LEGACY_GHCR_OWNER }}
LEGACY_TOKEN: ${{ secrets.LEGACY_GHCR_TOKEN }}
IMAGE_NAME: ${{ matrix.image_name }}
TAGS: "${{ github.sha }} ${{ inputs.tag }}"
run: |
set -euo pipefail

legacy_owner="${LEGACY_OWNER,,}"
if [ "$legacy_owner" = "${GITHUB_REPOSITORY_OWNER,,}" ]; then
echo "Legacy owner matches the current owner; nothing to mirror."
exit 0
fi
if [ -z "$LEGACY_TOKEN" ]; then
echo "::warning::LEGACY_GHCR_OWNER is set but LEGACY_GHCR_TOKEN is not; skipping the mirror."
exit 0
fi

# Registry names must be lowercase, and the repository owner is not guaranteed to be.
source_image="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/${IMAGE_NAME}"
legacy_image="ghcr.io/${legacy_owner}/${IMAGE_NAME}"

# This login reads both namespaces: it replaces the default ghcr.io credential.
echo "$LEGACY_TOKEN" | docker login ghcr.io -u "$legacy_owner" --password-stdin

for tag in $TAGS; do
echo "Mirroring ${source_image}:${tag} -> ${legacy_image}:${tag}"
docker buildx imagetools create -t "${legacy_image}:${tag}" "${source_image}:${tag}"
done
66 changes: 64 additions & 2 deletions .github/workflows/images.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Registry names must be lowercase, and the repository owner is not guaranteed to be.
- name: Resolve image name
env:
IMAGE_NAME: ${{ matrix.image_name }}
run: printf 'GHCR_IMAGE=ghcr.io/%s/%s\n' "${GITHUB_REPOSITORY_OWNER,,}" "$IMAGE_NAME" >> "$GITHUB_ENV"

# Build and push Docker images for each target
- name: Build and push Docker images
uses: docker/build-push-action@v5
Expand All @@ -65,9 +71,65 @@ jobs:
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
${{ env.GHCR_IMAGE }}:${{ github.sha }}
${{ env.GHCR_IMAGE }}:latest
platforms: linux/amd64,linux/arm64
target: ${{ matrix.target }}
cache-from: type=gha
cache-to: type=gha,mode=max

# After the repository moved to another owner, deployments still pull the old GHCR
# namespace. Copying each published tag there keeps them updating until the deprecation
# window closes. Copies are cross-repo blob mounts within GHCR, so no image data is
# rebuilt or re-uploaded.
#
# It runs in its own job so the credential that can write to the legacy namespace lives
# in an environment, out of reach of workflows on other branches, and so a mirror failure
# cannot fail a publish.
#
# Set the LEGACY_GHCR_OWNER variable and the LEGACY_GHCR_TOKEN secret (a token with
# write:packages for that owner) to turn this on.
mirror:
name: Mirror ${{ matrix.image_name }} to the legacy namespace
if: ${{ vars.LEGACY_GHCR_OWNER != '' }}
needs: build
runs-on: ubuntu-latest
timeout-minutes: 15
continue-on-error: true
environment: legacy-mirror
strategy:
fail-fast: false
matrix:
image_name: [librechat-rag-api-dev, librechat-rag-api-dev-lite]

steps:
- name: Copy the published tags
env:
LEGACY_OWNER: ${{ vars.LEGACY_GHCR_OWNER }}
LEGACY_TOKEN: ${{ secrets.LEGACY_GHCR_TOKEN }}
IMAGE_NAME: ${{ matrix.image_name }}
TAGS: "${{ github.sha }} latest"
run: |
set -euo pipefail

legacy_owner="${LEGACY_OWNER,,}"
if [ "$legacy_owner" = "${GITHUB_REPOSITORY_OWNER,,}" ]; then
echo "Legacy owner matches the current owner; nothing to mirror."
exit 0
fi
if [ -z "$LEGACY_TOKEN" ]; then
echo "::warning::LEGACY_GHCR_OWNER is set but LEGACY_GHCR_TOKEN is not; skipping the mirror."
exit 0
fi

# Registry names must be lowercase, and the repository owner is not guaranteed to be.
source_image="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/${IMAGE_NAME}"
legacy_image="ghcr.io/${legacy_owner}/${IMAGE_NAME}"

# This login reads both namespaces: it replaces the default ghcr.io credential.
echo "$LEGACY_TOKEN" | docker login ghcr.io -u "$legacy_owner" --password-stdin

for tag in $TAGS; do
echo "Mirroring ${source_image}:${tag} -> ${legacy_image}:${tag}"
docker buildx imagetools create -t "${legacy_image}:${tag}" "${source_image}:${tag}"
done
3 changes: 3 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,8 @@ permissions:
jobs:
call-docker-build:
uses: ./.github/workflows/docker-build-workflow.yaml
# The mirror step reads LEGACY_GHCR_TOKEN, and a called workflow gets no
# secrets unless they are passed.
secrets: inherit
with:
tag: ${{ github.ref_name }}
12 changes: 3 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ FROM python:3.12 AS main

WORKDIR /app

# Install pandoc and netcat
# Install pandoc and netcat. `upgrade` pulls Debian security fixes (e.g.
# perl-base) that the base image tag may not carry yet.
RUN apt-get update \
&& apt-get upgrade -y --no-install-recommends \
&& apt-get install -y --no-install-recommends \
pandoc \
netcat-openbsd \
Expand All @@ -13,14 +15,6 @@ RUN apt-get update \

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
ENV NLTK_DATA=/app/nltk_data

# Download standard NLTK data, to prevent unstructured from downloading packages at runtime.
# Uses the Python API with explicit package IDs (rather than `python -m nltk.downloader`),
# which never falls back to the interactive prompt that EOFs during a non-interactive build.
# A failed download now fails the build (upstream #315) instead of leaving the image to
# fetch at runtime.
RUN python -c "import nltk, sys; packages = ('punkt_tab', 'averaged_perceptron_tagger', 'averaged_perceptron_tagger_eng'); sys.exit(0 if all(nltk.download(package, download_dir='/app/nltk_data') for package in packages) else 1)"

# Disable Unstructured analytics
ENV SCARF_NO_ANALYTICS=true
Expand Down
13 changes: 4 additions & 9 deletions Dockerfile.lite
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ FROM python:3.12-slim AS lite

WORKDIR /app

# Install pandoc (DOCX /text extraction) and netcat (DB wait-for in entrypoint)
# Install pandoc (DOCX /text extraction) and netcat (DB wait-for in entrypoint).
# `upgrade` pulls Debian security fixes (e.g. perl-base) that the base image
# tag may not carry yet.
RUN apt-get update \
&& apt-get upgrade -y --no-install-recommends \
&& apt-get install -y --no-install-recommends \
pandoc \
netcat-openbsd \
Expand All @@ -17,14 +20,6 @@ RUN pip install --no-cache-dir --no-compile -r requirements.lite.txt \
-type d -name tests -prune -exec rm -rf {} + \
&& find /usr/local/lib/python3.12/site-packages \
-type d -name __pycache__ -prune -exec rm -rf {} +
ENV NLTK_DATA=/app/nltk_data

# Download standard NLTK data, to prevent unstructured from downloading packages at runtime.
# Uses the Python API with explicit package IDs (rather than `python -m nltk.downloader`),
# which never falls back to the interactive prompt that EOFs during a non-interactive build.
# A failed download now fails the build (upstream #315) instead of leaving the image to
# fetch at runtime.
RUN python -c "import nltk, sys; packages = ('punkt_tab', 'averaged_perceptron_tagger', 'averaged_perceptron_tagger_eng'); sys.exit(0 if all(nltk.download(package, download_dir='/app/nltk_data') for package in packages) else 1)"

# Disable Unstructured analytics
ENV SCARF_NO_ANALYTICS=true
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ The following environment variables are required to run the application:

> **Upgrading:** these defaults change what `/text` returns for `.docx` files — tables come back as pipe tables (or an HTML `<table>` for cells holding block content) instead of padded grid tables, and the cleanup pass runs. Anything downstream that depends on the previous Markdown shape byte for byte — a prompt template keyed on grid tables, or a stored extraction being compared against a fresh one — sees different text. Set `DOCX_TEXT_TABLE_STYLE=grid`, `DOCX_TEXT_CLEANUP=False` and `DOCX_TEXT_STRIP_HEADING_ANCHORS=False` to keep the old output.
- `EMAIL_INCLUDE_HEADERS`: (Optional) Boolean. When "True" (default), the From/To/Cc/Subject/Date headers are prepended to extracted email text for `.eml` and `.msg` files. Set "False" to extract the body only.
- `MISTRAL_API_KEY`: (Optional) API key for Mistral OCR, used for PDFs and standalone images. OCR is unavailable without it.
- `MISTRAL_OCR_MODEL`: (Optional) Mistral OCR model. Default value is `mistral-ocr-latest`.
- `MISTRAL_SERVER_URL`: (Optional) Base URL of the Mistral API. Leave unset for the SDK default (`https://api.mistral.ai`); set `https://api.eu.mistral.ai` to keep OCR traffic on Mistral's [EU regional endpoint](https://docs.mistral.ai/inference/regional-inference). Regional endpoints don't offer the Files API, which is fine here: documents and images are sent inline as base64. Confirm with Mistral that your OCR model is served on the regional endpoint before relying on it.
- Note: LibreChat's own `mistral_ocr` strategy uploads through the Files API, so on an EU-only deployment route PDFs and images to this service's `/text` endpoint instead.
- For EU embeddings as well, point `RAG_OPENAI_BASEURL` at an EU OpenAI-compatible endpoint (e.g. `https://eu.openrouter.ai/api/v1`) with a non-OpenAI model such as `mistralai/mistral-embed-2312` (1024 dimensions), and set `RAG_CHECK_EMBEDDING_CTX_LENGTH=False`: with the check on, inputs are sent as OpenAI tiktoken token IDs, which non-OpenAI models don't accept. Switching embedding models requires re-indexing existing files (see `EMBEDDINGS_DIMENSIONS`).
- Standalone image uploads (`.png`, `.jpg/.jpeg`, `.gif`, `.bmp`, `.tif/.tiff`, `.webp`) — including multi-page TIFF — are run through **Mistral OCR**, yielding one result per page. Requires `MISTRAL_API_KEY` (same key used for PDF OCR). This is separate from PDF handling; images embedded inside PDFs are not extracted.
- `IMAGE_OCR_MAX_PAGES`: (Optional) Max number of frames/pages of a single image upload (e.g. animated GIF or large multi-page TIFF) sent to OCR; frames beyond the cap are skipped with a warning. Default value is `100`.
- `DEBUG_RAG_API`: (Optional) Set to "True" to show more verbose logging output in the server console, and to enable postgresql database routes
Expand Down
3 changes: 3 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,9 @@ async def dispatch(self, request, call_next):
# Mistral OCR settings
MISTRAL_API_KEY = get_env_variable("MISTRAL_API_KEY", "")
MISTRAL_OCR_MODEL = get_env_variable("MISTRAL_OCR_MODEL", "mistral-ocr-latest")
# Override the Mistral API base URL, e.g. https://api.eu.mistral.ai for EU-only
# deployments. Empty keeps the SDK default (https://api.mistral.ai).
MISTRAL_SERVER_URL = get_env_variable("MISTRAL_SERVER_URL", "").rstrip("/")

# DOCX handling for the /text endpoint: when enabled, .docx files are converted
# via pandoc so tracked changes and comments are preserved (the embedding path
Expand Down
28 changes: 21 additions & 7 deletions app/routes/document_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def _apply_distance_threshold(documents):
QueryMultipleBody,
)
from app.services.vector_store.async_pg_vector import AsyncPgVector
from app.utils.async_utils import run_in_executor
from app.utils.document_loader import (
get_loader,
clean_text,
Expand Down Expand Up @@ -534,7 +535,8 @@ async def delete_documents(
raise HTTPException(status_code=500, detail=str(e))


# Cache the embedding function with LRU cache
# Cache query vectors only, never scoped search results. Both cache hits and
# misses run in the request worker pool so a miss cannot block the event loop.
@lru_cache(maxsize=128)
def get_cached_query_embedding(query: str):
return vector_store.embedding_function.embed_query(query)
Expand All @@ -548,7 +550,9 @@ async def query_embeddings_by_file_id(
scope = resolve_scope(request, body.entity_id)

try:
embedding = get_cached_query_embedding(body.query)
embedding = await run_in_executor(
request.app.state.thread_pool, get_cached_query_embedding, body.query
)
query_filter = scope.predicate(file_clause(body.file_id))

if isinstance(vector_store, AsyncPgVector):
Expand All @@ -559,8 +563,12 @@ async def query_embeddings_by_file_id(
executor=request.app.state.thread_pool,
)
else:
documents = vector_store.similarity_search_with_score_by_vector(
embedding, k=body.k, filter=query_filter
documents = await run_in_executor(
request.app.state.thread_pool,
vector_store.similarity_search_with_score_by_vector,
embedding,
k=body.k,
filter=query_filter,
)

return _apply_distance_threshold(documents)
Expand Down Expand Up @@ -1460,7 +1468,9 @@ async def query_embeddings_by_file_ids(request: Request, body: QueryMultipleBody
scope = resolve_scope(request, body.entity_id)
try:
# Get the embedding of the query text
embedding = get_cached_query_embedding(body.query)
embedding = await run_in_executor(
request.app.state.thread_pool, get_cached_query_embedding, body.query
)
query_filter = scope.predicate(files_clause(body.file_ids))

# Perform similarity search with the query embedding and filter by the file_ids in metadata
Expand All @@ -1472,8 +1482,12 @@ async def query_embeddings_by_file_ids(request: Request, body: QueryMultipleBody
executor=request.app.state.thread_pool,
)
else:
documents = vector_store.similarity_search_with_score_by_vector(
embedding, k=body.k, filter=query_filter
documents = await run_in_executor(
request.app.state.thread_pool,
vector_store.similarity_search_with_score_by_vector,
embedding,
k=body.k,
filter=query_filter,
)

documents = _apply_distance_threshold(documents)
Expand Down
18 changes: 3 additions & 15 deletions app/services/vector_store/async_pg_vector.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import asyncio
from concurrent.futures import Executor
from langchain_core.documents import Document
from app.utils.async_utils import run_in_executor
from .extended_pg_vector import ExtendedPgVector

T = TypeVar("T")
Expand All @@ -28,21 +29,8 @@ async def _run_in_executor(
*args: Any,
**kwargs: Any,
) -> T:
"""Run a sync callable in a thread pool executor.

Wraps the call to convert StopIteration into RuntimeError.
StopIteration cannot be set on an asyncio.Future — it raises
TypeError and leaves the Future pending forever.
"""

def wrapper() -> T:
try:
return func(*args, **kwargs)
except StopIteration as exc:
raise RuntimeError from exc

loop = asyncio.get_running_loop()
return await loop.run_in_executor(executor, wrapper)
"""Run a sync callable with the shared exception-safe executor bridge."""
return await run_in_executor(executor, func, *args, **kwargs)

async def get_all_ids(self, owners: Sequence[str], executor=None) -> list[str]:
executor = executor or self._get_thread_pool()
Expand Down
29 changes: 29 additions & 0 deletions app/utils/async_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Bridges for blocking operations used by async request paths."""

import asyncio
from concurrent.futures import Executor
from typing import Any, Callable, TypeVar

T = TypeVar("T")


async def run_in_executor(
executor: Executor | None,
func: Callable[..., T],
*args: Any,
**kwargs: Any,
) -> T:
"""Run a sync callable in the supplied pool without blocking the event loop.

StopIteration cannot be set on an asyncio.Future: convert it into an error
that the awaiting request can handle instead of leaving it pending forever.
Cancelling the await does not stop a callable already running in a worker.
"""

def wrapper() -> T:
try:
return func(*args, **kwargs)
except StopIteration as exc:
raise RuntimeError from exc

return await asyncio.get_running_loop().run_in_executor(executor, wrapper)
6 changes: 6 additions & 0 deletions app/utils/document_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
logger,
MISTRAL_API_KEY,
MISTRAL_OCR_MODEL,
MISTRAL_SERVER_URL,
DOCX_TEXT_USE_PANDOC,
DOCX_TEXT_TRACK_CHANGES,
DOCX_TEXT_INCLUDE_HEADERS_FOOTERS,
Expand Down Expand Up @@ -319,6 +320,11 @@ def _mistral_ocr_client():
"MISTRAL_API_KEY is not set. Please configure it in environment variables."
)

# Only pass server_url when configured so the SDK keeps its own default.
# Regional endpoints (e.g. api.eu.mistral.ai) lack the Files API; OCR here
# sends documents inline as base64, so it works against them unchanged.
if MISTRAL_SERVER_URL:
return Mistral(api_key=MISTRAL_API_KEY, server_url=MISTRAL_SERVER_URL)
return Mistral(api_key=MISTRAL_API_KEY)


Expand Down
Loading
Loading