+
+
{webhookUrl}
diff --git a/frontend/src/preferences/preferenceSlice.ts b/frontend/src/preferences/preferenceSlice.ts
index e12a144b..f53a8b9b 100644
--- a/frontend/src/preferences/preferenceSlice.ts
+++ b/frontend/src/preferences/preferenceSlice.ts
@@ -44,6 +44,10 @@ export interface Preference {
// not act before the first /me response (avoids a first-paint admin bounce).
roles: string[];
rolesResolved: boolean;
+ // Speech features the server has switched on (/api/config). True until the
+ // config loads, so a backend without these flags keeps showing the controls.
+ ttsAvailable: boolean;
+ sttAvailable: boolean;
}
const initialState: Preference = {
@@ -74,6 +78,8 @@ const initialState: Preference = {
agentFolders: null,
roles: [],
rolesResolved: false,
+ ttsAvailable: true,
+ sttAvailable: true,
};
export const prefSlice = createSlice({
@@ -142,6 +148,13 @@ export const prefSlice = createSlice({
state.roles = [];
state.rolesResolved = false;
},
+ setSpeechAvailability: (
+ state,
+ action: PayloadAction<{ tts: boolean; stt: boolean }>,
+ ) => {
+ state.ttsAvailable = action.payload.tts;
+ state.sttAvailable = action.payload.stt;
+ },
},
});
@@ -166,6 +179,7 @@ export const {
setAgentFolders,
setRoles,
clearRoles,
+ setSpeechAvailability,
} = prefSlice.actions;
export default prefSlice.reducer;
@@ -314,3 +328,7 @@ export const selectRolesResolved = (state: RootState) =>
state.preference.rolesResolved;
export const selectIsAdmin = (state: RootState) =>
state.preference.roles.includes('admin');
+export const selectTtsAvailable = (state: RootState) =>
+ state.preference.ttsAvailable;
+export const selectSttAvailable = (state: RootState) =>
+ state.preference.sttAvailable;
diff --git a/frontend/src/store.ts b/frontend/src/store.ts
index ff3330af..c9a72491 100644
--- a/frontend/src/store.ts
+++ b/frontend/src/store.ts
@@ -56,6 +56,8 @@ const preloadedState: { preference: Preference } = {
agentFolders: null,
roles: [],
rolesResolved: false,
+ ttsAvailable: true,
+ sttAvailable: true,
},
};
const store = configureStore({
diff --git a/tests/api/user/attachments/test_routes.py b/tests/api/user/attachments/test_routes.py
index 0165c4d0..8f2f2201 100644
--- a/tests/api/user/attachments/test_routes.py
+++ b/tests/api/user/attachments/test_routes.py
@@ -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)
diff --git a/tests/core/test_paths.py b/tests/core/test_paths.py
index e625626b..a1c80acd 100644
--- a/tests/core/test_paths.py
+++ b/tests/core/test_paths.py
@@ -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:
diff --git a/tests/parser/file/test_audio_parser.py b/tests/parser/file/test_audio_parser.py
index 6109c74d..5933b1d2 100644
--- a/tests/parser/file/test_audio_parser.py
+++ b/tests/parser/file/test_audio_parser.py
@@ -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
diff --git a/tests/parser/test_tokenization.py b/tests/parser/test_tokenization.py
index 9a254e31..be751f1a 100644
--- a/tests/parser/test_tokenization.py
+++ b/tests/parser/test_tokenization.py
@@ -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"]
diff --git a/tests/scripts/test_prefetch_models.py b/tests/scripts/test_prefetch_models.py
index 3deda43b..a0417835 100644
--- a/tests/scripts/test_prefetch_models.py
+++ b/tests/scripts/test_prefetch_models.py
@@ -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"
diff --git a/tests/scripts/test_verify_offline.py b/tests/scripts/test_verify_offline.py
index 11f6114b..e1f95b78 100644
--- a/tests/scripts/test_verify_offline.py
+++ b/tests/scripts/test_verify_offline.py
@@ -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)
diff --git a/tests/stt/test_stt_creator.py b/tests/stt/test_stt_creator.py
index e7de3aee..a1624d50 100644
--- a/tests/stt/test_stt_creator.py
+++ b/tests/stt/test_stt_creator.py
@@ -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
diff --git a/tests/test_app_routes.py b/tests/test_app_routes.py
index c4790060..552c1272 100644
--- a/tests/test_app_routes.py
+++ b/tests/test_app_routes.py
@@ -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:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 6c1f3911..2bdcf9a6 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -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:
diff --git a/tests/tts/test_tts_creator.py b/tests/tts/test_tts_creator.py
index 62284973..fe19f3f2 100644
--- a/tests/tts/test_tts_creator.py
+++ b/tests/tts/test_tts_creator.py
@@ -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
diff --git a/tests/vectorstore/test_embeddings_local.py b/tests/vectorstore/test_embeddings_local.py
index 6f5176d5..5d61a72b 100644
--- a/tests/vectorstore/test_embeddings_local.py
+++ b/tests/vectorstore/test_embeddings_local.py
@@ -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):