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

+75
View File
@@ -1854,6 +1854,81 @@ class TestTextToSpeech:
assert _get_response_status(response) == 400
assert _get_response_json(response)["success"] is False
@patch("docsgpt.api.user.attachments.routes.TTSCreator.create_tts")
def test_tts_disabled_returns_404_without_a_provider(self, mock_create_tts, flask_app):
from docsgpt.api.user.attachments import routes
app = Flask(__name__)
with patch.object(routes.settings, "TTS_PROVIDER", "none"), app.test_request_context(
"/api/tts",
method="POST",
json={"text": "Hello world"},
):
response = routes.TextToSpeech().post()
assert _get_response_status(response) == 404
assert _get_response_json(response) == {
"success": False,
"message": "Text-to-speech is disabled on this server.",
}
mock_create_tts.assert_not_called()
@pytest.mark.unit
class TestSpeechToTextDisabled:
"""STT_PROVIDER=none turns every transcription endpoint off before any provider is built."""
DISABLED = {"success": False, "message": "Speech-to-text is disabled on this server."}
@patch("docsgpt.api.user.attachments.routes.STTCreator.create_stt")
def test_stt_returns_404(self, mock_create_stt, flask_app):
from docsgpt.api.user.attachments import routes
app = Flask(__name__)
with patch.object(routes.settings, "STT_PROVIDER", "none"), app.test_request_context(
"/api/stt",
method="POST",
data={"file": (io.BytesIO(b"audio-bytes"), "clip.wav")},
content_type="multipart/form-data",
):
request.decoded_token = {"sub": "test_user"}
response = routes.SpeechToText().post()
assert _get_response_status(response) == 404
assert _get_response_json(response) == self.DISABLED
mock_create_stt.assert_not_called()
def test_live_stt_start_returns_404(self, flask_app):
from docsgpt.api.user.attachments import routes
app = Flask(__name__)
with patch.object(routes.settings, "STT_PROVIDER", "none"), app.test_request_context(
"/api/stt/live/start", method="POST", json={}
):
request.decoded_token = {"sub": "test_user"}
response = routes.LiveSpeechToTextStart().post()
assert _get_response_status(response) == 404
assert _get_response_json(response) == self.DISABLED
@patch("docsgpt.api.user.attachments.routes.STTCreator.create_stt")
def test_live_stt_chunk_returns_404(self, mock_create_stt, flask_app):
from docsgpt.api.user.attachments import routes
app = Flask(__name__)
with patch.object(routes.settings, "STT_PROVIDER", "none"), app.test_request_context(
"/api/stt/live/chunk",
method="POST",
data={
"session_id": "abc",
"chunk_index": "0",
"file": (io.BytesIO(b"audio-bytes"), "chunk.wav"),
},
content_type="multipart/form-data",
):
request.decoded_token = {"sub": "test_user"}
response = routes.LiveSpeechToTextChunk().post()
assert _get_response_status(response) == 404
assert _get_response_json(response) == self.DISABLED
mock_create_stt.assert_not_called()
# =====================================================================
# Coverage gap tests (lines 136, 256, 330, 337, 443, 457, 560, 590)
+12 -1
View File
@@ -57,12 +57,23 @@ class TestSettingsFollowTheHome:
assert fresh.MILVUS_URI == str(tmp_path.resolve() / "milvus_local.db")
assert fresh.LANCEDB_PATH == str(tmp_path.resolve() / "data" / "lancedb")
def test_model_cache_defaults_under_the_home(self, monkeypatch, tmp_path):
"""FastEmbed's own default is the temp dir, which a reboot wipes."""
from docsgpt.core.settings import Settings
monkeypatch.setenv(paths.HOME_ENV, str(tmp_path))
monkeypatch.delenv("EMBEDDINGS_CACHE_DIR", raising=False)
assert Settings(_env_file=None).EMBEDDINGS_CACHE_DIR == str(tmp_path.resolve() / "models")
def test_env_overrides_still_win(self, monkeypatch, tmp_path):
from docsgpt.core.settings import Settings
monkeypatch.setenv(paths.HOME_ENV, str(tmp_path))
monkeypatch.setenv("LANCEDB_PATH", "/srv/lancedb")
assert Settings(_env_file=None).LANCEDB_PATH == "/srv/lancedb"
monkeypatch.setenv("EMBEDDINGS_CACHE_DIR", "/srv/models")
fresh = Settings(_env_file=None)
assert fresh.LANCEDB_PATH == "/srv/lancedb"
assert fresh.EMBEDDINGS_CACHE_DIR == "/srv/models"
class TestPackageDir:
+17
View File
@@ -1,6 +1,9 @@
from unittest.mock import MagicMock, patch
import pytest
from docsgpt.parser.file.audio_parser import AudioParser
from docsgpt.parser.file.base_parser import DocumentParseError
from docsgpt.parser.file.bulk import get_default_file_extractor
from docsgpt.stt.upload_limits import AudioFileTooLargeError
@@ -44,6 +47,20 @@ def test_audio_parser_transcribes_file(
)
@patch("docsgpt.parser.file.audio_parser.STTCreator.create_stt")
@patch("docsgpt.parser.file.audio_parser.settings")
def test_audio_parser_refuses_when_speech_to_text_is_disabled(
mock_settings, mock_create_stt, tmp_path
):
mock_settings.STT_PROVIDER = "none"
audio_file = tmp_path / "meeting.wav"
audio_file.write_bytes(b"audio-bytes")
with pytest.raises(DocumentParseError, match="STT_PROVIDER"):
AudioParser().parse_file(audio_file)
mock_create_stt.assert_not_called()
@patch("docsgpt.stt.upload_limits.settings")
def test_audio_parser_rejects_oversized_files(mock_limit_settings, tmp_path):
mock_limit_settings.STT_MAX_FILE_SIZE_MB = 1
+19 -2
View File
@@ -309,7 +309,7 @@ class TestTokenizerFile:
def test_cache_hit_makes_no_online_call(self, monkeypatch):
calls = []
def fake_download(repo, filename, local_files_only=False):
def fake_download(repo, filename, local_files_only=False, cache_dir=None):
calls.append(local_files_only)
return "/cache/tokenizer.json"
@@ -322,7 +322,7 @@ class TestTokenizerFile:
def test_cache_miss_falls_back_to_online(self, monkeypatch):
calls = []
def fake_download(repo, filename, local_files_only=False):
def fake_download(repo, filename, local_files_only=False, cache_dir=None):
calls.append(local_files_only)
if local_files_only:
raise FileNotFoundError("not cached")
@@ -333,3 +333,20 @@ class TestTokenizerFile:
monkeypatch.setitem(sys.modules, "huggingface_hub", fake_hub)
assert tokenization._tokenizer_file("org/model") == "/downloaded/tokenizer.json"
assert calls == [True, False]
def test_reads_the_embedding_model_cache(self, monkeypatch):
"""FastEmbed's snapshot already holds tokenizer.json; a second cache downloads it again."""
cache_dirs = []
def fake_download(repo, filename, local_files_only=False, cache_dir=None):
cache_dirs.append(cache_dir)
if local_files_only:
raise FileNotFoundError("not cached")
return "/models/tokenizer.json"
fake_hub = types.ModuleType("huggingface_hub")
fake_hub.hf_hub_download = fake_download
monkeypatch.setitem(sys.modules, "huggingface_hub", fake_hub)
monkeypatch.setattr(tokenization.settings, "EMBEDDINGS_CACHE_DIR", "/models")
tokenization._tokenizer_file("org/model")
assert cache_dirs == ["/models", "/models"]
+8 -13
View File
@@ -95,17 +95,12 @@ class TestMain:
prefetch_models.main(["granite-97m"])
assert spy.call_args.args[1] == "/app/models"
def test_cache_dir_defaults_to_the_one_the_app_reads(self, fake_fastembed, monkeypatch):
"""Without the variable, models must land where the running app looks for them."""
from docsgpt.core.settings import settings
class TestPrefetchTiktoken:
def test_warms_every_listed_encoding(self):
"""The image sets TIKTOKEN_CACHE_DIR; warming fills it at build time."""
fake = MagicMock()
module = types.ModuleType("tiktoken")
module.get_encoding = fake
with patch.dict(sys.modules, {"tiktoken": module}):
fetched = prefetch_models.prefetch_tiktoken()
assert fetched == list(prefetch_models.TIKTOKEN_ENCODINGS)
assert [c.args[0] for c in fake.call_args_list] == list(prefetch_models.TIKTOKEN_ENCODINGS)
def test_cl100k_is_the_encoding_token_counting_uses(self):
assert "cl100k_base" in prefetch_models.TIKTOKEN_ENCODINGS
monkeypatch.delenv("EMBEDDINGS_CACHE_DIR", raising=False)
monkeypatch.setattr(settings, "EMBEDDINGS_CACHE_DIR", "/home/docsgpt/models")
with patch.object(prefetch_models, "prefetch", return_value=[]) as spy:
prefetch_models.main(["granite-97m"])
assert spy.call_args.args[1] == "/home/docsgpt/models"
+4 -1
View File
@@ -8,8 +8,11 @@ from docsgpt.scripts import verify_offline
def _fake_tiktoken(monkeypatch):
"""The check must exercise the app's own loader, which reads the packaged encoding."""
encoding = types.SimpleNamespace(name="cl100k_base", encode=lambda text: [1, 2])
monkeypatch.setattr("docsgpt.utils.get_encoding", lambda: encoding)
module = types.ModuleType("tiktoken")
module.get_encoding = lambda name: types.SimpleNamespace(encode=lambda text: [1, 2])
module.get_encoding = lambda name: (_ for _ in ()).throw(AssertionError("downloaded via tiktoken"))
monkeypatch.setitem(sys.modules, "tiktoken", module)
+10
View File
@@ -51,6 +51,16 @@ def test_stt_type_case_insensitivity(stt_creator):
assert result == instance
@pytest.mark.parametrize("value", ["none", "NONE", " none ", "", None])
def test_is_enabled_false_when_switched_off(value):
assert STTCreator.is_enabled(value) is False
@pytest.mark.parametrize("value", ["openai", "faster_whisper"])
def test_is_enabled_for_a_provider(value):
assert STTCreator.is_enabled(value) is True
def test_stt_providers_integrity(stt_creator):
providers = stt_creator.stt_providers
assert "openai" in providers
+22
View File
@@ -95,6 +95,28 @@ class TestConfigRoute:
data = json.loads(response.data)
assert data["hybrid_available"] is False
@pytest.mark.unit
def test_speech_features_available_by_default(self, client):
with patch("docsgpt.app.settings") as mock_settings:
mock_settings.AUTH_TYPE = None
mock_settings.TTS_PROVIDER = "google_tts"
mock_settings.STT_PROVIDER = "openai"
response = client.get("/api/config")
data = json.loads(response.data)
assert data["tts_available"] is True
assert data["stt_available"] is True
@pytest.mark.unit
def test_speech_features_unavailable_when_disabled(self, client):
with patch("docsgpt.app.settings") as mock_settings:
mock_settings.AUTH_TYPE = None
mock_settings.TTS_PROVIDER = "none"
mock_settings.STT_PROVIDER = "none"
response = client.get("/api/config")
data = json.loads(response.data)
assert data["tts_available"] is False
assert data["stt_available"] is False
@pytest.mark.unit
def test_oidc_config_exposes_login_paths(self, client):
with patch("docsgpt.app.settings") as mock_settings:
+41
View File
@@ -40,6 +40,47 @@ class TestGetEncoding:
enc2 = get_encoding()
assert enc1 is enc2
@pytest.mark.unit
def test_loads_the_packaged_encoding_without_network(self, monkeypatch):
"""Token counting runs on every chat; it must never download the encoding."""
import tiktoken
import tiktoken.load
from docsgpt import utils
def no_download(*args, **kwargs):
raise AssertionError("tiktoken tried to fetch cl100k_base")
monkeypatch.setattr(utils, "_encoding", None)
monkeypatch.setattr(tiktoken.load, "read_file", no_download)
monkeypatch.setattr(tiktoken, "get_encoding", no_download)
assert utils.get_encoding().encode("hello world") == [15339, 1917]
@pytest.mark.unit
def test_packaged_encoding_matches_the_tiktoken_definition(self, monkeypatch):
from tiktoken_ext import openai_public
from docsgpt import utils
monkeypatch.setattr(utils, "_encoding", None)
monkeypatch.setattr(openai_public, "load_tiktoken_bpe", lambda *args, **kwargs: {})
reference = openai_public.cl100k_base()
encoding = utils.get_encoding()
assert encoding.name == reference["name"]
assert encoding._pat_str == reference["pat_str"]
assert encoding._special_tokens == reference["special_tokens"]
@pytest.mark.unit
def test_corrupt_packaged_encoding_is_rejected(self, monkeypatch, tmp_path):
from docsgpt import utils
corrupt = tmp_path / "cl100k_base.tiktoken"
corrupt.write_bytes(b"IQ== 0\n")
monkeypatch.setattr(utils, "_encoding", None)
monkeypatch.setattr(utils, "_CL100K_BASE_FILE", corrupt)
with pytest.raises(ValueError, match="cl100k_base"):
utils.get_encoding()
class TestGetGptModel:
+10
View File
@@ -53,6 +53,16 @@ def test_tts_type_case_insensitivity(tts_creator):
assert result == instance
@pytest.mark.parametrize("value", ["none", "NONE", " none ", "", None])
def test_is_enabled_false_when_switched_off(value):
assert TTSCreator.is_enabled(value) is False
@pytest.mark.parametrize("value", ["google_tts", "elevenlabs"])
def test_is_enabled_for_a_provider(value):
assert TTSCreator.is_enabled(value) is True
def test_tts_providers_integrity(tts_creator):
providers = tts_creator.tts_providers
assert "google_tts" in providers
@@ -1,5 +1,7 @@
"""Local embeddings run through FastEmbed, configured from the model registry."""
import sys
import types
from unittest.mock import MagicMock, patch
import numpy as np
@@ -9,6 +11,9 @@ from docsgpt.vectorstore import embeddings_local
from docsgpt.vectorstore.embeddings_local import EmbeddingsWrapper
from docsgpt.vectorstore.model_registry import GRANITE_97M, MPNET
# The autouse fixture below replaces this for every test; keep the real one.
_READ_REPO_JSON = embeddings_local._read_repo_json
@pytest.fixture(autouse=True)
def _clear_registration():
@@ -142,6 +147,23 @@ class TestSettingsPassthrough:
EmbeddingsWrapper(MPNET.name)
assert text_embedding.call_args.kwargs["cache_dir"] == "/models"
def test_repo_metadata_reads_the_embedding_model_cache(self, monkeypatch, tmp_path):
"""Pooling metadata lives beside the model, not in a second hub cache."""
config = tmp_path / "config.json"
config.write_text('{"pooling_mode_cls_token": true}')
calls = []
def fake_download(repo_id, filename, local_files_only=False, cache_dir=None):
calls.append((local_files_only, cache_dir))
return str(config)
fake_hub = types.ModuleType("huggingface_hub")
fake_hub.hf_hub_download = fake_download
monkeypatch.setitem(sys.modules, "huggingface_hub", fake_hub)
monkeypatch.setattr(embeddings_local.settings, "EMBEDDINGS_CACHE_DIR", "/models")
assert _READ_REPO_JSON("org/model", "1_Pooling/config.json") == {"pooling_mode_cls_token": True}
assert calls == [(True, "/models")]
class TestEmbedding:
def test_embed_documents_returns_plain_lists(self, fake_fastembed):