- Resolve the (connection, profile) once per dictation / conversation and use it for the warm-up, the transcription (client-direct config + relay) and the release; untagged halves stay untagged instead of re-reading ambient scope. - Recorder and conversation drop any continuation (warm-up, transcript, submit) that settles after unmount, end(), or a newer start(). - syncSttLease: every settled-apart acquire is sent (no client freshness window); an acquire coalesces only onto a queued acquire that is still the latest intent, so on/off/on ends acquired and waits for its own acquire. - Idle-unload watcher applies the stt.local policy of the caller that last loaded/used the shared model, re-read in that caller's context each cycle.
391 lines
15 KiB
Python
391 lines
15 KiB
Python
"""``POST /api/audio/stt-lease`` — desktop voice input as STT warm-up/release.
|
|
|
|
The desktop acquires a lease when the mic opens so the backend pre-loads the
|
|
configured local faster-whisper model while the user is still speaking; the
|
|
transcription request then starts hot instead of paying the cold load inside
|
|
its timeout (issue #105955). Releasing the last lease drops the refcount but
|
|
— unlike TTS — never unloads the model: the engine is shared with the
|
|
gateway/CLI surfaces in the same backend process.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
|
|
from hermes_constants import get_hermes_home
|
|
from hermes_cli import profiles
|
|
|
|
default_home = get_hermes_home()
|
|
profiles_root = default_home / "profiles"
|
|
worker_home = profiles_root / "worker_beta"
|
|
for home in (default_home, worker_home):
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
|
|
(worker_home / ".env").write_text("", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
|
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
|
|
return {"default": default_home, "worker_beta": worker_home}
|
|
|
|
|
|
@pytest.fixture
|
|
def client(monkeypatch, isolated_profiles):
|
|
try:
|
|
from starlette.testclient import TestClient
|
|
except ImportError:
|
|
pytest.skip("fastapi/starlette not installed")
|
|
|
|
import hermes_state
|
|
from hermes_constants import get_hermes_home
|
|
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
|
|
|
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
|
c = TestClient(app)
|
|
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
|
return c
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_stt_state():
|
|
from tools import stt_lease, transcription_tools
|
|
|
|
stt_lease._reset_stt_leases_for_tests()
|
|
saved_model, saved_name = transcription_tools._local_model, transcription_tools._local_model_name
|
|
transcription_tools._local_model, transcription_tools._local_model_name = None, None
|
|
yield
|
|
stt_lease._reset_stt_leases_for_tests()
|
|
transcription_tools._local_model, transcription_tools._local_model_name = saved_model, saved_name
|
|
|
|
|
|
def _local_cfg(monkeypatch, model="tiny"):
|
|
from tools import transcription_tools
|
|
|
|
monkeypatch.setattr(transcription_tools, "_HAS_FASTER_WHISPER", True)
|
|
monkeypatch.setattr(
|
|
transcription_tools, "_load_stt_config", lambda: {"local": {"model": model}}
|
|
)
|
|
# Explicit local provider without going through the selection-file probe.
|
|
monkeypatch.setattr(transcription_tools, "_get_provider", lambda cfg: "local")
|
|
|
|
|
|
def test_active_acquires_and_warms(client, monkeypatch):
|
|
from tools import stt_lease
|
|
|
|
warmed = []
|
|
monkeypatch.setattr(
|
|
stt_lease,
|
|
"warm_stt_provider",
|
|
lambda cfg=None, provider=None: warmed.append(1)
|
|
or {"provider": "local", "warmed": True, "action": "loaded"},
|
|
)
|
|
|
|
resp = client.post("/api/audio/stt-lease", json={"lease": "desktop:voice-input:abc", "active": True})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["ok"] is True
|
|
assert body["lease"] == "desktop:voice-input:abc"
|
|
assert body["active"] is True
|
|
assert body["leases"] == 1
|
|
assert body["action"] == "loaded"
|
|
assert warmed == [1]
|
|
assert stt_lease.stt_lease_holders() == ["desktop:voice-input:abc"]
|
|
|
|
|
|
def test_inactive_releases_without_unloading(client, monkeypatch):
|
|
from tools import stt_lease, transcription_tools
|
|
|
|
monkeypatch.setattr(
|
|
stt_lease, "warm_stt_provider", lambda cfg=None, provider=None: {"action": "noop", "warmed": False, "provider": "openai"}
|
|
)
|
|
client.post("/api/audio/stt-lease", json={"lease": "desktop:voice-input:a", "active": True})
|
|
client.post("/api/audio/stt-lease", json={"lease": "desktop:voice-input:b", "active": True})
|
|
|
|
# A resident model shared with other surfaces must survive the releases.
|
|
sentinel = object()
|
|
transcription_tools._local_model, transcription_tools._local_model_name = sentinel, "tiny"
|
|
|
|
first = client.post("/api/audio/stt-lease", json={"lease": "desktop:voice-input:a", "active": False}).json()
|
|
assert first["leases"] == 1
|
|
assert transcription_tools._local_model is sentinel
|
|
|
|
last = client.post("/api/audio/stt-lease", json={"lease": "desktop:voice-input:b", "active": False}).json()
|
|
assert last["leases"] == 0
|
|
assert transcription_tools._local_model is sentinel
|
|
|
|
|
|
def test_warm_failure_is_reported_not_an_http_error(client, monkeypatch):
|
|
from tools import stt_lease
|
|
|
|
def _boom(cfg=None, provider=None):
|
|
raise RuntimeError("engine exploded")
|
|
|
|
monkeypatch.setattr(stt_lease, "warm_stt_provider", _boom)
|
|
resp = client.post("/api/audio/stt-lease", json={"lease": "desktop:voice-input:abc", "active": True})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["ok"] is True
|
|
assert body["action"] == "error"
|
|
assert "engine exploded" in body["error"]
|
|
|
|
|
|
def test_blank_lease_rejected(client):
|
|
resp = client.post("/api/audio/stt-lease", json={"lease": " ", "active": True})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_warm_loads_configured_local_model_into_transcription_slot(monkeypatch):
|
|
from tools import stt_lease, transcription_tools
|
|
|
|
_local_cfg(monkeypatch, model="small")
|
|
loaded = []
|
|
monkeypatch.setattr(
|
|
transcription_tools,
|
|
"_get_or_load_local_model",
|
|
lambda name, cfg: loaded.append((name, cfg)) or object(),
|
|
)
|
|
|
|
result = stt_lease.warm_stt_provider()
|
|
|
|
assert result["warmed"] is True
|
|
assert result["provider"] == "local"
|
|
assert result["action"] == "loaded"
|
|
# Same name transcription resolves (config model, normalized) and the
|
|
# same loader transcription reads — not a shadow cache.
|
|
assert loaded and loaded[0][0] == "small"
|
|
assert "elapsed_ms" in result
|
|
|
|
|
|
def test_warm_reports_cached_when_model_already_resident(monkeypatch):
|
|
from tools import stt_lease, transcription_tools
|
|
|
|
_local_cfg(monkeypatch, model="tiny")
|
|
sentinel = object()
|
|
transcription_tools._local_model, transcription_tools._local_model_name = sentinel, "tiny"
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
transcription_tools,
|
|
"_get_or_load_local_model",
|
|
lambda name, cfg: calls.append(name) or sentinel,
|
|
)
|
|
|
|
result = stt_lease.warm_stt_provider()
|
|
|
|
assert result == {
|
|
"provider": "local",
|
|
"warmed": True,
|
|
"action": "cached",
|
|
"elapsed_ms": result["elapsed_ms"],
|
|
}
|
|
|
|
|
|
def test_warm_is_noop_for_cloud_providers(monkeypatch):
|
|
from tools import stt_lease, transcription_tools
|
|
|
|
monkeypatch.setattr(transcription_tools, "_load_stt_config", lambda: {"provider": "openai"})
|
|
monkeypatch.setattr(transcription_tools, "_get_provider", lambda cfg: "openai")
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
transcription_tools, "_get_or_load_local_model", lambda name, cfg: calls.append(name)
|
|
)
|
|
|
|
result = stt_lease.warm_stt_provider()
|
|
|
|
assert result["warmed"] is False
|
|
assert result["action"] == "noop"
|
|
assert calls == []
|
|
|
|
|
|
def test_warm_never_raises_on_engine_failure(monkeypatch):
|
|
from tools import stt_lease, transcription_tools
|
|
|
|
_local_cfg(monkeypatch)
|
|
|
|
def _boom(name, cfg):
|
|
raise RuntimeError("CUDA exploded")
|
|
|
|
monkeypatch.setattr(transcription_tools, "_get_or_load_local_model", _boom)
|
|
|
|
result = stt_lease.warm_stt_provider()
|
|
|
|
assert result["warmed"] is False
|
|
assert result["action"] == "error"
|
|
assert "CUDA exploded" in result["error"]
|
|
|
|
|
|
def test_warm_only_load_starts_idle_unload_watcher(monkeypatch):
|
|
"""A model loaded ONLY by warm-up (mic open → cancel, no transcription)
|
|
must still be governed by ``stt.local.unload_after_idle_seconds``.
|
|
|
|
Pre-fix, ``_start_idle_unload_watcher`` only ran after a successful
|
|
``_transcribe_local``, so the warm-only path left the model resident with
|
|
no watcher until some later transcription happened. The regression
|
|
executes the real acquire/load path with a fresh process-like state and
|
|
asserts the watcher started and eventually evicts the model.
|
|
"""
|
|
from tools import stt_lease, transcription_tools
|
|
|
|
_local_cfg(monkeypatch)
|
|
monkeypatch.setattr(
|
|
transcription_tools,
|
|
"_load_stt_config",
|
|
lambda: {"local": {"model": "tiny", "unload_after_idle_seconds": 1}},
|
|
)
|
|
|
|
loaded = []
|
|
monkeypatch.setattr(transcription_tools, "_IDLE_UNLOAD_CHECK_INTERVAL", 0.01)
|
|
monkeypatch.setattr(transcription_tools, "_local_model", None)
|
|
monkeypatch.setattr(transcription_tools, "_local_model_name", None)
|
|
monkeypatch.setattr(transcription_tools, "_idle_unload_thread", None)
|
|
# Backdate the activity so the first watcher cycle (0.01s) already sees
|
|
# the configured idle window as elapsed.
|
|
monkeypatch.setattr(transcription_tools, "_last_transcription_time", 0.0)
|
|
real_model = object()
|
|
monkeypatch.setattr(
|
|
transcription_tools,
|
|
"_get_or_load_local_model",
|
|
lambda name, cfg: loaded.append(name) or real_model,
|
|
)
|
|
# The idle watcher reads the module global, not the warm-up's local ref,
|
|
# so mirror the singleton the real loader would have written.
|
|
transcription_tools._local_model = real_model
|
|
transcription_tools._local_model_name = "tiny"
|
|
|
|
result = stt_lease.warm_stt_provider()
|
|
|
|
assert result["warmed"] is True
|
|
assert loaded == ["tiny"]
|
|
# The watcher is running (fresh process: no prior watcher thread).
|
|
assert transcription_tools._idle_unload_thread is not None
|
|
assert transcription_tools._idle_unload_thread.is_alive()
|
|
# ...and eventually enforces the configured idle lifetime.
|
|
for _ in range(200):
|
|
if transcription_tools._local_model is None:
|
|
break
|
|
time.sleep(0.02)
|
|
assert transcription_tools._local_model is None
|
|
|
|
|
|
def test_warm_reacquire_after_eviction_restarts_watcher(monkeypatch):
|
|
"""After a prior watcher evicted its model and exited, a warm-up that
|
|
reloads it must start a NEW watcher — the old thread is gone."""
|
|
from tools import stt_lease, transcription_tools
|
|
|
|
_local_cfg(monkeypatch)
|
|
monkeypatch.setattr(
|
|
transcription_tools,
|
|
"_load_stt_config",
|
|
lambda: {"local": {"model": "tiny", "unload_after_idle_seconds": 60}},
|
|
)
|
|
monkeypatch.setattr(transcription_tools, "_IDLE_UNLOAD_CHECK_INTERVAL", 0.5)
|
|
# Simulate the post-eviction world: model gone, previous watcher exited.
|
|
monkeypatch.setattr(transcription_tools, "_local_model", None)
|
|
monkeypatch.setattr(transcription_tools, "_local_model_name", None)
|
|
monkeypatch.setattr(
|
|
transcription_tools, "_idle_unload_thread", type("Dead", (), {"is_alive": lambda self: False})()
|
|
)
|
|
|
|
real_model = object()
|
|
monkeypatch.setattr(
|
|
transcription_tools, "_get_or_load_local_model", lambda name, cfg: real_model
|
|
)
|
|
transcription_tools._local_model = real_model
|
|
transcription_tools._local_model_name = "tiny"
|
|
|
|
result = stt_lease.warm_stt_provider()
|
|
|
|
assert result["warmed"] is True
|
|
# A NEW watcher is running for the reloaded model.
|
|
assert transcription_tools._idle_unload_thread is not None
|
|
assert transcription_tools._idle_unload_thread.is_alive()
|
|
# Clean up the daemon so it does not leak into other tests.
|
|
transcription_tools._idle_unload_stop.set()
|
|
transcription_tools._idle_unload_thread.join(timeout=2)
|
|
transcription_tools._idle_unload_stop.clear()
|
|
transcription_tools._local_model = None
|
|
transcription_tools._local_model_name = None
|
|
transcription_tools._idle_unload_thread = None
|
|
|
|
|
|
def _watch_under_profiles(monkeypatch, *owners):
|
|
"""Start/refresh the idle watcher from each (profile, idle_seconds) caller in order, each
|
|
inside its own context — the same ContextVar mechanism a request's profile scope uses."""
|
|
import contextvars
|
|
import threading
|
|
|
|
from tools import transcription_tools
|
|
|
|
class _GatedStop:
|
|
"""The watcher's first tick waits until every owner has been registered."""
|
|
|
|
def __init__(self):
|
|
self.event = threading.Event()
|
|
self.gate = threading.Event()
|
|
|
|
def wait(self, timeout):
|
|
self.gate.wait(2)
|
|
return self.event.wait(timeout)
|
|
|
|
def set(self):
|
|
self.event.set()
|
|
self.gate.set()
|
|
|
|
def clear(self):
|
|
self.event.clear()
|
|
|
|
stop = _GatedStop()
|
|
monkeypatch.setattr(transcription_tools, "_idle_unload_stop", stop)
|
|
|
|
profile = contextvars.ContextVar("test_stt_profile", default="launch")
|
|
idle_by_profile = {"launch": 3600, **dict(owners)}
|
|
monkeypatch.setattr(
|
|
transcription_tools,
|
|
"_load_stt_config",
|
|
lambda: {"local": {"model": "tiny", "unload_after_idle_seconds": idle_by_profile[profile.get()]}},
|
|
)
|
|
monkeypatch.setattr(transcription_tools, "_IDLE_UNLOAD_CHECK_INTERVAL", 0.01)
|
|
monkeypatch.setattr(transcription_tools, "_idle_unload_thread", None)
|
|
monkeypatch.setattr(transcription_tools, "_last_transcription_time", time.monotonic() - 2)
|
|
transcription_tools._local_model = object()
|
|
transcription_tools._local_model_name = "tiny"
|
|
|
|
for name, idle in owners:
|
|
def _call(name=name, idle=idle):
|
|
profile.set(name)
|
|
transcription_tools._start_idle_unload_watcher(idle)
|
|
|
|
contextvars.copy_context().run(_call)
|
|
|
|
stop.gate.set()
|
|
for _ in range(50):
|
|
if transcription_tools._local_model is None:
|
|
break
|
|
time.sleep(0.01)
|
|
evicted = transcription_tools._local_model is None
|
|
|
|
stop.set()
|
|
if transcription_tools._idle_unload_thread is not None:
|
|
transcription_tools._idle_unload_thread.join(timeout=2)
|
|
transcription_tools._local_model = None
|
|
transcription_tools._local_model_name = None
|
|
transcription_tools._idle_unload_thread = None
|
|
return evicted
|
|
|
|
|
|
def test_idle_watcher_applies_the_profile_that_warmed_the_model(monkeypatch):
|
|
"""#128668 review: the watcher thread has no request scope of its own. It must read
|
|
``unload_after_idle_seconds`` in the scope of the caller that loaded the model, not the
|
|
launch profile's (3600s here), or a 1s policy never fires."""
|
|
assert _watch_under_profiles(monkeypatch, ("worker_beta", 1)) is True
|
|
|
|
|
|
def test_idle_watcher_follows_the_latest_owner_of_the_shared_model(monkeypatch):
|
|
"""A → B → A: the live watcher adopts each new owner's policy instead of keeping the one
|
|
it was started under."""
|
|
assert _watch_under_profiles(monkeypatch, ("alpha", 3600), ("worker_beta", 1)) is True
|
|
assert _watch_under_profiles(monkeypatch, ("worker_beta", 1), ("alpha", 3600)) is False
|