Merge pull request #80 from yusufipk/claude/transcript-cleaning-turkish-chars-6d8084

Unload a local model that has been sitting unused
This commit is contained in:
Yusuf İpek
2026-09-05 12:20:54 +03:00
committed by GitHub
13 changed files with 448 additions and 20 deletions
+2 -1
View File
@@ -161,7 +161,8 @@ running.
- **It all runs on this machine by default.** Speech to text on whisper.cpp and
cleanup on llama.cpp, neither installed beforehand: the settings window fetches
the program and the model, verifies the sha256 and refuses a download published
without one, then keeps a server alive while you dictate. The model list is
without one, then keeps a server alive while you dictate and hands the memory
back once it has sat unused for ten minutes. The model list is
grouped by model rather than by file size, and the row this machine's memory
and graphics can take is marked. The graphics card is
reached through CUDA, ROCm or Vulkan where the build allows. No key, no
+2 -1
View File
@@ -158,7 +158,8 @@ olmasını ister.
whisper.cpp, temizleme llama.cpp üzerinde; ikisini de önceden kurman gerekmez:
ayarlar penceresi programı ve modeli indirir, sha256'sını doğrular,
checksum'suz yayınlanmış bir indirmeyi reddeder, sen dikte ettikçe sunucuyu
ayakta tutar. Model listesi dosya boyutuna değil modele göre gruplanır ve bu
ayakta tutar ve on dakika kullanılmayan modelin belleğini geri verir. Model
listesi dosya boyutuna değil modele göre gruplanır ve bu
makinenin belleğine ve ekran kartına uyan satır işaretlenir. Derleme
destekliyorsa ekran kartına CUDA, ROCm ya da Vulkan
üzerinden ulaşılır. Anahtar yok, hesap yok, makineden çıkan bir şey yok.
+10 -5
View File
@@ -387,12 +387,17 @@ def _transcribe_request(target, audio_path, language, prompt, response_format,
if granularity:
fields.append(("timestamp_granularities[]", granularity))
body, ctype = _multipart(fields, "file", audio_path)
# An hour of meeting takes the local server a while, and the idle unload has
# to count that as the model being used rather than as nobody wanting it.
held = (ggml.whisper.busy() if target.provider == "local"
else contextlib.nullcontext())
try:
return _request(
f"{target.base_url.rstrip('/')}/audio/transcriptions", body,
_headers(target.provider, target.api_key, ctype), timeout=timeout,
aborter=aborter,
)
with held:
return _request(
f"{target.base_url.rstrip('/')}/audio/transcriptions", body,
_headers(target.provider, target.api_key, ctype), timeout=timeout,
aborter=aborter,
)
except ApiError as exc:
if target.provider == "local":
raise local_failure(target.service, ggml.whisper, exc) from None
+36
View File
@@ -289,6 +289,11 @@ class Dikte:
self.update_action.triggered.connect(self.open_release_page)
self.menu.addAction(self.update_action)
# Named in _refresh_tray, which is where the loaded models are known.
self.unload_action = QAction("", self.menu)
self.unload_action.triggered.connect(self.unload_models)
self.menu.addAction(self.unload_action)
self.settings_action = QAction(t("Settings…"), self.menu)
self.settings_action.triggered.connect(self.open_settings)
self.menu.addAction(self.settings_action)
@@ -303,6 +308,10 @@ class Dikte:
self.menu.addAction(self.quit_action)
self.tray.setContextMenu(self.menu)
# A model unloads itself in the background, so what the unload row says
# goes stale between state changes. Refreshed as the menu opens, which
# is the only moment anybody reads it.
self.menu.aboutToShow.connect(self._refresh_tray)
self.tray.setToolTip(t("Dikte: ready"))
self.tray.activated.connect(self._tray_clicked)
self._refresh_update()
@@ -398,6 +407,21 @@ class Dikte:
)
self.ask_cancel_action.setEnabled(self.ask_state == BUSY)
# A local model holds its memory whether or not anything is using it, so
# the menu says which of the two are loaded and offers to give it back.
# Hidden on a machine that runs neither: there is nothing to unload and
# nothing to report.
loaded = [server for server in (ggml.whisper, ggml.llm) if server.running]
self.unload_action.setVisible(
self.conf["transcribe_provider"] == "local" or self.conf.uses_local_llm()
)
self.unload_action.setText(
t("Unload the models") if len(loaded) > 1
else t("Unload the model") if loaded
else t("No model loaded")
)
self.unload_action.setEnabled(bool(loaded))
# The agent speaks through the icon only when dictation has nothing to
# say, since dictation is the one being waited on in front of a screen.
if self.state == IDLE and self.ask_state != IDLE:
@@ -1209,6 +1233,18 @@ class Dikte:
QDesktopServices.openUrl(
QUrl(release.url if release is not None else update.RELEASES_PAGE))
def unload_models(self):
"""Give the memory back now rather than when the idle window closes."""
held = [server for server in (ggml.whisper, ggml.llm)
if not server.unload()]
self._refresh_tray()
if held:
self.tray.showMessage(
"Dikte",
t("A model is loading or answering right now. Try again in a "
"moment."),
QSystemTrayIcon.MessageIcon.Information, 5000)
# ---- settings ---------------------------------------------------------
def open_settings(self):
+14 -10
View File
@@ -115,16 +115,20 @@ def _local(text, conf, system_prompt, timeout, aborter=None):
"""
service = t("Local model")
try:
return api.cleanup(
text, "", conf["local_llm_model"], system_prompt,
reasoning=conf["local_llm_reasoning"],
base_url=api.serving(ggml.llm),
timeout=max(timeout, api.LOCAL_TIMEOUT),
provider="local-llm", service=service, aborter=aborter,
# The ceiling is only a ceiling while it sits under what the server
# was started with; above that the context is what stops the reply.
context=ggml.llm.settings()["context"],
)
# Held for the length of the request so that the idle unload does not
# take the model away from a block still being cleaned up.
with ggml.llm.busy():
return api.cleanup(
text, "", conf["local_llm_model"], system_prompt,
reasoning=conf["local_llm_reasoning"],
base_url=api.serving(ggml.llm),
timeout=max(timeout, api.LOCAL_TIMEOUT),
provider="local-llm", service=service, aborter=aborter,
# The ceiling is only a ceiling while it sits under what the
# server was started with; above that the context is what stops
# the reply.
context=ggml.llm.settings()["context"],
)
except api.ApiError as exc:
# A server that died mid-request would otherwise report only that the
# connection dropped, when the reason is in its own output.
+17
View File
@@ -442,6 +442,15 @@ DEFAULTS = {
# Off rather than empty: a model trained to think will, and 300 tokens of
# reasoning about a comma is 300 tokens of waiting.
"local_llm_reasoning": "none",
# --- what happens to both of them when nothing is using them -------------
# One pair for the two servers rather than a pair each: what is being
# decided is whether a machine keeps gigabytes tied up between dictations,
# and nobody wants that answered one model at a time. On by default because
# a reload costs seconds and the memory costs the rest of the desktop.
"local_idle_unload": True,
"local_idle_minutes": 10,
"cleanup_prompt": "", # empty -> language-specific default
"auto_paste": True,
"paste_shortcut": paste.desktop().shortcuts[0], # cmd+v on a Mac
@@ -710,6 +719,14 @@ class Config:
binary=self["local_llm_binary"],
context=int(self["local_llm_context"]),
)
ggml.whisper.set_idle(self.idle_seconds())
ggml.llm.set_idle(self.idle_seconds())
def idle_seconds(self):
"""How long a loaded model may sit unused. 0 means it is kept."""
if not self["local_idle_unload"]:
return 0
return max(1, int(self["local_idle_minutes"])) * 60
def uses_local_llm(self):
"""Whether anything is set to run the local cleanup model."""
+128 -2
View File
@@ -26,6 +26,7 @@ interface already knows how to show.
import atexit
import collections
import contextlib
import ctypes
import ctypes.util
import hashlib
@@ -68,6 +69,10 @@ STARTUP_TIMEOUT = 180.0
# to load takes longer than this to be read in first. The line between "worth
# another port" and "would fail the same way again" is drawn on time.
EARLY_EXIT_WINDOW = 5.0
# How often the watcher looks at a model it has been asked to unload when idle.
# Short next to any window worth setting, so the memory goes back within seconds
# of the window closing rather than a minute after it.
IDLE_CHECK_SECONDS = 5.0
DOWNLOAD_CHUNK = 1 << 20
# `health` is the path that answers only once the model is in memory. whisper
@@ -1116,6 +1121,15 @@ class Server:
# The pid this instance last wrote to its pid file, so _forget never
# removes a file some other Dikte wrote after us.
self._pid = 0
# The idle unload. `_idle` is the window in seconds, zero meaning the
# model stays loaded until something else stops it; `_used` is when the
# address was last handed out or a request last finished; `_busy` counts
# the requests still in flight. The count is there because a file being
# transcribed is one address lookup and then minutes of work, which to a
# clock started at the lookup looks exactly like a model nobody wants.
self._idle = 0.0
self._used = 0.0
self._busy = 0
# ---- settings --------------------------------------------------------
@@ -1133,6 +1147,23 @@ class Server:
with self._lock:
return dict(self._settings)
def set_idle(self, seconds):
"""How long a loaded model may sit unused before the memory goes back.
Deliberately not one of the settings above: those describe the server
that is running, and changing one has to restart it. This describes how
long to keep it, which the server it is applied to never needs to know.
Zero keeps the model until something else stops it.
"""
with self._lock:
self._idle = max(0.0, float(seconds))
@property
def idle(self):
"""The window `set_idle` was last given, in seconds."""
with self._lock:
return self._idle
def _settings_key(self):
"""What a running server would have to be restarted for."""
return json.dumps(self._settings, sort_keys=True, default=str)
@@ -1172,13 +1203,83 @@ class Server:
proc, port, log = self._launch(settings)
with self._lock:
self._proc, self._port, self._log, self._key = proc, port, log, key
# Only the clock. The count is not this launch's to reset: a
# caller that took a hold and then asked for the address, which
# is what the local cleanup does, would have it wiped here and
# spend the whole request unprotected.
self._used = time.monotonic()
threading.Thread(target=self._watch, args=(proc,), daemon=True).start()
return self.base_url()
def _current_url(self):
"""The address of a server running the current settings, or "".
Asking counts as using it. Everything that asks is about to send a
request, and the idle watcher reads the same clock, so the stamp has to
be set here rather than where the answer comes back.
"""
with self._lock:
up = self._proc is not None and self._proc.poll() is None
return (f"http://{HOST}:{self._port}/v1"
if up and self._key == self._settings_key() else "")
if not (up and self._key == self._settings_key()):
return ""
self._used = time.monotonic()
return f"http://{HOST}:{self._port}/v1"
@contextlib.contextmanager
def busy(self):
"""Hold the model for the length of one request.
A dictation is over a second after the address was handed out, but a
file is minutes of it, and an hour of meeting is longer still. Without
the count the watcher would unload the model out from under the request
that started it.
"""
with self._lock:
self._busy += 1
try:
yield
finally:
with self._lock:
# Nothing else moves the count, so every hold that was taken is
# given back here and it stays balanced across a restart. A hold
# outliving the server it was taken against only keeps the next
# one loaded a moment longer, which is the safe way round.
self._busy -= 1
self._used = time.monotonic()
def _idle_now(self, proc):
"""Whether `proc` is still ours and has been sitting unused long enough."""
with self._lock:
if self._proc is not proc or not self._idle or self._busy:
return False
return time.monotonic() - self._used >= self._idle
def _watch(self, proc):
"""Give the memory back when nothing has asked anything for a while.
One thread per launch, holding the process it was started for, so that a
server stopped and started again is watched by the new thread alone and
this one leaves on the first pass that finds its own process gone.
Started whatever the window is, zero included: turning the unload on in
Settings has to reach a model that is already loaded, and a thread that
wakes every few seconds to read one number is cheaper than the machinery
for starting one later.
"""
while True:
time.sleep(IDLE_CHECK_SECONDS)
with self._lock:
if self._proc is not proc:
return # stopped, or replaced by a later launch
if not self._idle_now(proc):
continue
with self._starting:
# Asked once more under the lock a start has to take. An address
# handed out while this thread waited its turn stamps _used, and
# the request behind it must not arrive at a server killed here.
if self._idle_now(proc):
self._stop_now()
return
def _launch(self, settings):
args = self._build(settings) # raises LocalError when unusable
@@ -1285,6 +1386,31 @@ class Server:
except subprocess.TimeoutExpired:
pass
def unload(self):
"""Stop the server unless it is in the middle of something.
The same rule the idle watcher goes by, taken by hand from the menu, and
it says no for the same reason: the memory is worth having back, but not
at the price of the dictation waiting on it. A model still being read in
counts as in the middle of something too, and that is why the lock is
asked for rather than waited on: this runs on the interface's own
thread, and a start holds _starting for as long as the load takes, which
for a large model on a cold cache is most of a minute. True when nothing
is loaded any more, either way.
"""
if not self._starting.acquire(blocking=False):
return False
try:
with self._lock:
if self._proc is None:
return True
if self._busy:
return False
self._stop_now()
return True
finally:
self._starting.release()
def stop(self):
# Taking _starting means a stop cannot slide past a launch in flight:
# serve() finishes registering its child first, and the child is then
+20
View File
@@ -782,6 +782,26 @@ TR = {
"On this machine": "Bu makinede",
"Use the graphics card": "Ekran kartını kullan",
"Load the model when Dikte starts": "Modeli Dikte açılırken yükle",
"Models on this machine": "Bu makinedeki modeller",
"Unload a model that is sitting unused": "Kullanılmayan modeli bellekten çıkar",
"A loaded model holds its memory whether anything is using it or "
"not: over a gigabyte for whisper, several for an LLM. Unloading "
"gives that back to the rest of the desktop, and the next "
"dictation loads it again at the cost of the seconds that takes.":
"Yüklü bir model, kullanılsa da kullanılmasa da belleği tutar: whisper "
"için bir gigabaytın üzerinde, bir LLM için birkaç gigabayt. Bellekten "
"çıkarmak bunu masaüstünün geri kalanına iade eder, sonraki dikte de "
"modeli birkaç saniye bekleyerek yeniden yükler.",
" minute": " dakika",
" minutes": " dakika",
"After": "Şu kadar sonra",
"Unload the model": "Modeli bellekten çıkar",
"Unload the models": "Modelleri bellekten çıkar",
"No model loaded": "Yüklü model yok",
"A model is loading or answering right now. Try again in a "
"moment.":
"Bir model şu anda yükleniyor ya da cevap veriyor. Az sonra tekrar "
"deneyin.",
"Local whisper": "Yerel whisper",
"Local model": "Yerel model",
"Not installed.": "Kurulu değil.",
+45
View File
@@ -1325,9 +1325,47 @@ class SettingsWindow(QDialog):
orr_form.addRow(self.local_llm_options)
outer.addWidget(orr)
# One box for both servers rather than a row inside each: what is being
# decided is whether this machine keeps gigabytes tied up between
# dictations, and that is not a question anybody wants to answer once
# per model.
self.local_box = QGroupBox(t("Models on this machine"))
local_form = QFormLayout(self.local_box)
self.local_idle_unload = QCheckBox(t("Unload a model that is sitting unused"))
self.local_idle_unload.setToolTip(
t("A loaded model holds its memory whether anything is using it or "
"not: over a gigabyte for whisper, several for an LLM. Unloading "
"gives that back to the rest of the desktop, and the next "
"dictation loads it again at the cost of the seconds that takes."))
self.local_idle_minutes = QSpinBox()
self.local_idle_minutes.setRange(1, 720)
self.local_idle_minutes.valueChanged.connect(self._idle_suffix)
self._idle_suffix(self.local_idle_minutes.value())
self.local_idle_unload.toggled.connect(self.local_idle_minutes.setEnabled)
local_form.addRow("", self.local_idle_unload)
local_form.addRow(t("After"), self.local_idle_minutes)
outer.addWidget(self.local_box)
outer.addStretch(1)
return page
def _idle_suffix(self, minutes):
"""The spin box's own noun, since its lowest value is one of them.
Turkish is handed both and translates them the same: a number there is
followed by the singular however many it counts.
"""
self.local_idle_minutes.setSuffix(
t(" minute") if minutes == 1 else t(" minutes"))
def _refresh_local_box(self):
"""The idle unload is only on screen when something here runs locally."""
self.local_box.setVisible(
(self.transcribe_provider.currentData() or "local") == "local"
or (self.cleanup_provider.currentData() or "openrouter") == "local"
)
def _prompt_tab(self):
page = QWidget()
layout = QVBoxLayout(page)
@@ -2094,6 +2132,9 @@ class SettingsWindow(QDialog):
self.local_llm_preload.setChecked(conf["local_llm_preload"])
self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"])
self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"])
self.local_idle_unload.setChecked(conf["local_idle_unload"])
self.local_idle_minutes.setValue(int(conf["local_idle_minutes"]))
self.local_idle_minutes.setEnabled(conf["local_idle_unload"])
# The defaults as they read NOW, kept for the save comparison: after a
# language switch the boxes still hold the old language's default, and
# comparing against the new one would store that text as a custom
@@ -2225,6 +2266,8 @@ class SettingsWindow(QDialog):
conf["local_llm_gpu"] = self.local_llm_gpu.isChecked()
conf["local_llm_preload"] = self.local_llm_preload.isChecked()
conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or ""
conf["local_idle_unload"] = self.local_idle_unload.isChecked()
conf["local_idle_minutes"] = self.local_idle_minutes.value()
# Store an empty prompt when it matches a default: the one it was
# loaded with, or today's (a Reset click in a session that switched
@@ -2367,6 +2410,7 @@ class SettingsWindow(QDialog):
self.stt_form.setRowVisible(self.transcribe_status, not local)
self.stt_form.setRowVisible(self.local_whisper, local)
self.stt_form.setRowVisible(self.local_options, local)
self._refresh_local_box()
if local:
return
self.transcribe_model.clear()
@@ -2875,6 +2919,7 @@ class SettingsWindow(QDialog):
provider != "local")
self.cleanup_form.setRowVisible(self.local_llm, provider == "local")
self.cleanup_form.setRowVisible(self.local_llm_options, provider == "local")
self._refresh_local_box()
binary = cleanup.executable(provider)
found = shutil.which(binary) if binary else ""
if provider == "local":
+10
View File
@@ -9,6 +9,7 @@ is blocked on, and a faked urlopen has no socket to cut, so those tests talk to
a server of their own on the loopback interface.
"""
import contextlib
import http.server
import json
import os
@@ -653,6 +654,7 @@ class FakeServer:
self.fails = fails
self.log = log
self.starts = 0
self.held = 0
self.context = context
def serve(self):
@@ -661,6 +663,14 @@ class FakeServer:
raise ggml.LocalError(self.fails)
return self.url
@contextlib.contextmanager
def busy(self):
self.held += 1
try:
yield
finally:
self.held -= 1
def error(self):
return self.log
+21
View File
@@ -689,3 +689,24 @@ class ReadyToRun(DikteTest):
self.assertEqual(ggml.whisper.settings()["threads"], 4)
self.assertFalse(ggml.whisper.settings()["gpu"])
self.assertEqual(ggml.llm.settings()["context"], 4096)
def test_the_idle_window_is_in_seconds(self):
conf = self.config(local_idle_unload=True, local_idle_minutes=15)
self.assertEqual(conf.idle_seconds(), 900)
def test_an_unchecked_box_keeps_the_model(self):
conf = self.config(local_idle_unload=False, local_idle_minutes=15)
self.assertEqual(conf.idle_seconds(), 0)
def test_a_window_of_no_minutes_is_still_a_window(self):
"""The spin box will not go below one; a config edited by hand can."""
conf = self.config(local_idle_unload=True, local_idle_minutes=0)
self.assertEqual(conf.idle_seconds(), 60)
def test_both_servers_are_told_the_window(self):
conf = self.config(local_idle_unload=True, local_idle_minutes=3)
self.addCleanup(ggml.llm.set_idle, 0)
self.addCleanup(ggml.whisper.set_idle, 0)
conf.apply_local()
self.assertEqual(ggml.whisper.idle, 180)
self.assertEqual(ggml.llm.idle, 180)
+121 -1
View File
@@ -783,7 +783,9 @@ STAND_IN = textwrap.dedent("""
""")
class Servers(Local):
class ServerCase(Local):
"""The stand-in server and the fixture around it, with no tests of its own."""
def setUp(self):
super().setUp()
self.path("data").mkdir(parents=True, exist_ok=True)
@@ -806,6 +808,8 @@ class Servers(Local):
self.addCleanup(made.stop)
return made
class Servers(ServerCase):
def test_a_started_server_hands_back_its_address(self):
server = self.server()
url = server.serve()
@@ -1037,6 +1041,122 @@ class Servers(Local):
self.assertFalse(server.sweep()) # and the pid file went with it
class IdleUnload(ServerCase):
"""Giving the memory back when nothing has asked anything for a while."""
IDLE = 0.3
def setUp(self):
super().setUp()
# The real check runs every five seconds against a window of minutes.
# Both are scaled down here; what is being tested is the decision, and
# nothing in it reads the clock in units of its own.
self.patch_attr(ggml, "IDLE_CHECK_SECONDS", 0.05)
def idle_server(self, seconds=None, **settings):
server = self.server(**settings)
server.set_idle(self.IDLE if seconds is None else seconds)
return server
def wait_for(self, predicate, timeout=5.0):
"""True as soon as `predicate` holds, False once the wait runs out."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.02)
return False
def test_a_model_nobody_is_using_is_unloaded(self):
server = self.idle_server()
server.serve()
self.assertTrue(self.wait_for(lambda: not server.running))
def test_the_default_is_to_keep_it(self):
"""A server nobody set a window on stays until something stops it."""
server = self.server()
server.serve()
self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6))
def test_a_window_of_zero_keeps_it_too(self):
server = self.idle_server(0)
server.serve()
self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6))
def test_a_request_in_flight_holds_the_model(self):
"""A file is one address lookup and then minutes of work: the clock
alone would call that idle and unload it mid-transcription."""
server = self.idle_server()
server.serve()
with server.busy():
self.assertFalse(
self.wait_for(lambda: not server.running, timeout=self.IDLE * 3))
self.assertTrue(self.wait_for(lambda: not server.running))
def test_asking_for_the_address_puts_the_window_back(self):
server = self.idle_server()
first = server.serve()
for _ in range(4):
time.sleep(self.IDLE / 2)
self.assertEqual(server.serve(), first) # never restarted
self.assertTrue(server.running)
def test_the_next_request_loads_it_again(self):
server = self.idle_server()
first = server.serve()
self.assertTrue(self.wait_for(lambda: not server.running))
second = server.serve()
self.assertTrue(server.running)
self.assertNotEqual(second, first) # a new process, a new port
def test_the_watcher_of_a_stopped_server_does_not_touch_the_next_one(self):
server = self.idle_server()
server.serve()
server.stop()
server.set_idle(0)
server.serve()
self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6))
def test_unloading_by_hand_does_not_wait_for_the_window(self):
server = self.idle_server(0)
server.serve()
self.assertTrue(server.unload())
self.assertFalse(server.running)
def test_a_hold_taken_before_the_start_survives_it(self):
"""The local cleanup takes the hold and only then asks for the address,
so the start it triggers must not be what drops the hold."""
server = self.idle_server()
with server.busy():
server.serve()
self.assertFalse(
self.wait_for(lambda: not server.running, timeout=self.IDLE * 3))
self.assertTrue(self.wait_for(lambda: not server.running))
def test_unloading_is_refused_while_the_model_is_still_loading(self):
"""It runs on the interface's thread, and a start holds its lock for as
long as the load takes: waiting there would freeze the whole window."""
server = self.idle_server(0, extra=["--wait", "0.6"])
thread = threading.Thread(target=server.serve)
thread.start()
try:
began = time.monotonic()
self.assertFalse(server.unload())
self.assertLess(time.monotonic() - began, 0.2)
finally:
thread.join(timeout=10)
def test_unloading_is_refused_while_a_request_is_in_flight(self):
server = self.idle_server(0)
server.serve()
with server.busy():
self.assertFalse(server.unload())
self.assertTrue(server.running)
def test_unloading_nothing_is_not_a_refusal(self):
self.assertTrue(self.server().unload())
class Arguments(Local):
"""What the two command lines say, since neither program is here to say it."""
+22
View File
@@ -83,6 +83,8 @@ CHANGED = {
"local_llm_gpu": False,
"local_llm_preload": True,
"local_llm_reasoning": "low",
"local_idle_unload": False,
"local_idle_minutes": 45,
"cleanup_prompt": "Only fix the punctuation.",
"file_cleanup_prompt": "Keep the stamps where they are.",
"transcribe_prompt": "Paraşüt, OpenFrame",
@@ -1734,6 +1736,26 @@ class LocalModels(DikteTest):
# Its own thinking box, because the two default to opposite things.
self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_reasoning))
def test_the_idle_unload_is_offered_to_whoever_runs_a_model_here(self):
for transcriber, cleaner in (("local", "openrouter"),
("openai", "local"),
("local", "local")):
with self.subTest(transcriber=transcriber, cleaner=cleaner):
window = self.window(self.config(transcribe_provider=transcriber,
cleanup_provider=cleaner))
self.assertTrue(window.local_box.isVisibleTo(window))
def test_a_machine_that_runs_neither_is_not_asked_about_memory(self):
window = self.window(self.config(transcribe_provider="openai",
cleanup_provider="openrouter"))
self.assertFalse(window.local_box.isVisibleTo(window))
def test_the_minutes_follow_the_checkbox(self):
window = self.window(self.config(local_idle_unload=False))
self.assertFalse(window.local_idle_minutes.isEnabled())
window.local_idle_unload.setChecked(True)
self.assertTrue(window.local_idle_minutes.isEnabled())
def test_each_cleaner_brings_its_own_model_row_and_no_other(self):
window = self.window(cfg.Config())
rows = {"openrouter": window.cleanup_model_row,