mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 00:14:02 +00:00
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:
1 parent
ca69d1ea29
commit
7da46c2bea
38 files changed
+100892
-95
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user