feat: air-gapped deployment guide, no implicit downloads

- Ship tiktoken's cl100k_base inside the package and build the encoding
  from it, so token counting never downloads anything.
- Default EMBEDDINGS_CACHE_DIR to <data home>/models instead of FastEmbed's
  temp dir, and read tokenizer.json and repo metadata from that cache, so
  a model downloads once and survives reboots.
- TTS_PROVIDER=none and STT_PROVIDER=none switch the speech features off:
  the endpoints return 404, audio files fail to ingest with a clear
  message, /api/config reports tts_available/stt_available, and the UI
  hides the Speak and microphone buttons.
- Drop the Google Fonts Roboto import from the web UI.
- prefetch-models fills the cache the app reads; verify-offline checks the
  packaged encoding.
- Docs: new Air-Gapped Deployment guide, settings and cache notes.
This commit is contained in:
Alex committed 2026-09-15 17:54:24 +01:00
1 parent ca69d1ea29
commit 7da46c2bea
38 files changed
+100892 -95

No files matched your search

+1 -2
View File
@@ -108,11 +108,10 @@ COPY --from=builder /venv /venv
# EMBEDDINGS_CACHE_DIR / HF_HUB_CACHE FastEmbed models and their tokenizers
# (chunking reads tokenizer.json from
# the same hub-layout snapshot)
# TIKTOKEN_CACHE_DIR cl100k_base for token accounting
# DOCLING_ARTIFACTS_PATH docling's models (docling extra only)
# tiktoken's cl100k_base encoding ships inside the package.
ENV EMBEDDINGS_CACHE_DIR=/app/models \
HF_HUB_CACHE=/app/models \
TIKTOKEN_CACHE_DIR=/app/models/tiktoken \
DOCLING_ARTIFACTS_PATH=/app/models/docling \
HF_HUB_DISABLE_TELEMETRY=1 \
PATH="/venv/bin:$PATH"
+17
View File
@@ -345,6 +345,15 @@ class StoreAttachment(Resource):
return make_response(jsonify({"success": False, "error": "Failed to store attachment"}), 400)
_STT_DISABLED_MESSAGE = "Speech-to-text is disabled on this server."
_TTS_DISABLED_MESSAGE = "Text-to-speech is disabled on this server."
def _feature_disabled(message: str):
"""404 for a speech feature whose provider is set to ``none``."""
return make_response(jsonify({"success": False, "message": message}), 404)
@attachments_ns.route("/stt")
class SpeechToText(Resource):
@api.expect(
@@ -368,6 +377,8 @@ class SpeechToText(Resource):
jsonify({"success": False, "message": "Authentication required"}),
401,
)
if not STTCreator.is_enabled(settings.STT_PROVIDER):
return _feature_disabled(_STT_DISABLED_MESSAGE)
file = request.files.get("file")
if not file or file.filename == "":
@@ -440,6 +451,8 @@ class LiveSpeechToTextStart(Resource):
jsonify({"success": False, "message": "Authentication required"}),
401,
)
if not STTCreator.is_enabled(settings.STT_PROVIDER):
return _feature_disabled(_STT_DISABLED_MESSAGE)
redis_client = _require_live_stt_redis()
if hasattr(redis_client, "status_code"):
@@ -501,6 +514,8 @@ class LiveSpeechToTextChunk(Resource):
jsonify({"success": False, "message": "Authentication required"}),
401,
)
if not STTCreator.is_enabled(settings.STT_PROVIDER):
return _feature_disabled(_STT_DISABLED_MESSAGE)
redis_client = _require_live_stt_redis()
if hasattr(redis_client, "status_code"):
@@ -817,6 +832,8 @@ class TextToSpeech(Resource):
@api.expect(tts_model)
@api.doc(description="Synthesize audio speech from text")
def post(self):
if not TTSCreator.is_enabled(settings.TTS_PROVIDER):
return _feature_disabled(_TTS_DISABLED_MESSAGE)
data = request.get_json()
text = data["text"]
try:
+4
View File
@@ -203,12 +203,16 @@ def health():
@app.route("/api/config")
def get_config():
from docsgpt.graphrag import graphrag_available
from docsgpt.stt.stt_creator import STTCreator
from docsgpt.tts.tts_creator import TTSCreator
response = {
"auth_type": settings.AUTH_TYPE,
"requires_auth": settings.AUTH_TYPE in ["simple_jwt", "session_jwt", "oidc"],
"graphrag_available": graphrag_available(),
"hybrid_available": settings.VECTOR_STORE == "pgvector",
"tts_available": TTSCreator.is_enabled(settings.TTS_PROVIDER),
"stt_available": STTCreator.is_enabled(settings.STT_PROVIDER),
}
if settings.AUTH_TYPE == "oidc":
response["oidc"] = {
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -57,7 +57,8 @@ class Settings(BaseSettings):
# Intra-op threads for the local ONNX runner; None = every core. It scales sub-linearly,
# so several single-threaded workers beat one many-threaded process on the same cores.
EMBEDDINGS_THREADS: Optional[int] = None
EMBEDDINGS_CACHE_DIR: Optional[str] = None # where FastEmbed caches model artifacts
# Embedding models and their tokenizers. Persistent by default: FastEmbed's own default is the temp dir.
EMBEDDINGS_CACHE_DIR: Optional[str] = Field(default_factory=lambda: str(home_dir() / "models"))
# Pooling ("cls"/"mean") and L2 normalisation. Read from the model's own repository;
# set these only for a repository that declares neither, or to override what it declares.
EMBEDDINGS_POOLING: Optional[str] = None
@@ -360,9 +361,9 @@ class Settings(BaseSettings):
# Encryption settings
ENCRYPTION_SECRET_KEY: str = "default-docsgpt-encryption-key"
TTS_PROVIDER: str = "google_tts" # google_tts or elevenlabs
TTS_PROVIDER: str = "google_tts" # google_tts, elevenlabs, or none to switch text-to-speech off
ELEVENLABS_API_KEY: Optional[str] = None
STT_PROVIDER: str = "openai" # openai or faster_whisper
STT_PROVIDER: str = "openai" # openai, faster_whisper, or none to switch speech-to-text off
OPENAI_STT_MODEL: str = "gpt-4o-mini-transcribe"
STT_LANGUAGE: Optional[str] = None
STT_MAX_FILE_SIZE_MB: int = 50
+5 -1
View File
@@ -2,7 +2,7 @@ from pathlib import Path
from typing import Dict, Union
from docsgpt.core.settings import settings
from docsgpt.parser.file.base_parser import BaseParser
from docsgpt.parser.file.base_parser import BaseParser, DocumentParseError
from docsgpt.stt.stt_creator import STTCreator
from docsgpt.stt.upload_limits import enforce_audio_file_size_limit
@@ -17,6 +17,10 @@ class AudioParser(BaseParser):
def parse_file(self, file: Path, errors: str = "ignore") -> Union[str, list[str]]:
_ = errors
if not STTCreator.is_enabled(settings.STT_PROVIDER):
raise DocumentParseError(
f"{file.name}: audio files need speech-to-text, which is disabled (STT_PROVIDER=none)."
)
try:
enforce_audio_file_size_limit(file.stat().st_size)
except OSError:
+9 -7
View File
@@ -217,19 +217,21 @@ class HuggingFaceCounter(TokenCounter):
def _tokenizer_file(repo: str) -> str:
"""Path to ``repo``'s ``tokenizer.json``, from the hub cache when present.
"""Path to ``repo``'s ``tokenizer.json``, from the embedding model cache when present.
A warmed cache (the Docker image bakes the default models) answers without
touching the network. ``hf_hub_download`` would otherwise revalidate the
revision with a HEAD request on every process start, and stall for the
etag timeout on a host that cannot reach huggingface.co.
FastEmbed's snapshot of the repository in ``EMBEDDINGS_CACHE_DIR`` already
holds the file, so a warmed cache answers without touching the network.
``hf_hub_download`` would otherwise revalidate the revision with a HEAD
request on every process start, and stall for the etag timeout on a host
that cannot reach huggingface.co.
"""
from huggingface_hub import hf_hub_download
cache_dir = settings.EMBEDDINGS_CACHE_DIR or None
try:
return hf_hub_download(repo, "tokenizer.json", local_files_only=True)
return hf_hub_download(repo, "tokenizer.json", local_files_only=True, cache_dir=cache_dir)
except Exception: # noqa: BLE001 -- not cached: fetch it
return hf_hub_download(repo, "tokenizer.json")
return hf_hub_download(repo, "tokenizer.json", cache_dir=cache_dir)
def _load_hf_counter(repo: str) -> Optional[HuggingFaceCounter]:
+28 -32
View File
@@ -1,14 +1,13 @@
"""Download the model artifacts a fresh container would otherwise fetch.
"""Download the embedding models a fresh install would otherwise fetch.
Run at image build time so a fresh container does not download on its first
request, and an air-gapped install works at all. Two things are warmed:
* Embedding models, into FastEmbed's cache. Both the legacy and the current
default are baked: an upgraded deployment keeps using mpnet until it runs
``reembed``, while a new one starts on granite.
* tiktoken's ``cl100k_base`` encoding, which token accounting uses on every
chat. tiktoken caches it under ``TIKTOKEN_CACHE_DIR`` (a temp dir when
unset), so the image sets that variable and this warms it.
request, and before moving an install onto a host without internet access.
Both the legacy and the current default embedding model are fetched: an
upgraded deployment keeps using mpnet until it runs ``reembed``, while a new
one starts on granite. Each model lands in ``EMBEDDINGS_CACHE_DIR`` together
with its tokenizer, which chunking reads from the same snapshot. tiktoken's
``cl100k_base`` encoding ships inside the package, so there is nothing to warm
for it.
Usage::
@@ -34,23 +33,6 @@ logger = logging.getLogger("prefetch_models")
#: Fetched when no names are given.
DEFAULT_MODELS = (DEFAULT_LEGACY, DEFAULT_NEW_INSTALL)
#: tiktoken encodings the application loads (``docsgpt.utils.get_encoding``).
TIKTOKEN_ENCODINGS = ("cl100k_base",)
def prefetch_tiktoken(names: Sequence[str] = TIKTOKEN_ENCODINGS) -> List[str]:
"""Warm tiktoken's cache for each encoding in ``names``.
Returns:
The encodings fetched.
"""
import tiktoken
for name in names:
logger.info("Fetching tiktoken encoding %s", name)
tiktoken.get_encoding(name)
return list(names)
def prefetch(names: Sequence[str], cache_dir: Optional[str] = None) -> List[str]:
"""Fetch each named model's artifacts.
@@ -97,6 +79,22 @@ def prefetch(names: Sequence[str], cache_dir: Optional[str] = None) -> List[str]
return fetched
def _cache_dir() -> Optional[str]:
"""``EMBEDDINGS_CACHE_DIR`` from the environment, else the directory the app reads.
The image build sets the variable and copies in only this module's imports,
so settings are loaded only when the variable is absent.
"""
import os
configured = os.environ.get("EMBEDDINGS_CACHE_DIR")
if configured:
return configured
from docsgpt.core.settings import settings
return settings.EMBEDDINGS_CACHE_DIR or None
def _parse(argv: Optional[Sequence[str]], prog: str, description: str) -> list[str]:
import argparse
@@ -109,13 +107,11 @@ def _parse(argv: Optional[Sequence[str]], prog: str, description: str) -> list[s
def main(argv: Optional[Sequence[str]] = None) -> int:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
import os
names = _parse(argv, "prefetch-models", "Download the embedding models and the tiktoken encodings into the local caches.")
fetched = prefetch(names, os.environ.get("EMBEDDINGS_CACHE_DIR"))
logger.info("Cached %d model(s): %s", len(fetched), ", ".join(fetched))
encodings = prefetch_tiktoken()
logger.info("Cached tiktoken encoding(s): %s", ", ".join(encodings))
names = _parse(argv, "prefetch-models", "Download the embedding models and their tokenizers into the model cache.")
cache_dir = _cache_dir()
fetched = prefetch(names, cache_dir)
logger.info("Cached %d model(s) in %s: %s", len(fetched), cache_dir or "FastEmbed's default cache", ", ".join(fetched))
return 0
+5 -9
View File
@@ -21,7 +21,7 @@ import time
from typing import Callable, List, Optional, Sequence
from docsgpt.core.optional_deps import is_available
from docsgpt.scripts.prefetch_models import DEFAULT_MODELS, TIKTOKEN_ENCODINGS
from docsgpt.scripts.prefetch_models import DEFAULT_MODELS
from docsgpt.vectorstore.model_registry import resolve
logger = logging.getLogger("verify_offline")
@@ -50,16 +50,12 @@ def verify(models: Sequence[str]) -> bool:
"""Run every check; return whether all passed."""
ok = True
def tiktoken_check(encoding: str) -> Callable[[], object]:
def run() -> object:
import tiktoken
def tiktoken_check() -> object:
from docsgpt.utils import get_encoding
return f"{len(tiktoken.get_encoding(encoding).encode('hello world'))} tokens"
return f"{len(get_encoding().encode('hello world'))} tokens"
return run
for encoding in TIKTOKEN_ENCODINGS:
ok &= _check(f"tiktoken {encoding}", tiktoken_check(encoding))
ok &= _check("tiktoken cl100k_base", tiktoken_check)
for name in models:
spec = resolve(name)
+10
View File
@@ -1,7 +1,12 @@
from typing import Optional
from docsgpt.stt.base import BaseSTT
from docsgpt.stt.faster_whisper_stt import FasterWhisperSTT
from docsgpt.stt.openai_stt import OpenAISTT
#: STT_PROVIDER value that switches speech-to-text off.
DISABLED = "none"
class STTCreator:
stt_providers = {
@@ -9,6 +14,11 @@ class STTCreator:
"faster_whisper": FasterWhisperSTT,
}
@staticmethod
def is_enabled(stt_type: Optional[str]) -> bool:
"""False when the provider is ``none`` or empty: speech-to-text is switched off."""
return (stt_type or "").strip().lower() not in ("", DISABLED)
@classmethod
def create_stt(cls, stt_type, *args, **kwargs) -> BaseSTT:
stt_class = cls.stt_providers.get(stt_type.lower())
+10 -1
View File
@@ -1,7 +1,11 @@
from typing import Optional
from docsgpt.tts.google_tts import GoogleTTS
from docsgpt.tts.elevenlabs import ElevenlabsTTS
from docsgpt.tts.base import BaseTTS
#: TTS_PROVIDER value that switches text-to-speech off.
DISABLED = "none"
class TTSCreator:
@@ -10,9 +14,14 @@ class TTSCreator:
"elevenlabs": ElevenlabsTTS,
}
@staticmethod
def is_enabled(tts_type: Optional[str]) -> bool:
"""False when the provider is ``none`` or empty: text-to-speech is switched off."""
return (tts_type or "").strip().lower() not in ("", DISABLED)
@classmethod
def create_tts(cls, tts_type, *args, **kwargs)-> BaseTTS:
tts_class = cls.tts_providers.get(tts_type.lower())
if not tts_class:
raise ValueError(f"No tts class found for type {tts_type}")
return tts_class(*args, **kwargs)
return tts_class(*args, **kwargs)
+36 -3
View File
@@ -6,7 +6,7 @@ import logging
import os
import re
import uuid
from pathlib import PurePosixPath
from pathlib import Path, PurePosixPath
from typing import List
import tiktoken
@@ -22,11 +22,44 @@ logger = logging.getLogger(__name__)
_encoding = None
# cl100k_base ships with the package; tiktoken would download it on first use.
_CL100K_BASE_FILE = Path(__file__).resolve().parent / "core" / "encodings" / "cl100k_base.tiktoken"
_CL100K_BASE_SHA256 = "223921b76ee99bde995b7ff738513eef100fb51d18c93597a113bcffe865b2a7"
# Pattern and special tokens exactly as tiktoken_ext.openai_public defines cl100k_base.
_CL100K_BASE_PAT_STR = (
r"""'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}++|\p{N}{1,3}+| ?[^\s\p{L}\p{N}]++[\r\n]*+|\s++$|\s*[\r\n]|\s+(?!\S)|\s"""
)
_CL100K_BASE_SPECIAL_TOKENS = {
"<|endoftext|>": 100257,
"<|fim_prefix|>": 100258,
"<|fim_middle|>": 100259,
"<|fim_suffix|>": 100260,
"<|endofprompt|>": 100276,
}
def get_encoding():
def _load_cl100k_base() -> tiktoken.Encoding:
"""Build cl100k_base from the packaged ranks file."""
data = _CL100K_BASE_FILE.read_bytes()
if hashlib.sha256(data).hexdigest() != _CL100K_BASE_SHA256:
raise ValueError(f"{_CL100K_BASE_FILE} does not match the cl100k_base checksum")
ranks = {
base64.b64decode(token): int(rank)
for token, rank in (line.split() for line in data.splitlines() if line)
}
return tiktoken.Encoding(
name="cl100k_base",
pat_str=_CL100K_BASE_PAT_STR,
mergeable_ranks=ranks,
special_tokens=_CL100K_BASE_SPECIAL_TOKENS,
)
def get_encoding() -> tiktoken.Encoding:
"""The cl100k_base encoding, loaded once per process."""
global _encoding
if _encoding is None:
_encoding = tiktoken.get_encoding("cl100k_base")
_encoding = _load_cl100k_base()
return _encoding
+4 -3
View File
@@ -97,17 +97,18 @@ def _register(model: EmbeddingModel) -> None:
def _read_repo_json(repo: str, filename: str) -> Optional[dict]:
"""Fetch one small JSON from ``repo``, or ``None`` when it is not there.
Reads through the Hugging Face hub cache, so a warmed image finds it
Reads through the embedding model cache, so a warmed install finds it
offline. Every failure -- absent file, no network, malformed JSON -- is the
same answer to the caller: this repository does not tell us.
"""
try:
from huggingface_hub import hf_hub_download
cache_dir = settings.EMBEDDINGS_CACHE_DIR or None
try:
path = hf_hub_download(repo_id=repo, filename=filename, local_files_only=True)
path = hf_hub_download(repo_id=repo, filename=filename, local_files_only=True, cache_dir=cache_dir)
except Exception: # noqa: BLE001 -- not cached: fetch it
path = hf_hub_download(repo_id=repo, filename=filename)
path = hf_hub_download(repo_id=repo, filename=filename, cache_dir=cache_dir)
with open(path, encoding="utf-8") as handle:
return json.load(handle)
except Exception as exc: