From 9dddef12b3c2a0383486c760438758a45f709688 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 22 Sep 2026 13:43:57 -0400 Subject: [PATCH 1/7] ci: Prepare Image Publishing for the Move to LibreChat-AI (#325) * ci: build image names from a lowercased repository owner * ci: mirror published images to a legacy GHCR namespace --- .github/workflows/docker-build-workflow.yaml | 50 ++++++++++++++++++-- .github/workflows/images.yaml | 48 ++++++++++++++++++- .github/workflows/release.yaml | 3 ++ 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker-build-workflow.yaml b/.github/workflows/docker-build-workflow.yaml index 116bc990..c8f7a4e1 100644 --- a/.github/workflows/docker-build-workflow.yaml +++ b/.github/workflows/docker-build-workflow.yaml @@ -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 @@ -53,7 +59,45 @@ 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 }} \ No newline at end of file + target: ${{ matrix.target }} + + # After the repository moves 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. + # + # Set the LEGACY_GHCR_OWNER variable and the LEGACY_GHCR_TOKEN secret (a token with + # write:packages for that owner) to turn this on. It stays off when either is unset, + # and while the owner still matches the one publishing the images. + - name: Mirror tags to the legacy namespace + if: ${{ vars.LEGACY_GHCR_OWNER != '' }} + continue-on-error: true + env: + LEGACY_OWNER: ${{ vars.LEGACY_GHCR_OWNER }} + LEGACY_TOKEN: ${{ secrets.LEGACY_GHCR_TOKEN }} + 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 + + # The pull side needs this login too: it replaces the GITHUB_TOKEN credential + # for ghcr.io, and both namespaces are read with it. + echo "$LEGACY_TOKEN" | docker login ghcr.io -u "$legacy_owner" --password-stdin + + legacy_image="ghcr.io/${legacy_owner}/${GHCR_IMAGE##*/}" + for tag in $TAGS; do + echo "Mirroring ${GHCR_IMAGE}:${tag} -> ${legacy_image}:${tag}" + docker buildx imagetools create -t "${legacy_image}:${tag}" "${GHCR_IMAGE}:${tag}" + done diff --git a/.github/workflows/images.yaml b/.github/workflows/images.yaml index 12e8099c..b2865e8b 100644 --- a/.github/workflows/images.yaml +++ b/.github/workflows/images.yaml @@ -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 @@ -65,9 +71,47 @@ 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 moves 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. + # + # Set the LEGACY_GHCR_OWNER variable and the LEGACY_GHCR_TOKEN secret (a token with + # write:packages for that owner) to turn this on. It stays off when either is unset, + # and while the owner still matches the one publishing the images. + - name: Mirror tags to the legacy namespace + if: ${{ vars.LEGACY_GHCR_OWNER != '' }} + continue-on-error: true + env: + LEGACY_OWNER: ${{ vars.LEGACY_GHCR_OWNER }} + LEGACY_TOKEN: ${{ secrets.LEGACY_GHCR_TOKEN }} + 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 + + # The pull side needs this login too: it replaces the GITHUB_TOKEN credential + # for ghcr.io, and both namespaces are read with it. + echo "$LEGACY_TOKEN" | docker login ghcr.io -u "$legacy_owner" --password-stdin + + legacy_image="ghcr.io/${legacy_owner}/${GHCR_IMAGE##*/}" + for tag in $TAGS; do + echo "Mirroring ${GHCR_IMAGE}:${tag} -> ${legacy_image}:${tag}" + docker buildx imagetools create -t "${legacy_image}:${tag}" "${GHCR_IMAGE}:${tag}" + done diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3aaeaaff..207f37ee 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 }} From 529891977c446cc97ae682814d747a66df92214f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 22 Sep 2026 18:21:22 -0400 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=93=A6=20chore:=20update=20`pypdf`=20?= =?UTF-8?q?and=20`unstructured`=20versions=20in=20requirements=20files=20(?= =?UTF-8?q?#326)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bumped pypdf from 6.14.2 to 6.19.0 * Updated unstructured from 0.18.32 to 0.27.8 * Upgraded cryptography from 48.0.1 to 50.0.1 in both requirements files --- requirements.lite.txt | 6 +++--- requirements.txt | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements.lite.txt b/requirements.lite.txt index 735dd5d6..914801c0 100644 --- a/requirements.lite.txt +++ b/requirements.lite.txt @@ -9,8 +9,8 @@ fastapi==0.115.12 psycopg2-binary==2.9.9 pgvector==0.2.5 uvicorn==0.28.0 -pypdf==6.14.2 -unstructured==0.18.32 +pypdf==6.19.0 +unstructured==0.27.8 markdown==3.8.2 networkx==3.2.1 pandas==2.2.1 @@ -25,7 +25,7 @@ rapidocr-onnxruntime==1.4.4 opencv-python-headless==4.9.0.80 pymongo>=4.12.0,<5 langchain-mongodb==0.11.0 -cryptography==48.0.1 +cryptography==50.0.1 python-magic==0.4.27 python-pptx==1.0.2 xlrd==2.0.2 diff --git a/requirements.txt b/requirements.txt index 14e6e00c..0a77695e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,8 +11,8 @@ fastapi==0.115.12 psycopg2-binary==2.9.9 pgvector==0.2.5 uvicorn==0.28.0 -pypdf==6.14.2 -unstructured==0.18.32 +pypdf==6.19.0 +unstructured==0.27.8 markdown==3.8.2 networkx==3.2.1 pandas==2.2.1 @@ -31,7 +31,7 @@ langchain-mongodb==0.11.0 langchain-ollama==1.1.0 langchain-huggingface==1.2.2 langchain-google-genai==4.2.5 -cryptography==48.0.1 +cryptography==50.0.1 python-magic==0.4.27 python-pptx==1.0.2 xlrd==2.0.2 From 3946c153f963f0ab70f85fdab823b1582fd344f6 Mon Sep 17 00:00:00 2001 From: "lia-by-librechat[bot]" <328778573+lia-by-librechat[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:26:32 -0400 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=A7=B5=20perf:=20Keep=20Query=20Embed?= =?UTF-8?q?dings=20and=20Searches=20Off=20the=20Event=20Loop=20(#327)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lia --- app/routes/document_routes.py | 28 +- app/services/vector_store/async_pg_vector.py | 18 +- app/utils/async_utils.py | 29 +++ tests/test_query_execution.py | 255 +++++++++++++++++++ 4 files changed, 308 insertions(+), 22 deletions(-) create mode 100644 app/utils/async_utils.py create mode 100644 tests/test_query_execution.py diff --git a/app/routes/document_routes.py b/app/routes/document_routes.py index 2360174a..c3cdb2ee 100644 --- a/app/routes/document_routes.py +++ b/app/routes/document_routes.py @@ -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, @@ -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) @@ -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): @@ -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) @@ -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 @@ -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) diff --git a/app/services/vector_store/async_pg_vector.py b/app/services/vector_store/async_pg_vector.py index 22e093a8..7b93d010 100644 --- a/app/services/vector_store/async_pg_vector.py +++ b/app/services/vector_store/async_pg_vector.py @@ -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") @@ -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() diff --git a/app/utils/async_utils.py b/app/utils/async_utils.py new file mode 100644 index 00000000..2adc69b8 --- /dev/null +++ b/app/utils/async_utils.py @@ -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) diff --git a/tests/test_query_execution.py b/tests/test_query_execution.py new file mode 100644 index 00000000..4e4d7518 --- /dev/null +++ b/tests/test_query_execution.py @@ -0,0 +1,255 @@ +"""Exercise query dispatch with real worker threads and no external services.""" + +import asyncio +import threading +from concurrent.futures import ThreadPoolExecutor +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from fastapi import HTTPException +from langchain_core.documents import Document + +from app.config import VectorDBType +from app.models import QueryMultipleBody, QueryRequestBody +from app.routes import document_routes +from app.services.vector_store.async_pg_vector import AsyncPgVector +from app.services.vector_store.extended_pg_vector import ExtendedPgVector + + +class DummyAsyncPgVector(AsyncPgVector): + def __init__(self, embedding_function): + self.embedding_function = embedding_function + self._thread_pool = None + self._bind = None + + +@pytest.fixture(params=["single", "multiple"]) +def query_route(request): + return request.param + + +@pytest.fixture(params=["async", "sync"]) +def query_context(request, monkeypatch, query_route): + """Use the real async adapter or the route's synchronous fallback.""" + embedding = Mock(return_value=[0.1, 0.2, 0.3]) + provider = SimpleNamespace(embed_query=embedding) + hits = [ + (Document(page_content="near", metadata={"file_id": "file-1"}), 0.2), + (Document(page_content="far", metadata={"file_id": "file-1"}), 0.8), + ] + search = Mock(return_value=hits) + if request.param == "async": + store = DummyAsyncPgVector(provider) + monkeypatch.setattr( + ExtendedPgVector, + "similarity_search_with_score_by_vector", + lambda self, *args, **kwargs: search(*args, **kwargs), + ) + else: + store = SimpleNamespace( + embedding_function=provider, + similarity_search_with_score_by_vector=search, + ) + monkeypatch.setattr(document_routes, "vector_store", store) + monkeypatch.setattr(document_routes, "RAG_DISTANCE_THRESHOLD", None) + monkeypatch.setattr(document_routes, "VECTOR_DB_TYPE", VectorDBType.PGVECTOR) + document_routes.get_cached_query_embedding.cache_clear() + + with ThreadPoolExecutor(max_workers=2, thread_name_prefix="query-test") as pool: + req = SimpleNamespace( + app=SimpleNamespace(state=SimpleNamespace(thread_pool=pool)), + state=SimpleNamespace(user={"id": "owner-1"}), + ) + + async def query(text="question", entity_id=None): + body_args = dict(query=text, k=7, entity_id=entity_id) + if query_route == "single": + return await document_routes.query_embeddings_by_file_id( + QueryRequestBody(file_id="file-1", **body_args), req + ) + return await document_routes.query_embeddings_by_file_ids( + req, QueryMultipleBody(file_ids=["file-1", "file-2"], **body_args) + ) + + try: + yield SimpleNamespace( + query=query, + embedding=embedding, + search=search, + hits=hits, + request=req, + ) + finally: + # Drain cancelled read-only work before clearing its shared cache. + pool.shutdown(wait=True) + document_routes.get_cached_query_embedding.cache_clear() + + +def _block_stage(context, stage, loop, started, release, finished): + operation = getattr(context, stage) + result = operation.return_value + + def blocking(*args, **kwargs): + assert threading.current_thread().name.startswith("query-test") + loop.call_soon_threadsafe(started.set) + try: + if not release.wait(timeout=5): + raise TimeoutError("event loop did not release the worker") + return result + finally: + loop.call_soon_threadsafe(finished.set) + + operation.side_effect = blocking + + +@pytest.mark.parametrize("stage", ["embedding", "search"]) +async def test_blocking_stages_leave_event_loop_responsive(query_context, stage): + started, finished = asyncio.Event(), asyncio.Event() + release = threading.Event() + _block_stage( + query_context, stage, asyncio.get_running_loop(), started, release, finished + ) + task = asyncio.create_task(query_context.query()) + try: + await asyncio.wait_for(started.wait(), timeout=2) + # This coroutine can resume while the provider or database is still blocked. + assert not task.done() + assert not finished.is_set() + finally: + release.set() + result = await asyncio.wait_for(task, timeout=2) + assert result == query_context.hits + + +async def test_concurrent_queries_share_the_configured_worker_limit(query_context): + loop = asyncio.get_running_loop() + saturated = asyncio.Event() + release = threading.Event() + lock = threading.Lock() + started = 0 + + def embed(query): + nonlocal started + with lock: + started += 1 + if started == 2: + loop.call_soon_threadsafe(saturated.set) + if not release.wait(timeout=5): + raise TimeoutError("event loop did not release the workers") + return [0.1, 0.2, 0.3] + + query_context.embedding.side_effect = embed + tasks = [asyncio.create_task(query_context.query(str(i))) for i in range(5)] + try: + await asyncio.wait_for(saturated.wait(), timeout=2) + # Two workers are occupied. The remaining requests must wait in this + # same pool, not start in a separate or unbounded set of threads. + with lock: + assert started == 2 + assert all(not task.done() for task in tasks) + finally: + release.set() + results = await asyncio.wait_for(asyncio.gather(*tasks), timeout=2) + assert results == [query_context.hits] * 5 + assert started == 5 + + +async def test_cache_reuses_vectors_but_not_scoped_results(query_context, query_route): + assert await query_context.query() == query_context.hits + query_context.request.state.user = {"id": "owner-2"} + assert await query_context.query(entity_id="agent-1") == query_context.hits + query_context.embedding.assert_called_once_with("question") + assert query_context.search.call_count == 2 + expected_file_clause = ( + {"file_id": {"$eq": "file-1"}} + if query_route == "single" + else {"file_id": {"$in": ["file-1", "file-2"]}} + ) + for call, owners in zip( + query_context.search.call_args_list, + [["owner-1"], ["agent-1", "owner-2"]], + ): + # The real async adapter forwards positional arguments; the sync path + # forwards keyword arguments. Both must carry the same pre-ranking scope. + embedding = call.args[0] + k = call.args[1] if len(call.args) > 1 else call.kwargs["k"] + predicate = call.args[2] if len(call.args) > 2 else call.kwargs["filter"] + assert embedding == [0.1, 0.2, 0.3] + assert k == 7 + assert predicate == { + "$and": [expected_file_clause, {"user_id": {"$in": owners}}] + } + + +@pytest.mark.parametrize("stage", ["embedding", "search"]) +@pytest.mark.parametrize( + "error,status", + [ + (ValueError("unavailable"), 500), + (HTTPException(429, "retry"), 429), + (StopIteration("exhausted"), 500), + ], +) +async def test_worker_errors_propagate_and_allow_retry( + query_context, stage, error, status +): + operation = getattr(query_context, stage) + operation.side_effect = error + with pytest.raises(HTTPException) as caught: + await asyncio.wait_for(query_context.query(), timeout=2) + assert caught.value.status_code == status + if stage == "embedding": + query_context.search.assert_not_called() + operation.side_effect = None + assert await query_context.query() == query_context.hits + assert operation.call_count == 2 + + +@pytest.mark.parametrize("stage", ["embedding", "search"]) +async def test_cancellation_propagates_while_read_only_worker_finishes( + query_context, stage +): + started, finished = asyncio.Event(), asyncio.Event() + release = threading.Event() + _block_stage( + query_context, stage, asyncio.get_running_loop(), started, release, finished + ) + task = asyncio.create_task(query_context.query()) + try: + await asyncio.wait_for(started.wait(), timeout=2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, timeout=2) + assert not finished.is_set() + finally: + release.set() + await asyncio.wait_for(finished.wait(), timeout=2) + await asyncio.gather(task, return_exceptions=True) + if stage == "embedding": + query_context.search.assert_not_called() + + +@pytest.mark.parametrize("db_type", [VectorDBType.PGVECTOR, VectorDBType.ATLAS_MONGO]) +async def test_distance_threshold_semantics_unchanged( + query_context, monkeypatch, db_type +): + monkeypatch.setattr(document_routes, "VECTOR_DB_TYPE", db_type) + monkeypatch.setattr(document_routes, "RAG_DISTANCE_THRESHOLD", 0.3) + expected = ( + query_context.hits[:1] + if db_type == VectorDBType.PGVECTOR + else query_context.hits + ) + assert await query_context.query() == expected + + +async def test_empty_result_contract_unchanged(query_context, query_route): + query_context.search.return_value = [] + if query_route == "single": + assert await query_context.query() == [] + else: + with pytest.raises(HTTPException) as caught: + await query_context.query() + assert caught.value.status_code == 404 + assert caught.value.detail == "No documents found for the given query" From 0029515bf07939d9f43860bcc65ff410a7b8065d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 23 Sep 2026 19:47:05 -0400 Subject: [PATCH 4/7] fix: drop the NLTK data download from both images (#329) --- Dockerfile | 4 ---- Dockerfile.lite | 4 ---- 2 files changed, 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index f1b97bc3..6a3e42ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,10 +13,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 -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 diff --git a/Dockerfile.lite b/Dockerfile.lite index 229d3179..f0e2f40a 100644 --- a/Dockerfile.lite +++ b/Dockerfile.lite @@ -13,10 +13,6 @@ RUN apt-get update \ COPY requirements.lite.txt . RUN pip install --no-cache-dir -r requirements.lite.txt -ENV NLTK_DATA=/app/nltk_data - -# Download standard NLTK data, to prevent unstructured from downloading packages 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 From c4e5cbf79fcece1df9adfff5dfed983a5990ca9f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 23 Sep 2026 20:45:37 -0400 Subject: [PATCH 5/7] ci: gate the legacy mirror behind an environment (#328) --- .github/workflows/docker-build-workflow.yaml | 50 +++++++++++++------- .github/workflows/images.yaml | 50 +++++++++++++------- 2 files changed, 68 insertions(+), 32 deletions(-) diff --git a/.github/workflows/docker-build-workflow.yaml b/.github/workflows/docker-build-workflow.yaml index c8f7a4e1..5338994e 100644 --- a/.github/workflows/docker-build-workflow.yaml +++ b/.github/workflows/docker-build-workflow.yaml @@ -64,20 +64,36 @@ jobs: platforms: linux/amd64,linux/arm64 target: ${{ matrix.target }} - # After the repository moves 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. - # - # Set the LEGACY_GHCR_OWNER variable and the LEGACY_GHCR_TOKEN secret (a token with - # write:packages for that owner) to turn this on. It stays off when either is unset, - # and while the owner still matches the one publishing the images. - - name: Mirror tags to the legacy namespace - if: ${{ vars.LEGACY_GHCR_OWNER != '' }} - continue-on-error: true + # 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 @@ -92,12 +108,14 @@ jobs: exit 0 fi - # The pull side needs this login too: it replaces the GITHUB_TOKEN credential - # for ghcr.io, and both namespaces are read with it. + # 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 - legacy_image="ghcr.io/${legacy_owner}/${GHCR_IMAGE##*/}" for tag in $TAGS; do - echo "Mirroring ${GHCR_IMAGE}:${tag} -> ${legacy_image}:${tag}" - docker buildx imagetools create -t "${legacy_image}:${tag}" "${GHCR_IMAGE}:${tag}" + echo "Mirroring ${source_image}:${tag} -> ${legacy_image}:${tag}" + docker buildx imagetools create -t "${legacy_image}:${tag}" "${source_image}:${tag}" done diff --git a/.github/workflows/images.yaml b/.github/workflows/images.yaml index b2865e8b..8b04a503 100644 --- a/.github/workflows/images.yaml +++ b/.github/workflows/images.yaml @@ -78,20 +78,36 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - # After the repository moves 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. - # - # Set the LEGACY_GHCR_OWNER variable and the LEGACY_GHCR_TOKEN secret (a token with - # write:packages for that owner) to turn this on. It stays off when either is unset, - # and while the owner still matches the one publishing the images. - - name: Mirror tags to the legacy namespace - if: ${{ vars.LEGACY_GHCR_OWNER != '' }} - continue-on-error: true + # 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 @@ -106,12 +122,14 @@ jobs: exit 0 fi - # The pull side needs this login too: it replaces the GITHUB_TOKEN credential - # for ghcr.io, and both namespaces are read with it. + # 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 - legacy_image="ghcr.io/${legacy_owner}/${GHCR_IMAGE##*/}" for tag in $TAGS; do - echo "Mirroring ${GHCR_IMAGE}:${tag} -> ${legacy_image}:${tag}" - docker buildx imagetools create -t "${legacy_image}:${tag}" "${GHCR_IMAGE}:${tag}" + echo "Mirroring ${source_image}:${tag} -> ${legacy_image}:${tag}" + docker buildx imagetools create -t "${legacy_image}:${tag}" "${source_image}:${tag}" done From cb4c4cafb0499ca42b5349f04e96719a65020987 Mon Sep 17 00:00:00 2001 From: Daniel Vejsada Date: Thu, 15 Oct 2026 07:19:53 +0200 Subject: [PATCH 6/7] feat(ocr): configurable Mistral server URL for EU regional endpoint Add MISTRAL_SERVER_URL (unset = SDK default) and pass it to the Mistral client, so EU-only deployments can OCR via https://api.eu.mistral.ai. OCR sends documents inline as base64, so the missing Files API on regional endpoints doesn't matter. Document MISTRAL_* env vars and the EU embeddings setup (RAG_CHECK_EMBEDDING_CTX_LENGTH=False, re-index on model change). Closes #14 Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++++ app/config.py | 3 +++ app/utils/document_loader.py | 6 +++++ tests/utils/test_document_loader.py | 39 +++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+) diff --git a/README.md b/README.md index 2be1b14a..0d81a103 100644 --- a/README.md +++ b/README.md @@ -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 `` 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 diff --git a/app/config.py b/app/config.py index d37e12b4..2a34257c 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/utils/document_loader.py b/app/utils/document_loader.py index 6c51837e..44ab09db 100644 --- a/app/utils/document_loader.py +++ b/app/utils/document_loader.py @@ -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, @@ -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) diff --git a/tests/utils/test_document_loader.py b/tests/utils/test_document_loader.py index 1de02e5f..b1b81a21 100644 --- a/tests/utils/test_document_loader.py +++ b/tests/utils/test_document_loader.py @@ -203,6 +203,45 @@ def test_safe_pdf_loader_requires_api_key(): loader.load() +def test_mistral_client_uses_sdk_default_server_without_override(): + """Without MISTRAL_SERVER_URL the client is built with the SDK's default server.""" + from app.utils import document_loader + + fake_module, _ = _make_mistral_module(pages=[]) + + with patch.dict( + "sys.modules", {"mistralai": fake_module, "mistralai.client": fake_module} + ), patch.object( + document_loader, "MISTRAL_API_KEY", "test-key" + ), patch.object(document_loader, "MISTRAL_SERVER_URL", ""): + document_loader._mistral_ocr_client() + + fake_module.Mistral.assert_called_once_with(api_key="test-key") + + +def test_mistral_client_uses_configured_server_url(): + """MISTRAL_SERVER_URL (e.g. the EU regional endpoint) is passed to the client.""" + from app.utils import document_loader + from app.utils.document_loader import SafePyPDFLoader + + fake_module, client = _make_mistral_module(pages=[]) + + loader = SafePyPDFLoader("dummy.pdf") + with patch.dict( + "sys.modules", {"mistralai": fake_module, "mistralai.client": fake_module} + ), patch.object( + document_loader, "MISTRAL_API_KEY", "test-key" + ), patch.object( + document_loader, "MISTRAL_SERVER_URL", "https://api.eu.mistral.ai" + ), patch.object(SafePyPDFLoader, "_encode_pdf_b64", return_value="b64data"): + loader.load() + + fake_module.Mistral.assert_called_once_with( + api_key="test-key", server_url="https://api.eu.mistral.ai" + ) + client.ocr.process.assert_called_once() + + MARKDOWN_SAMPLE = ( "# Heading\n\n" "**bold** and *italic* text with a [link](https://example.com).\n\n" From 1ec5453148c73296ec35d4c1da56febb7fd13ac0 Mon Sep 17 00:00:00 2001 From: Daniel Vejsada Date: Thu, 15 Oct 2026 07:19:54 +0200 Subject: [PATCH 7/7] fix(security): patch critical image CVEs; bake spaCy model into image - PyJWT 2.13.0 -> 2.15.1 (CVE-2026-102268) - apt-get upgrade in both images for Debian security fixes; perl-base now 5.40.1-6+deb13u1 (CVE-2026-13221, CVE-2026-42496, CVE-2026-8376) - unstructured 0.27.8 (CVE-2026-71428) comes from the upstream merge - pymongo 4.17.0 -> 4.18.2 (CVE-2026-96748, CVE-2026-96749) - Pin en_core_web_sm 3.8.0 (sha256-checked): unstructured >=0.24 otherwise downloads it from GitHub on first use, which fails without egress Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 4 +++- Dockerfile.lite | 5 ++++- requirements.lite.txt | 6 ++++-- requirements.txt | 6 ++++-- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index bef585de..8d7a836e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/Dockerfile.lite b/Dockerfile.lite index 5cc8b5dd..ddc6d572 100644 --- a/Dockerfile.lite +++ b/Dockerfile.lite @@ -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 \ diff --git a/requirements.lite.txt b/requirements.lite.txt index 04aabe4a..6a0c7f02 100644 --- a/requirements.lite.txt +++ b/requirements.lite.txt @@ -11,6 +11,8 @@ pgvector==0.2.5 uvicorn==0.28.0 pypdf==6.19.0 unstructured==0.27.8 +# unstructured >=0.24 lazily downloads this spaCy model from GitHub on first use; install it at build time instead (same pin/hash unstructured 0.27.8 expects). +en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl#sha256=1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85 markdown==3.8.2 networkx==3.2.1 pandas==2.2.1 @@ -20,11 +22,11 @@ python-docx==1.1.2 # .docx header/footer extraction (pandoc drops them) pypandoc==1.15 python-oxmsg==0.0.2 # .msg (Outlook) extraction; also a transitive of unstructured Pillow==12.3.0 # normalize standalone images (incl. multi-page TIFF) for OCR -PyJWT==2.13.0 +PyJWT==2.15.1 asyncpg==0.29.0 python-multipart==0.0.32 aiofiles==24.1.0 -pymongo==4.17.0 +pymongo==4.18.2 langchain-mongodb==0.11.0 cryptography==50.0.1 python-magic==0.4.27 diff --git a/requirements.txt b/requirements.txt index d48412a2..e77f4368 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,8 @@ pgvector==0.2.5 uvicorn==0.28.0 pypdf==6.19.0 unstructured==0.27.8 +# unstructured >=0.24 lazily downloads this spaCy model from GitHub on first use; install it at build time instead (same pin/hash unstructured 0.27.8 expects). +en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl#sha256=1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85 markdown==3.8.2 networkx==3.2.1 pandas==2.2.1 @@ -22,14 +24,14 @@ python-docx==1.1.2 # .docx header/footer extraction (pandoc drops them) pypandoc==1.15 python-oxmsg==0.0.2 # .msg (Outlook) extraction; also a transitive of unstructured Pillow==12.3.0 # normalize standalone images (incl. multi-page TIFF) for OCR -PyJWT==2.13.0 +PyJWT==2.15.1 asyncpg==0.29.0 python-multipart==0.0.32 sentence_transformers==3.1.1 aiofiles==24.1.0 rapidocr-onnxruntime==1.4.4 opencv-python-headless==4.9.0.80 -pymongo==4.17.0 +pymongo==4.18.2 langchain-mongodb==0.11.0 langchain-ollama==1.1.0 langchain-huggingface==1.2.2