Say what the local models are running on

"Use the graphics card" was a flag and nothing else: whisper got -ng
when it was off, llama got -ngl 99 or 0, and nobody looked at what
happened next. A build without a GPU backend runs on the processor
while the box stays ticked, which is what the release archives for
Linux do, every time. Nothing anywhere reported whether the local
server was even up.

Both servers already say where the model went, in the log Dikte
captures. It is read back once the server reports ready and turned
into a backend, a card and, for llama, the layers it offloaded. The
verdict comes from what whisper committed to -- "using X backend" and
the model buffer -- rather than from the devices it merely listed: a
card that is found and then fails to initialise sends it back to the
processor, and the listing alone would have called that a graphics
card. A log that says nothing stays "could not tell" instead of being
guessed at; a hand-built macOS whisper has Metal compiled in and
prints no backend line at all.

The state is then somewhere to be seen. Server.state() is a snapshot
of the process and what it settled on, and it reaches `dikte status`,
`dikte doctor` and a line under each local model box in the settings
window. doctor reads the log from disk when no instance is running, so
it still answers on a machine where Dikte is closed, and it says which
of the three it is: what the last run used, that the last run said
nothing, or that none ever ran here. Where the card was asked for and
not obtained, the line says which of the two it is -- none was found,
or this build carries none -- because only the second is worth
replacing a download over.

The tests grew a second isolation. They read ggml's own data
directory, which is the real one on the machine running them, and
program_path prefers a whisper-server on the PATH, so the suite
answered from whatever the developer happened to have installed. Both
are now the test's own.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019zqCqhmeaNT6m1GPp8ZWPp
This commit is contained in:
oztturk
2026-08-28 16:16:45 +03:00
co-authored by Claude Opus 5
parent 310ef8d7cf
commit 840e70463a
9 changed files with 851 additions and 10 deletions
+17
View File
@@ -24,6 +24,7 @@ from unittest import mock
from dikte import assistant
from dikte import config as cfg
from dikte import ggml
from dikte import i18n
from dikte import update
@@ -91,6 +92,22 @@ class DikteTest(unittest.TestCase):
# down when it last ran.
self.patch_attr(assistant, "SESSION_FILE", data_dir / "assistant.json")
self.patch_attr(update, "STATE_FILE", data_dir / "update.json")
# ggml resolves its own three from paths.DATA_DIR at import, the same
# way cfg does. Left alone, a test asking what is installed or what the
# last server ran on would be reading whatever this machine happens to
# have downloaded, and passing or failing on somebody's home directory.
# program_path prefers a whisper-server or llama-server on the PATH
# over the copy Dikte downloaded, so on a machine with whisper.cpp
# installed these tests would be answering from that copy instead of
# from the install they set up. Every other tool still resolves; the
# tests that are about the system build patch this again themselves.
_which = shutil.which
self.patch_attr(shutil, "which", lambda tool, *args, **rest: (
None if tool in ("whisper-server", "llama-server")
else _which(tool, *args, **rest)))
self.patch_attr(ggml, "DATA_DIR", data_dir)
self.patch_attr(ggml, "BIN_DIR", data_dir / "bin")
self.patch_attr(ggml, "MODELS_DIR", data_dir / "models")
i18n.set_language("en")
self.addCleanup(i18n.set_language, "en")
+108
View File
@@ -775,6 +775,114 @@ class Replies(DikteTest):
self.assertFalse(launched.called)
class LocalModels(DikteTest):
"""Whether the model on this machine is loaded, and what it is loaded on."""
def status(self, local, **rest):
reply = {"ok": True, "running": True, "dictation": "idle", "ask": "idle",
"meeting": "idle", "listener": True, "local": local, **rest}
with mock.patch.object(ipc, "send", return_value=reply), \
captured() as (out, _err):
cli.cmd_status(Options(json=False))
return out.getvalue()
def entry(self, **values):
base = {"running": True, "used": True, "pid": 7, "port": 4321,
"model": "ggml-small.bin", "gpu_wanted": True,
"backend": "CUDA", "device": "RTX 4070", "layers": "",
"available": ["CUDA", "CPU"]}
base.update(values)
return base
def test_a_loaded_model_says_what_it_is_loaded_on(self):
line = self.status({"whisper": self.entry()})
self.assertIn("whisper:", line)
self.assertIn("loaded on the graphics card (CUDA, RTX 4070)", line)
self.assertIn("ggml-small.bin", line)
def test_a_card_asked_for_and_not_found_is_said_out_loud(self):
line = self.status({"whisper": self.entry(
backend="CPU", device="CPU", available=["CPU"])})
self.assertIn("loaded on the processor", line)
self.assertIn("this build carries none", line)
def test_a_card_the_build_could_have_used_says_something_else(self):
line = self.status({"whisper": self.entry(
backend="CPU", device="CPU", available=["CUDA", "CPU"])})
self.assertIn("none was found", line)
self.assertNotIn("carries none", line)
def test_a_card_nobody_asked_for_is_not_a_complaint(self):
line = self.status({"whisper": self.entry(
backend="CPU", device="CPU", gpu_wanted=False, available=["CPU"])})
self.assertIn("loaded on the processor", line)
self.assertNotIn("switched on", line)
def test_a_model_that_is_wanted_and_not_loaded_says_so(self):
line = self.status({"whisper": self.entry(running=False)})
self.assertIn("whisper:", line)
self.assertIn("not loaded", line)
def test_a_model_neither_used_nor_loaded_is_not_worth_a_line(self):
line = self.status({"llama": self.entry(running=False, used=False)})
self.assertNotIn("llama", line)
def test_an_instance_too_old_to_have_been_asked_says_nothing(self):
reply = {"ok": True, "running": True, "dictation": "idle", "ask": "idle",
"meeting": "idle", "listener": True}
with mock.patch.object(ipc, "send", return_value=reply), \
captured() as (out, _err):
cli.cmd_status(Options(json=False))
self.assertNotIn("whisper", out.getvalue())
# ---- doctor, which can be asked with nothing running -----------------
def doctor(self, as_json=False, **settings):
self.write_config(settings)
with mock.patch.object(ipc, "send", return_value=None), \
captured() as (out, _err):
cli.cmd_doctor(Options(json=as_json))
return json.loads(out.getvalue()) if as_json else out.getvalue()
def log(self, text):
path = ggml.DATA_DIR / "whisper-server.log"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
def test_with_nothing_running_the_last_start_is_read_off_its_log(self):
self.log("load_backend: loaded CPU backend from /x.so\n"
"whisper_backend_init_gpu: device 0: CPU (type: 0)\n"
"whisper_backend_init_gpu: no GPU found\n")
line = self.doctor(transcribe_provider="local", local_gpu=True)
self.assertIn("last run on the processor", line)
self.assertIn("this build carries none", line)
def test_a_run_that_named_no_backend_is_not_read_as_no_run_at_all(self):
# A log with nothing recognisable in it still says a server started
# here once, which is a different thing from never having started.
self.log("whisper_model_load: model size = 147.37 MB\n")
line = self.doctor(transcribe_provider="local")
self.assertIn("said nothing about what it was running on", line)
self.assertNotIn("never run here", line)
def test_a_machine_that_never_ran_one_is_not_made_up_a_history_for(self):
line = self.doctor(transcribe_provider="local")
self.assertIn("never run here", line)
def test_a_setup_that_transcribes_in_the_cloud_reads_about_none_of_it(self):
line = self.doctor(transcribe_provider="openai", cleanup_enabled=False)
self.assertNotIn("whisper ", line)
self.assertNotIn("never run here", line)
def test_an_instance_that_cannot_be_asked_is_not_read_as_a_no(self):
"""It used to print "not loaded", which is a different claim."""
self.write_config({"transcribe_provider": "local"})
with mock.patch.object(ipc, "send", return_value={"ok": True}), \
captured() as (out, _err):
cli.cmd_doctor(Options(json=False))
self.assertIn("too old to say", out.getvalue())
class TranscribeRunsHere(DikteTest):
"""`dikte transcribe` runs in this process, not in the instance."""
+230
View File
@@ -525,6 +525,180 @@ class Catalogue(Local):
"model.gguf")
# --- what it ended up running on ------------------------------------------
# Trimmed from real logs. The first is this project's own bug report: the
# graphics card is switched on, whisper asked for one, and the build had none
# to give.
WHISPER_CPU = """\
load_backend: loaded CPU backend from /opt/whisper/libggml-cpu-haswell.so
whisper_init_from_file_with_params_no_state: loading model from 'ggml-small.bin'
whisper_init_with_params_no_state: use gpu = 1
whisper_model_load: CPU total size = 189.49 MB
whisper_backend_init_gpu: device 0: CPU (type: 0)
whisper_backend_init_gpu: no GPU found
"""
WHISPER_CUDA = """\
load_backend: loaded CUDA backend from /opt/whisper/libggml-cuda.so
load_backend: loaded CPU backend from /opt/whisper/libggml-cpu-haswell.so
whisper_init_with_params_no_state: use gpu = 1
whisper_model_load: CUDA0 total size = 189.49 MB
whisper_backend_init_gpu: device 0: NVIDIA GeForce RTX 4070 (type: 1)
whisper_backend_init_gpu: using CUDA0 backend
"""
# A card listed, tried, and refused: whisper says so and carries on without it,
# and the weights stay where they were put. Reading the listing alone would
# report a graphics card that is doing nothing.
WHISPER_GPU_FAILED = """\
load_backend: loaded Vulkan backend from /usr/lib/ggml/libggml-vulkan.so
load_backend: loaded CPU backend from /usr/lib/ggml/libggml-cpu-haswell.so
whisper_model_load: CPU total size = 189.49 MB
whisper_backend_init_gpu: device 0: Vulkan0 (type: 1)
whisper_backend_init_gpu: found GPU device 0: Vulkan0 (type: 1, cnt: 0)
whisper_backend_init_gpu: failed to initialize Vulkan0 backend
"""
# Both backends in one build. The Vulkan listing is there and is not the one
# that ran, so naming the card out of it would name the wrong device.
WHISPER_MIXED = """\
ggml_vulkan: Found 1 Vulkan devices:
ggml_vulkan: 0 = Intel UHD Graphics 770 (ANV TGL) (anv) | uma: 1
load_backend: loaded CUDA backend from /opt/whisper/libggml-cuda.so
load_backend: loaded Vulkan backend from /opt/whisper/libggml-vulkan.so
load_backend: loaded CPU backend from /opt/whisper/libggml-cpu-haswell.so
Device 0: NVIDIA GeForce RTX 4070, compute capability 8.9, VMM: yes
whisper_model_load: CUDA0 total size = 189.49 MB
whisper_backend_init_gpu: device 0: CUDA0 (type: 1)
whisper_backend_init_gpu: using CUDA0 backend
"""
# The same start on a card whisper names only by its slot. The card's own name
# is one line further up, printed by the backend as it enumerates.
WHISPER_VULKAN = """\
ggml_vulkan: Found 1 Vulkan devices:
ggml_vulkan: 0 = AMD Radeon RX 6600 (RADV NAVI23) (radv) | uma: 0 | fp16: dot2
load_backend: loaded Vulkan backend from /usr/lib/ggml/libggml-vulkan.so
load_backend: loaded CPU backend from /usr/lib/ggml/libggml-cpu-haswell.so
whisper_model_load: Vulkan0 total size = 189.49 MB
whisper_backend_init_gpu: device 0: Vulkan0 (type: 1)
whisper_backend_init_gpu: using Vulkan0 backend
"""
# A whisper built by hand on a Mac: Metal is compiled in rather than loaded, so
# there is no line to read and no honest answer but "it did not say".
WHISPER_QUIET = """\
whisper_init_from_file_with_params_no_state: loading model from 'ggml-base.bin'
whisper_model_load: model size = 147.37 MB
"""
LLAMA_GPU = """\
load_backend: loaded Vulkan backend from /opt/llama/libggml-vulkan.so
load_backend: loaded CPU backend from /opt/llama/libggml-cpu.so
load_tensors: offloading 28 repeating layers to GPU
load_tensors: offloaded 29/29 layers to GPU
"""
LLAMA_CPU = """\
load_backend: loaded Vulkan backend from /opt/llama/libggml-vulkan.so
load_backend: loaded CPU backend from /opt/llama/libggml-cpu.so
load_tensors: offloaded 0/29 layers to GPU
"""
class WhatItRunsOn(Local):
"""Reading the backend back out of the log the server wrote."""
def log(self, text):
path = self.path("server.log")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
return path
def read(self, program, text):
return ggml._read_accel(program, self.log(text))
def test_a_card_that_was_asked_for_and_not_found_is_the_processor(self):
accel = self.read(ggml.WHISPER, WHISPER_CPU)
self.assertEqual(accel.backend, "CPU")
self.assertEqual(ggml.accel_kind(accel), "cpu")
def test_a_build_with_nothing_but_a_processor_backend_says_so(self):
self.assertTrue(ggml.cpu_only_build(self.read(ggml.WHISPER, WHISPER_CPU)))
self.assertFalse(ggml.cpu_only_build(self.read(ggml.WHISPER, WHISPER_CUDA)))
def test_a_card_that_was_found_is_named(self):
accel = self.read(ggml.WHISPER, WHISPER_CUDA)
self.assertEqual(accel.backend, "CUDA")
self.assertEqual(accel.device, "NVIDIA GeForce RTX 4070")
self.assertEqual(ggml.accel_kind(accel), "gpu")
self.assertEqual(ggml.accel_detail(accel),
"CUDA, NVIDIA GeForce RTX 4070")
def test_a_card_named_only_by_its_slot_is_looked_up(self):
accel = self.read(ggml.WHISPER, WHISPER_VULKAN)
self.assertEqual(accel.backend, "Vulkan")
# "Vulkan0" says which slot; the point of the line is which card.
self.assertEqual(accel.device, "AMD Radeon RX 6600 (RADV NAVI23)")
def test_the_driver_behind_the_card_is_not_part_of_its_name(self):
# "(radv)" is how it is reached; "(RADV NAVI23)" is what it is called.
self.assertNotIn("(radv)",
self.read(ggml.WHISPER, WHISPER_VULKAN).device)
def test_a_card_that_failed_to_start_is_not_a_card_in_use(self):
# It was listed, it was tried, it did not work, and whisper went on
# without it. The listing alone would have called this a graphics card.
accel = self.read(ggml.WHISPER, WHISPER_GPU_FAILED)
self.assertEqual(accel.backend, "CPU")
self.assertEqual(ggml.accel_kind(accel), "cpu")
def test_the_card_named_is_the_one_that_ran(self):
accel = self.read(ggml.WHISPER, WHISPER_MIXED)
self.assertEqual(accel.backend, "CUDA")
self.assertEqual(accel.device, "NVIDIA GeForce RTX 4070")
self.assertNotIn("Intel", ggml.accel_detail(accel))
def test_a_slot_number_is_not_a_name(self):
# "Vulkan0" says which slot; with no listing to look it up in, saying
# nothing beats saying that.
self.assertEqual(self.read(ggml.LLAMA, LLAMA_GPU).device, "")
def test_a_log_that_says_nothing_is_not_guessed_at(self):
accel = self.read(ggml.WHISPER, WHISPER_QUIET)
self.assertEqual(accel.backend, "")
self.assertEqual(ggml.accel_kind(accel), "unknown")
def test_a_log_that_is_not_there_is_not_guessed_at_either(self):
self.assertEqual(ggml._read_accel(ggml.WHISPER, self.path("gone.log")),
ggml.NO_ACCEL)
def test_the_layers_llama_offloaded_are_read_back(self):
accel = self.read(ggml.LLAMA, LLAMA_GPU)
self.assertEqual(accel.backend, "Vulkan")
self.assertEqual(accel.layers, "29/29")
self.assertEqual(ggml.accel_detail(accel), "Vulkan, 29/29 layers")
def test_a_llama_that_offloaded_nothing_is_on_the_processor(self):
accel = self.read(ggml.LLAMA, LLAMA_CPU)
self.assertEqual(accel.backend, "CPU")
self.assertEqual(ggml.accel_kind(accel), "cpu")
# The build could have used the card; this run did not.
self.assertFalse(ggml.cpu_only_build(accel))
def test_the_processor_is_not_named_twice(self):
# whisper prints CPU as the backend and as the device, and saying it
# twice reads like two different things.
self.assertEqual(ggml.accel_detail(self.read(ggml.WHISPER, WHISPER_CPU)),
"CPU")
def test_nothing_is_running_is_not_a_backend(self):
self.assertEqual(ggml.accel_kind({"running": False, "backend": "CUDA"}),
"off")
# --- keeping a server alive -----------------------------------------------
@@ -544,6 +718,18 @@ STAND_IN = textwrap.dedent("""
print("could not load model: no such file")
sys.exit(2)
# The startup chatter a real server prints before it binds, so that the
# log has something for _read_accel to find. Flushed, because stdout here
# is a file and nothing would reach it before the port opened.
if "--backend" in args:
print("load_backend: loaded " + opt("--backend") + " backend from /x.so",
flush=True)
print("whisper_backend_init_gpu: device 0: Test Card (type: 1)",
flush=True)
# The line that says one of them worked, which is the one read back.
print("whisper_backend_init_gpu: using " + opt("--backend") + "0 backend",
flush=True)
started = time.monotonic()
healthy_after = float(opt("--healthy-after", "0"))
@@ -602,6 +788,50 @@ class Servers(Local):
self.assertRegex(url, r"^http://127\.0\.0\.1:\d+/v1$")
self.assertTrue(server.running)
def test_nothing_started_is_a_state_saying_so(self):
state = self.server().state()
self.assertFalse(state["running"])
self.assertEqual(ggml.accel_kind(state), "off")
def test_a_running_server_says_what_it_settled_on(self):
server = self.server(extra=["--backend", "CUDA"], gpu=True)
server.serve()
state = server.state()
self.assertTrue(state["running"])
self.assertIn(f":{state['port']}/v1", server.base_url())
self.assertEqual(state["backend"], "CUDA")
self.assertEqual(state["device"], "Test Card")
self.assertTrue(state["gpu_wanted"])
self.assertEqual(ggml.accel_kind(state), "gpu")
def test_a_setting_changed_mid_start_does_not_rename_what_is_running(self):
# A save that lands while the model is being read in finds no process
# to stop, so it changes the settings under a start already in flight.
# The line must name the model that is loaded, not the one that will be.
server = self.server(model="first")
launch = server._launch
def during(settings):
result = launch(settings)
server.configure(model="second")
return result
self.patch_attr(server, "_launch", during)
server.serve()
self.assertEqual(server.state()["model"], "first")
self.assertEqual(server.settings()["model"], "second")
def test_stopping_takes_the_backend_with_it(self):
server = self.server(extra=["--backend", "CUDA"])
server.serve()
server.stop()
self.assertEqual(server.state()["backend"], "")
def test_a_server_that_announced_nothing_is_not_guessed_at(self):
server = self.server()
server.serve()
self.assertEqual(ggml.accel_kind(server.state()), "unknown")
def test_the_second_call_does_not_start_a_second_one(self):
server = self.server()
first = server.serve()
+55 -1
View File
@@ -13,7 +13,7 @@ from typing import ClassVar
from unittest import mock
from PyQt6.QtCore import QPoint, QPointF, Qt
from PyQt6.QtGui import QWheelEvent
from PyQt6.QtGui import QHideEvent, QShowEvent, QWheelEvent
from PyQt6.QtWidgets import QApplication, QMessageBox
from dikte import audio
@@ -1177,6 +1177,60 @@ class LocalModels(DikteTest):
self.window(conf)._save()
self.assertEqual(conf["local_model"], "ggml-large-v3-turbo-q5_0.bin")
def state(self, **values):
base = {"running": True, "pid": 3, "port": 4321, "model": "ggml-small.bin",
"gpu_wanted": True, "backend": "CUDA", "device": "RTX 4070",
"layers": "", "available": ["CUDA", "CPU"]}
base.update(values)
return base
def shown(self, **values):
"""The line the window writes under the local model boxes."""
window = self.window(self.config(transcribe_provider="local"))
with mock.patch.object(ggml, "state",
return_value={"whisper": self.state(**values),
"llama": self.state(running=False)}):
window._show_local_state()
return window.local_state.text(), window.local_llm_state.text()
def test_a_loaded_model_says_which_card_it_is_on(self):
whisper, llm = self.shown()
self.assertIn("graphics card", whisper)
self.assertIn("RTX 4070", whisper)
# The other box is about the other model, and that one is not loaded.
self.assertIn("Not loaded", llm)
def test_a_card_asked_for_and_missing_is_not_left_to_be_guessed_at(self):
whisper, _ = self.shown(backend="CPU", device="CPU", available=["CPU"])
self.assertIn("processor", whisper)
self.assertIn("no graphics backend", whisper)
def test_a_build_that_could_have_used_one_says_the_other_thing(self):
whisper, _ = self.shown(backend="CPU", device="CPU",
available=["CUDA", "CPU"])
self.assertIn("none was found", whisper)
self.assertNotIn("no graphics backend", whisper)
def test_a_processor_nobody_argued_about_is_stated_plainly(self):
whisper, _ = self.shown(backend="CPU", device="CPU", gpu_wanted=False,
available=["CPU"])
self.assertEqual(whisper, "Loaded on the processor (CPU).")
def test_a_server_that_said_nothing_is_not_answered_for(self):
"""A whisper built by hand on a Mac prints no backend line at all."""
whisper, _ = self.shown(backend="", device="", available=[])
self.assertIn("did not say", whisper)
def test_the_line_stops_being_written_while_the_window_is_away(self):
# The events rather than show() and hide(): showing the window for real
# would send the same event down to the download boxes, which answer it
# by asking Hugging Face what models there are.
window = self.window(self.config(transcribe_provider="local"))
window.showEvent(QShowEvent())
self.assertTrue(window._local_state_timer.isActive())
window.hideEvent(QHideEvent())
self.assertFalse(window._local_state_timer.isActive())
def test_nothing_is_fetched_for_a_window_nobody_opened(self):
# DikteTest closes the network, so a request would fail the test. The
# lists are asked for when the box is shown, not when it is built.