mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Merge current local master and preserve automatic language detection
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -95,6 +96,10 @@ class DikteTest(unittest.TestCase):
|
||||
i18n.set_language("en")
|
||||
self.addCleanup(i18n.set_language, "en")
|
||||
|
||||
# Read once and kept for the life of the process, which across a test
|
||||
# run means one test's machine answering for the next one's.
|
||||
self.patch_attr(ggml, "_MEMORY", None)
|
||||
|
||||
# cli.launch_gui replaces this process with the application when no
|
||||
# instance is running. A test that reaches it would take the whole run
|
||||
# with it and hang, so it fails loudly here instead.
|
||||
|
||||
+138
-2
@@ -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
|
||||
@@ -53,6 +54,16 @@ class TimestampModel(unittest.TestCase):
|
||||
self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"),
|
||||
"whisper-1")
|
||||
|
||||
def test_openrouter_takes_the_file_model_that_was_set(self):
|
||||
self.assertEqual(
|
||||
api.timestamp_model("openrouter", "openai/gpt-4o-transcribe",
|
||||
"openai/whisper-large-v3"),
|
||||
"openai/whisper-large-v3")
|
||||
|
||||
def test_openrouter_with_no_file_model_falls_back_to_whisper(self):
|
||||
self.assertEqual(api.timestamp_model("openrouter", "openai/gpt-4o-transcribe", ""),
|
||||
"openai/whisper-1")
|
||||
|
||||
|
||||
class Explain(DikteTest):
|
||||
def error(self, status):
|
||||
@@ -311,13 +322,25 @@ class TranscribeSegments(DikteTest):
|
||||
fields = multipart_fields(calls[0])
|
||||
self.assertEqual(fields["model"], "whisper-1")
|
||||
self.assertEqual(fields["response_format"], "verbose_json")
|
||||
self.assertEqual(fields["timestamp_granularities[]"], "segment")
|
||||
# Both are asked for: whisper answers with segments, and a model that
|
||||
# does not mark them still answers with word times.
|
||||
body = calls[0].data.decode("utf-8", "replace")
|
||||
for level in ("segment", "word"):
|
||||
self.assertIn(
|
||||
f'name="timestamp_granularities[]"\r\n\r\n{level}\r\n', body)
|
||||
|
||||
def test_openrouter_uses_the_namespaced_id(self):
|
||||
with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls:
|
||||
api.transcribe_segments(OPENROUTER, self.wav)
|
||||
self.assertEqual(multipart_fields(calls[0])["model"], "openai/whisper-1")
|
||||
|
||||
def test_openrouter_asks_for_the_file_model_when_one_is_set(self):
|
||||
target = OPENROUTER._replace(file_model="mistralai/voxtral-mini-transcribe")
|
||||
with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls:
|
||||
api.transcribe_segments(target, self.wav)
|
||||
self.assertEqual(multipart_fields(calls[0])["model"],
|
||||
"mistralai/voxtral-mini-transcribe")
|
||||
|
||||
def test_groq_stays_on_the_model_it_was_given(self):
|
||||
target = GROQ._replace(model="whisper-large-v3")
|
||||
with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls:
|
||||
@@ -345,6 +368,74 @@ class TranscribeSegments(DikteTest):
|
||||
self.assertEqual(api.transcribe_segments(OPENAI, self.wav),
|
||||
[(5.0, 5.0, "hi")])
|
||||
|
||||
def test_a_long_sentence_is_broken_where_it_gets_too_long_to_read(self):
|
||||
words = [{"word": "word", "start": i * 0.2, "end": i * 0.2 + 0.2}
|
||||
for i in range(60)]
|
||||
cues = api.cues_from_words(words)
|
||||
self.assertGreater(len(cues), 1)
|
||||
for start, end, text in cues:
|
||||
self.assertLessEqual(len(text), api.MAX_CUE_CHARS)
|
||||
self.assertLessEqual(end - start, api.MAX_CUE_SECONDS + 0.2)
|
||||
|
||||
def test_a_pause_between_short_sentences_does_not_join_them(self):
|
||||
cues = api.cues_from_words([
|
||||
{"word": "Yes.", "start": 0.0, "end": 0.3},
|
||||
{"word": "No.", "start": 9.0, "end": 9.3},
|
||||
])
|
||||
self.assertEqual([(start, text) for start, _, text in cues],
|
||||
[(0.0, "Yes."), (9.0, "No.")])
|
||||
|
||||
def test_a_cue_too_short_to_read_is_held_until_the_next_one(self):
|
||||
cues = api.cues_from_words([
|
||||
{"word": "Yes.", "start": 0.0, "end": 0.3},
|
||||
{"word": "No.", "start": 9.0, "end": 9.3},
|
||||
])
|
||||
# The first has the room for it, the last has nothing after it to wait for.
|
||||
self.assertEqual(cues[0][1], api.MIN_CUE_SECONDS)
|
||||
self.assertEqual(cues[1][1], 9.0 + api.MIN_CUE_SECONDS)
|
||||
|
||||
def test_a_list_marker_does_not_end_a_cue_on_its_own(self):
|
||||
cues = api.cues_from_words([
|
||||
{"word": "1.", "start": 0.0, "end": 0.2},
|
||||
{"word": "Antivirus.", "start": 0.4, "end": 1.6},
|
||||
])
|
||||
self.assertEqual([text for _, _, text in cues], ["1. Antivirus."])
|
||||
|
||||
def test_a_sentence_ending_inside_a_quote_still_ends_the_cue(self):
|
||||
cues = api.cues_from_words([
|
||||
{"word": '"Stop', "start": 0.0, "end": 1.0},
|
||||
{"word": 'there."', "start": 1.1, "end": 2.0},
|
||||
{"word": "Then", "start": 2.2, "end": 2.6},
|
||||
])
|
||||
self.assertEqual([text for _, _, text in cues],
|
||||
['"Stop there."', "Then"])
|
||||
|
||||
def test_word_times_take_over_from_segments_too_long_to_read(self):
|
||||
# What a model that does not mark segments answers with: one entry for
|
||||
# the whole file, and the real timing in the words beside it.
|
||||
reply = {
|
||||
"text": "One. Two.",
|
||||
"segments": [{"start": 0, "end": 60, "text": "One. Two."}],
|
||||
"words": [
|
||||
{"word": "One.", "start": 0.1, "end": 1.5},
|
||||
{"word": "Two.", "start": 1.7, "end": 3.0},
|
||||
],
|
||||
}
|
||||
with fake_urlopen(reply):
|
||||
self.assertEqual(api.transcribe_segments(OPENAI, self.wav),
|
||||
[(0.1, 1.5, "One."), (1.7, 3.0, "Two.")])
|
||||
|
||||
def test_whisper_segments_are_left_alone_when_words_come_too(self):
|
||||
reply = {
|
||||
"text": "hi there",
|
||||
"segments": [{"start": 0, "end": 2, "text": "hi there"}],
|
||||
"words": [{"word": "hi", "start": 0.0, "end": 0.5},
|
||||
{"word": "there", "start": 0.5, "end": 2.0}],
|
||||
}
|
||||
with fake_urlopen(reply):
|
||||
self.assertEqual(api.transcribe_segments(OPENAI, self.wav),
|
||||
[(0.0, 2.0, "hi there")])
|
||||
|
||||
def test_a_model_that_returned_no_segments_still_gives_its_text(self):
|
||||
with fake_urlopen(self.reply([], text="the whole thing")):
|
||||
self.assertEqual(api.transcribe_segments(OPENAI, self.wav),
|
||||
@@ -446,6 +537,29 @@ class Cleanup(DikteTest):
|
||||
with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError):
|
||||
api.cleanup("hello", "k", "m", "p")
|
||||
|
||||
def test_a_reply_cut_off_at_a_ceiling_is_refused_rather_than_pasted(self):
|
||||
# Half a sentence looks like a cleaned-up transcript and is not one. The
|
||||
# caller keeps what it was given, which is the whole dictation.
|
||||
reply = {"choices": [{"message": {"content": "Hello, and then the"},
|
||||
"finish_reason": "length"}]}
|
||||
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
||||
api.cleanup("hello", "k", "m", "p")
|
||||
self.assertIn("cut off", str(caught.exception))
|
||||
|
||||
def test_a_reply_that_stopped_on_its_own_is_kept(self):
|
||||
reply = {"choices": [{"message": {"content": "Hello."},
|
||||
"finish_reason": "stop"}]}
|
||||
with fake_urlopen(reply):
|
||||
self.assertEqual(api.cleanup("hello", "k", "m", "p"), "Hello.")
|
||||
|
||||
def test_all_thinking_is_named_before_the_ceiling_it_was_cut_at(self):
|
||||
"""Both are true at once, and only one of them says what to change."""
|
||||
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"},
|
||||
"finish_reason": "length"}]}
|
||||
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
||||
api.cleanup("hello", "k", "m", "p")
|
||||
self.assertIn("Thinking", str(caught.exception))
|
||||
|
||||
def test_a_rate_limit_is_explained(self):
|
||||
with fake_urlopen(http_error(429)), \
|
||||
self.assertRaises(api.ApiError) as caught:
|
||||
@@ -454,6 +568,14 @@ class Cleanup(DikteTest):
|
||||
|
||||
|
||||
class Chat(DikteTest):
|
||||
def test_an_answer_cut_off_at_a_ceiling_is_refused_rather_than_pasted(self):
|
||||
# Half an answer reads like a whole one once it is on the screen.
|
||||
reply = {"choices": [{"message": {"content": "Booked it for the"},
|
||||
"finish_reason": "length"}]}
|
||||
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
||||
api.chat([{"role": "user", "content": "book it"}], "k", "m", "p")
|
||||
self.assertIn("cut off", str(caught.exception))
|
||||
|
||||
def test_the_history_is_sent_after_the_system_prompt(self):
|
||||
history = [{"role": "user", "content": "book it"},
|
||||
{"role": "assistant", "content": "done"}]
|
||||
@@ -599,11 +721,14 @@ if __name__ == "__main__":
|
||||
class FakeServer:
|
||||
"""A ggml.Server as far as api.py is concerned."""
|
||||
|
||||
def __init__(self, url="http://127.0.0.1:9999/v1", fails="", log=""):
|
||||
def __init__(self, url="http://127.0.0.1:9999/v1", fails="", log="",
|
||||
context=8192):
|
||||
self.url = url
|
||||
self.fails = fails
|
||||
self.log = log
|
||||
self.starts = 0
|
||||
self.held = 0
|
||||
self.context = context
|
||||
|
||||
def serve(self):
|
||||
self.starts += 1
|
||||
@@ -611,9 +736,20 @@ 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
|
||||
|
||||
def settings(self):
|
||||
return {"context": self.context}
|
||||
|
||||
|
||||
LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin")
|
||||
|
||||
|
||||
+38
-1
@@ -99,6 +99,7 @@ class Provider(DikteTest):
|
||||
self.assertEqual(assistant.executable("codex"), "codex")
|
||||
self.assertEqual(assistant.executable("agy"), "agy")
|
||||
self.assertEqual(assistant.executable("openrouter"), "")
|
||||
self.assertEqual(assistant.executable("opencode"), "")
|
||||
|
||||
def test_the_model_recorded_is_the_one_that_answered(self):
|
||||
"""The history used to write Claude's setting whoever had answered."""
|
||||
@@ -118,7 +119,8 @@ class Provider(DikteTest):
|
||||
def test_what_each_one_is_called(self):
|
||||
self.assertEqual(assistant.display_name(self.config()), "Claude")
|
||||
for name, called in (("codex", "Codex"), ("agy", "Antigravity"),
|
||||
("openrouter", "OpenRouter")):
|
||||
("openrouter", "OpenRouter"),
|
||||
("opencode", "OpenCode Go")):
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(
|
||||
assistant.display_name(self.config(assistant_provider=name)),
|
||||
@@ -736,6 +738,41 @@ class AskOpenRouter(DikteTest):
|
||||
assistant.ask("when is it", conf)
|
||||
|
||||
|
||||
class AskOpenCode(DikteTest):
|
||||
def test_a_question_and_an_answer(self):
|
||||
conf = self.config(assistant_provider="opencode",
|
||||
opencode_api_key="opencode-test-key")
|
||||
with fake_urlopen({"choices": [{"message": {"content": "on Thursday"}}]}):
|
||||
answer, warning = assistant.ask("when is it", conf)
|
||||
self.assertEqual(answer, "on Thursday")
|
||||
self.assertEqual(warning, "")
|
||||
|
||||
def test_the_conversation_is_ours_to_keep(self):
|
||||
conf = self.config(assistant_provider="opencode",
|
||||
opencode_api_key="opencode-test-key")
|
||||
with fake_urlopen({"choices": [{"message": {"content": "on Thursday"}}]}):
|
||||
assistant.ask("when is it", conf)
|
||||
stored = assistant.read_messages("opencode", 1800)
|
||||
self.assertEqual([row["content"] for row in stored],
|
||||
["when is it", "on Thursday"])
|
||||
|
||||
def test_the_model_and_endpoint_are_opencode_s_own(self):
|
||||
conf = self.config(assistant_provider="opencode",
|
||||
opencode_api_key="opencode-test-key",
|
||||
assistant_opencode_model="glm-5.3")
|
||||
with fake_urlopen({"choices": [{"message": {"content": "on Thursday"}}]}) as calls:
|
||||
assistant.ask("when is it", conf)
|
||||
sent = json.loads(calls[0].data.decode("utf-8"))
|
||||
self.assertEqual(sent["model"], "glm-5.3")
|
||||
self.assertIn("https://opencode.ai/zen/go/v1/chat/completions",
|
||||
calls[0].full_url)
|
||||
|
||||
def test_an_api_failure_reads_as_an_assistant_failure(self):
|
||||
conf = self.config(assistant_provider="opencode")
|
||||
with self.assertRaises(assistant.AssistantError):
|
||||
assistant.ask("when is it", conf)
|
||||
|
||||
|
||||
class Ask(DikteTest):
|
||||
def test_a_cli_that_is_not_installed_says_where_to_change_it(self):
|
||||
with only_these_tools(), \
|
||||
|
||||
@@ -60,6 +60,7 @@ class Provider(DikteTest):
|
||||
self.assertEqual(cleanup.executable("agy"), "agy")
|
||||
self.assertEqual(cleanup.executable("openrouter"), "")
|
||||
self.assertEqual(cleanup.executable("gemini"), "")
|
||||
self.assertEqual(cleanup.executable("opencode"), "")
|
||||
|
||||
def test_the_model_named_in_the_history_is_the_one_that_did_it(self):
|
||||
self.assertEqual(cleanup.model(self.config(cleanup_model="some/model")),
|
||||
@@ -85,6 +86,9 @@ class Provider(DikteTest):
|
||||
cleanup.model(self.config(cleanup_provider="agy",
|
||||
cleanup_agy_model="gemini-3.7-flash-low")),
|
||||
"gemini-3.7-flash-low")
|
||||
self.assertEqual(
|
||||
cleanup.model(self.config(cleanup_provider="opencode",
|
||||
cleanup_opencode_model="glm-5.3")), "glm-5.3")
|
||||
|
||||
|
||||
class OpenRouter(DikteTest):
|
||||
@@ -106,6 +110,32 @@ class OpenRouter(DikteTest):
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
|
||||
class OpenCode(DikteTest):
|
||||
def test_it_is_one_request_with_the_settings_as_they_were(self):
|
||||
conf = self.config(cleanup_provider="opencode",
|
||||
opencode_api_key="opencode-test-key",
|
||||
cleanup_opencode_model="some/model",
|
||||
cleanup_reasoning="low")
|
||||
with mock.patch.object(api, "cleanup", return_value="Done.") as call:
|
||||
self.assertEqual(cleanup.run("uh, done", conf, "the rules"), "Done.")
|
||||
text, key, model, prompt = call.call_args.args
|
||||
self.assertEqual((text, key, model, prompt),
|
||||
("uh, done", "opencode-test-key", "some/model", "the rules"))
|
||||
self.assertEqual(call.call_args.kwargs["reasoning"], "low")
|
||||
self.assertEqual(call.call_args.kwargs["provider"], "opencode")
|
||||
self.assertEqual(call.call_args.kwargs["service"], "OpenCode Go")
|
||||
self.assertEqual(call.call_args.kwargs["base_url"],
|
||||
"https://opencode.ai/zen/go/v1")
|
||||
|
||||
def test_no_cli_is_started_for_it(self):
|
||||
conf = self.config(cleanup_provider="opencode",
|
||||
opencode_api_key="opencode-test-key")
|
||||
patcher, calls = fake_cli(stdout="never")
|
||||
with patcher, mock.patch.object(api, "cleanup", return_value="Done."):
|
||||
cleanup.run("uh, done", conf, "the rules")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
|
||||
class GoogleAiStudio(DikteTest):
|
||||
"""Cleanup over Google's OpenAI-compatible endpoint: one request, no CLI."""
|
||||
|
||||
@@ -381,6 +411,52 @@ class Here(DikteTest):
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertEqual(sent_json(calls[0])["max_tokens"], 512)
|
||||
|
||||
def test_thinking_is_given_room_of_its_own_rather_than_the_answer_s(self):
|
||||
# llama.cpp counts the thinking towards the same ceiling, so a rung that
|
||||
# took its budget out of the answer would leave a short dictation with
|
||||
# nothing to reply with. On a context roomy enough that the clamp the
|
||||
# top rung would otherwise meet is not what is being measured.
|
||||
self.patch_attr(ggml, "llm", FakeServer(context=32768))
|
||||
for rung, room in api.THINKING_ROOM.items():
|
||||
with self.subTest(rung=rung):
|
||||
self.conf["local_llm_reasoning"] = rung
|
||||
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertEqual(sent_json(calls[0])["max_tokens"], 512 + room)
|
||||
|
||||
def test_each_rung_of_the_ladder_thinks_longer_than_the_one_below(self):
|
||||
rungs = [api.THINKING_ROOM[name] for name in
|
||||
("minimal", "low", "medium", "high", "xhigh", "max")]
|
||||
self.assertEqual(rungs, sorted(rungs))
|
||||
self.assertEqual(len(set(rungs)), len(rungs))
|
||||
|
||||
def test_the_models_own_default_is_given_room_to_think_in_too(self):
|
||||
# Nothing is sent, so a template that thinks will think, and the ceiling
|
||||
# has to survive that as well.
|
||||
self.conf["local_llm_reasoning"] = ""
|
||||
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertEqual(sent_json(calls[0])["max_tokens"],
|
||||
512 + api.DEFAULT_THINKING_ROOM)
|
||||
|
||||
def test_the_ceiling_stays_under_the_context_the_server_was_started_with(self):
|
||||
# Above the context there is no ceiling at all: the runaway would run to
|
||||
# the end of the context instead of stopping where this says.
|
||||
self.patch_attr(ggml, "llm", FakeServer(context=2048))
|
||||
self.conf["local_llm_reasoning"] = "max"
|
||||
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertLess(sent_json(calls[0])["max_tokens"], 2048)
|
||||
|
||||
def test_the_prompt_keeps_its_share_of_a_small_context(self):
|
||||
self.patch_attr(ggml, "llm", FakeServer(context=2048))
|
||||
self.conf["local_llm_reasoning"] = "max"
|
||||
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||
cleanup.run("x" * 2000, self.conf, "the rules")
|
||||
# 2048 less half the characters of prompt and transcript together.
|
||||
self.assertEqual(sent_json(calls[0])["max_tokens"],
|
||||
2048 - (len("the rules") + 2000) // 2)
|
||||
|
||||
def test_a_reply_that_was_all_thinking_names_the_setting_that_fixes_it(self):
|
||||
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]}
|
||||
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
||||
|
||||
@@ -9,6 +9,7 @@ socket is faked, and everything that runs locally runs for real.
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
import webbrowser
|
||||
from typing import ClassVar
|
||||
@@ -424,6 +425,16 @@ class Providers(DikteTest):
|
||||
self.assertIn("groq", out)
|
||||
self.assertIn("Groq", out)
|
||||
|
||||
def test_opencode_is_a_choice_and_reports_under_its_own_name(self):
|
||||
parser = cli.build_parser()
|
||||
self.assertEqual(
|
||||
parser.parse_args(["test-key", "opencode"]).which, "opencode")
|
||||
self.write_config({"opencode_api_key": "opencode-test"})
|
||||
with fake_urlopen({"data": [{"id": "deepseek-v4-flash"}]}):
|
||||
code, out, _ = self.run_cmd(cli.cmd_test_key, which="opencode")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("opencode: connection works, 1 models visible", out)
|
||||
|
||||
|
||||
class Updates(DikteTest):
|
||||
"""`dikte update` looks, says what it found, and installs nothing."""
|
||||
@@ -504,6 +515,15 @@ class Doctor(DikteTest):
|
||||
self.assertIn("OpenRouter key, cleaning up on some/model",
|
||||
self.run_doctor(as_json=False, cleanup_model="some/model"))
|
||||
|
||||
def test_cleanup_on_opencode_is_a_question_about_its_own_key(self):
|
||||
reply = self.run_doctor(cleanup_provider="opencode",
|
||||
cleanup_opencode_model="glm-5.3")
|
||||
self.assertEqual(reply["cleanup"]["provider"], "opencode")
|
||||
self.assertEqual(reply["cleanup"]["model"], "glm-5.3")
|
||||
self.assertIn("OpenCode Go key, cleaning up on glm-5.3",
|
||||
self.run_doctor(as_json=False, cleanup_provider="opencode",
|
||||
cleanup_opencode_model="glm-5.3"))
|
||||
|
||||
def test_it_survives_every_provider_cleanup_can_be_set_to(self):
|
||||
"""It used to raise KeyError on the local model, whose executable is ""."""
|
||||
for name in cleanup.PROVIDERS:
|
||||
@@ -649,7 +669,11 @@ class WithoutAnInstance(DikteTest):
|
||||
def run_verb(self, argv):
|
||||
# launch_gui replaces this process with the application, so it never
|
||||
# comes back in real use and must not be allowed to here.
|
||||
# `ask` with no text reads what was piped in, and the runner's own
|
||||
# stdin is not that: under pytest it is an object that refuses to be
|
||||
# read at all.
|
||||
with mock.patch.object(ipc, "send", return_value=None), \
|
||||
mock.patch.object(sys, "stdin", io.StringIO()), \
|
||||
mock.patch.object(cli, "launch_gui") as launch, \
|
||||
captured() as (out, err):
|
||||
code = cli.run(argv)
|
||||
|
||||
@@ -189,6 +189,8 @@ class Keys(DikteTest):
|
||||
self.assertEqual(cfg.Config().groq_key(), "gsk-env")
|
||||
with mock.patch.dict(os.environ, {"GEMINI_API_KEY": "AIza-env"}):
|
||||
self.assertEqual(cfg.Config().gemini_key(), "AIza-env")
|
||||
with mock.patch.dict(os.environ, {"OPENCODE_API_KEY": "opencode-env"}):
|
||||
self.assertEqual(cfg.Config().opencode_key(), "opencode-env")
|
||||
|
||||
|
||||
class TranscribeTarget(DikteTest):
|
||||
@@ -218,6 +220,19 @@ class TranscribeTarget(DikteTest):
|
||||
self.assertEqual(target.service, "OpenRouter")
|
||||
self.assertEqual(target.api_key, "sk-or-test")
|
||||
self.assertEqual(target.model, "openai/whisper-1")
|
||||
self.assertEqual(target.file_model, "")
|
||||
|
||||
def test_openrouter_carries_its_file_model(self):
|
||||
conf = self.config(transcribe_provider="openrouter",
|
||||
openrouter_api_key="sk-or-test",
|
||||
openrouter_file_model=" openai/whisper-large-v3 ")
|
||||
self.assertEqual(conf.transcribe_target().file_model,
|
||||
"openai/whisper-large-v3")
|
||||
|
||||
def test_only_openrouter_has_a_file_model(self):
|
||||
conf = self.config(transcribe_provider="openai", openai_api_key="sk-test",
|
||||
openrouter_file_model="openai/whisper-large-v3")
|
||||
self.assertEqual(conf.transcribe_target().file_model, "")
|
||||
|
||||
def test_groq_when_it_is_picked(self):
|
||||
conf = self.config(transcribe_provider="groq", groq_api_key="gsk-test",
|
||||
@@ -589,12 +604,19 @@ class Defaults(unittest.TestCase):
|
||||
self.assertEqual(cfg.DEFAULTS["openai_api_key"], "")
|
||||
self.assertEqual(cfg.DEFAULTS["openrouter_api_key"], "")
|
||||
self.assertEqual(cfg.DEFAULTS["gemini_api_key"], "")
|
||||
self.assertEqual(cfg.DEFAULTS["opencode_api_key"], "")
|
||||
|
||||
def test_google_ai_studio_is_a_cleanup_provider_and_not_a_transcriber(self):
|
||||
"""Its compatible endpoint has no /audio/transcriptions behind it."""
|
||||
self.assertNotIn("gemini", cfg.TRANSCRIBERS)
|
||||
self.assertIn("gemini", cleanup.PROVIDERS)
|
||||
|
||||
def test_opencode_ships_on_its_own_endpoint(self):
|
||||
self.assertEqual(cfg.DEFAULTS["opencode_base_url"],
|
||||
"https://opencode.ai/zen/go/v1")
|
||||
self.assertEqual(cfg.DEFAULTS["cleanup_opencode_model"], "deepseek-v4-flash")
|
||||
self.assertEqual(cfg.DEFAULTS["assistant_opencode_model"], "deepseek-v4-flash")
|
||||
|
||||
def test_every_language_specific_prompt_has_both_languages(self):
|
||||
for name in ("CLEANUP_PROMPT", "FILE_CLEANUP_PROMPT", "MEETING_PROMPT",
|
||||
"ASSISTANT_PROMPT"):
|
||||
@@ -679,3 +701,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)
|
||||
|
||||
+533
-1
@@ -49,6 +49,11 @@ def item(name, data, url="https://example.invalid/f", sha=True):
|
||||
hashlib.sha256(data).hexdigest() if sha else "")
|
||||
|
||||
|
||||
def listed(name, size):
|
||||
"""A row as a listing hands it over: a name and a size, no bytes."""
|
||||
return hub.Item(name, f"https://example.invalid/{name}", size, "a" * 64)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def serving(release, archive):
|
||||
"""Answer by what is being asked for rather than by what came before.
|
||||
@@ -205,6 +210,7 @@ class InstallProgram(Local):
|
||||
# These fixtures are Ubuntu release archives. Keep checking that path
|
||||
# on every host, including the Mac that checks the macOS backend.
|
||||
self.patch_attr(sys, "platform", "linux")
|
||||
self.patch_attr(ggml.platform, "machine", lambda: "x86_64")
|
||||
# Built once, because the release listing has to publish its checksum
|
||||
# and a tarball is not the same bytes twice.
|
||||
self.archive = tarball({
|
||||
@@ -221,6 +227,7 @@ class InstallProgram(Local):
|
||||
|
||||
def install(self, *names, archive=None):
|
||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||
self.patch_attr(ggml, "_has_vulkan", lambda: False)
|
||||
blob = self.archive if archive is None else archive
|
||||
with serving(self.release(*names, archive=blob), blob) as calls:
|
||||
path = ggml.install_program(ggml.WHISPER)
|
||||
@@ -238,6 +245,162 @@ class InstallProgram(Local):
|
||||
"whisper-bin-ubuntu-x64.tar.gz")
|
||||
self.assertTrue(urls[1].endswith("whisper-bin-ubuntu-x64.tar.gz"))
|
||||
|
||||
def test_the_nightly_pointer_is_followed_to_where_the_builds_are(self):
|
||||
"""llama.cpp's latest release carries a tag name, not the binaries."""
|
||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||
self.patch_attr(ggml, "_has_vulkan", lambda: False)
|
||||
marker = self.release(ggml.NIGHTLY_TAG)
|
||||
nightly = dict(self.release("llama-b10809-bin-ubuntu-x64.tar.gz"),
|
||||
tag_name="b10809")
|
||||
|
||||
def opener(request, timeout=None):
|
||||
url = request.full_url
|
||||
if url.endswith("/releases/latest"):
|
||||
return json_body(marker)
|
||||
if url.endswith("/releases/tags/b10809"):
|
||||
return json_body(nightly)
|
||||
if url.endswith(ggml.NIGHTLY_TAG):
|
||||
return body(b"b10809\n")
|
||||
return body(self.archive)
|
||||
|
||||
with mock.patch("urllib.request.urlopen", side_effect=opener):
|
||||
tag, found = ggml._pick_asset(ggml.LLAMA)
|
||||
self.assertEqual(tag, "b10809")
|
||||
self.assertEqual(found.name, "llama-b10809-bin-ubuntu-x64.tar.gz")
|
||||
|
||||
def test_without_a_pointer_the_newest_release_that_has_a_build_is_taken(self):
|
||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||
self.patch_attr(ggml, "_has_vulkan", lambda: False)
|
||||
marker = self.release("source.zip")
|
||||
listing = [dict(self.release("llama-b2-bin-win-cpu-x64.zip"), tag_name="b2"),
|
||||
dict(self.release("llama-b1-bin-ubuntu-x64.tar.gz"), tag_name="b1")]
|
||||
|
||||
def opener(request, timeout=None):
|
||||
url = request.full_url
|
||||
return json_body(listing if "per_page" in url else marker)
|
||||
|
||||
with mock.patch("urllib.request.urlopen", side_effect=opener):
|
||||
tag, found = ggml._pick_asset(ggml.LLAMA)
|
||||
self.assertEqual(tag, "b1")
|
||||
self.assertEqual(found.name, "llama-b1-bin-ubuntu-x64.tar.gz")
|
||||
def test_linux_x64_with_vulkan_takes_diktes_accelerated_build(self):
|
||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||
self.patch_attr(ggml, "_has_vulkan", lambda: True)
|
||||
listing = self.release("whisper-bin-ubuntu-vulkan-x64.tar.gz")
|
||||
listing["tag_name"] = "whisper.cpp-v1.9.3"
|
||||
managed_sha = hashlib.sha256(self.archive).hexdigest()
|
||||
with mock.patch.object(ggml, "MANAGED_WHISPER_SHA256", managed_sha,
|
||||
create=True):
|
||||
with fake_urlopen(listing, body(self.archive)) as calls:
|
||||
path = ggml.install_program(ggml.WHISPER)
|
||||
urls = [call.full_url for call in calls]
|
||||
self.assertIn(
|
||||
"/repos/yusufipk/dikte/releases/tags/whisper.cpp-v1.9.3",
|
||||
urls[0],
|
||||
)
|
||||
self.assertTrue(urls[1].endswith(
|
||||
"whisper-bin-ubuntu-vulkan-x64.tar.gz"))
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
self.assertEqual("v1.9.3", ggml.installed_version(ggml.WHISPER))
|
||||
self.assertFalse(ggml.vulkan_missing(ggml.WHISPER))
|
||||
|
||||
def test_an_explicit_whisper_version_still_comes_from_upstream(self):
|
||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||
self.patch_attr(ggml, "_has_vulkan", lambda: True)
|
||||
listing = self.release("whisper-bin-ubuntu-x64.tar.gz")
|
||||
with fake_urlopen(listing, body(self.archive)) as calls:
|
||||
ggml.install_program(ggml.WHISPER, tag="v1.9.1")
|
||||
self.assertIn(
|
||||
"/repos/ggml-org/whisper.cpp/releases/tags/v1.9.1",
|
||||
calls[0].full_url,
|
||||
)
|
||||
|
||||
def test_linux_arm64_keeps_using_the_upstream_cpu_build(self):
|
||||
self.patch_attr(ggml, "_arch", lambda: "arm64")
|
||||
self.patch_attr(ggml.platform, "machine", lambda: "aarch64")
|
||||
self.patch_attr(ggml, "_has_vulkan", lambda: True)
|
||||
listing = self.release("whisper-bin-ubuntu-arm64.tar.gz")
|
||||
with fake_urlopen(listing, body(self.archive)) as calls:
|
||||
ggml.install_program(ggml.WHISPER)
|
||||
self.assertIn(
|
||||
"/repos/ggml-org/whisper.cpp/releases/latest",
|
||||
calls[0].full_url,
|
||||
)
|
||||
|
||||
def test_linux_non_x86_does_not_try_the_managed_x64_build(self):
|
||||
self.patch_attr(ggml, "_has_vulkan", lambda: True)
|
||||
listing = self.release("whisper-bin-ubuntu-arm64.tar.gz")
|
||||
with mock.patch("platform.machine", return_value="ppc64le"):
|
||||
with fake_urlopen(listing, listing) as calls:
|
||||
with self.assertRaises(ggml.LocalError):
|
||||
ggml.install_program(ggml.WHISPER)
|
||||
self.assertIn(
|
||||
"/repos/ggml-org/whisper.cpp/releases/latest",
|
||||
calls[0].full_url,
|
||||
)
|
||||
|
||||
def test_a_missing_managed_build_falls_back_to_upstream_cpu(self):
|
||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||
self.patch_attr(ggml, "_has_vulkan", lambda: True)
|
||||
managed = self.release("Dikte-1.1.0-x86_64.AppImage")
|
||||
managed["tag_name"] = "whisper.cpp-v1.9.3"
|
||||
upstream = self.release("whisper-bin-ubuntu-x64.tar.gz")
|
||||
with fake_urlopen(managed, upstream, body(self.archive)) as calls:
|
||||
path = ggml.install_program(ggml.WHISPER)
|
||||
urls = [call.full_url for call in calls]
|
||||
self.assertIn(
|
||||
"/repos/yusufipk/dikte/releases/tags/whisper.cpp-v1.9.3",
|
||||
urls[0],
|
||||
)
|
||||
self.assertIn("/repos/ggml-org/whisper.cpp/releases/latest", urls[1])
|
||||
self.assertTrue(urls[2].endswith("whisper-bin-ubuntu-x64.tar.gz"))
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
|
||||
def test_a_managed_build_with_an_unreviewed_digest_falls_back(self):
|
||||
self.patch_attr(ggml, "_has_vulkan", lambda: True)
|
||||
managed = self.release("whisper-bin-ubuntu-vulkan-x64.tar.gz")
|
||||
managed["assets"][0]["digest"] = "sha256:" + "0" * 64
|
||||
upstream = self.release("whisper-bin-ubuntu-x64.tar.gz")
|
||||
with fake_urlopen(managed, upstream, body(self.archive)) as calls:
|
||||
try:
|
||||
path = ggml.install_program(ggml.WHISPER)
|
||||
except ggml.LocalError as exc:
|
||||
self.fail(f"unreviewed digest did not fall back: {exc}")
|
||||
urls = [call.full_url for call in calls]
|
||||
self.assertEqual(3, len(urls))
|
||||
self.assertTrue(urls[2].endswith("whisper-bin-ubuntu-x64.tar.gz"))
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
|
||||
def test_an_unavailable_managed_release_falls_back_to_upstream_cpu(self):
|
||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||
self.patch_attr(ggml, "_has_vulkan", lambda: True)
|
||||
upstream = self.release("whisper-bin-ubuntu-x64.tar.gz")
|
||||
with fake_urlopen(http_error(404), upstream,
|
||||
body(self.archive)) as calls:
|
||||
path = ggml.install_program(ggml.WHISPER)
|
||||
self.assertEqual(3, len(calls))
|
||||
self.assertTrue(calls[2].full_url.endswith(
|
||||
"whisper-bin-ubuntu-x64.tar.gz"))
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
|
||||
def test_a_fallback_to_the_processor_build_is_there_to_be_shown(self):
|
||||
"""Until the Vulkan package is published every download lands the
|
||||
processor build, and a graphics card sitting idle looks exactly like
|
||||
one being used. The window asks this and says so."""
|
||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||
self.patch_attr(ggml, "_has_vulkan", lambda: True)
|
||||
managed = self.release("Dikte-1.1.0-x86_64.AppImage")
|
||||
managed["tag_name"] = "whisper.cpp-v1.9.3"
|
||||
upstream = self.release("whisper-bin-ubuntu-x64.tar.gz")
|
||||
with fake_urlopen(managed, upstream, body(self.archive)):
|
||||
ggml.install_program(ggml.WHISPER)
|
||||
self.assertTrue(ggml.vulkan_missing(ggml.WHISPER))
|
||||
|
||||
def test_a_machine_with_no_vulkan_is_not_told_it_is_missing_one(self):
|
||||
# Nothing was on offer to fall back from, so there is nothing to say.
|
||||
self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||
self.assertFalse(ggml.vulkan_missing(ggml.WHISPER))
|
||||
|
||||
def test_a_release_with_nothing_for_this_machine_says_so(self):
|
||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||
with fake_urlopen(self.release("whisper-bin-Win32.zip")):
|
||||
@@ -506,6 +669,53 @@ class Catalogue(Local):
|
||||
with self.assertRaises(ggml.LocalError):
|
||||
ggml.whisper_models()
|
||||
|
||||
def test_the_speculative_decoding_heads_are_not_models(self):
|
||||
# They are the small files in a repository, so a list sorted by size
|
||||
# puts them first, where the eye lands and the click goes.
|
||||
tree = GGUF_TREE + [
|
||||
{"type": "file", "path": "dflash-Qwen3-8B-Q8_0.gguf",
|
||||
"size": 1_120_000_000, "lfs": {"oid": "f" * 64}},
|
||||
{"type": "file", "path": "eagle3-gpt-oss-20b-Q8_0.gguf",
|
||||
"size": 920_000_000, "lfs": {"oid": "0" * 64}},
|
||||
]
|
||||
with fake_urlopen(tree):
|
||||
names = [q.name for q in ggml.llm_quants("ggml-org/x-GGUF")]
|
||||
self.assertEqual(names,
|
||||
["gemma-3-4b-it-Q4_K_M.gguf", "gemma-3-4b-it-Q8_0.gguf"])
|
||||
|
||||
def test_a_speech_or_vision_repository_is_not_a_cleanup_publisher(self):
|
||||
listing = [{"id": "ggml-org/parakeet-GGUF"},
|
||||
{"id": "ggml-org/Qwen3-TTS-12Hz-1.7B-Base-GGUF"},
|
||||
{"id": "ggml-org/SmolVLM2-256M-Video-Instruct-GGUF"},
|
||||
{"id": "ggml-org/Qwen3-8B-Base-GGUF"},
|
||||
{"id": "ggml-org/SmolLM3-3B-GGUF"}]
|
||||
with fake_urlopen(listing):
|
||||
found = ggml.llm_repos()
|
||||
self.assertEqual([r for r in found if r.startswith("ggml-org/Smol")],
|
||||
["ggml-org/SmolLM3-3B-GGUF"])
|
||||
self.assertNotIn("ggml-org/parakeet-GGUF", found)
|
||||
self.assertNotIn("ggml-org/Qwen3-8B-Base-GGUF", found)
|
||||
|
||||
def test_a_publisher_is_not_dropped_for_a_word_it_happens_to_contain(self):
|
||||
# The skip marks are matched as plain substrings, and an unanchored
|
||||
# "test-" is also inside "Latest-".
|
||||
self.assertTrue(ggml.can_clean("ggml-org/Qwen3-Latest-GGUF"))
|
||||
self.assertFalse(ggml.can_clean("ggml-org/test-model-router-download"))
|
||||
|
||||
def test_a_base_model_beside_its_tuned_twin_is_dropped(self):
|
||||
# Gemma names the base model after the tuned one with the `-it` taken
|
||||
# out, so the two sit next to each other and the wrong one answers a
|
||||
# cleanup prompt by carrying on writing the transcript.
|
||||
listing = [{"id": "ggml-org/gemma-4-E2B-GGUF"},
|
||||
{"id": "ggml-org/gemma-4-E2B-it-GGUF"},
|
||||
{"id": "ggml-org/Qwen3-0.6B-GGUF"}]
|
||||
with fake_urlopen(listing):
|
||||
found = ggml.llm_repos()
|
||||
self.assertNotIn("ggml-org/gemma-4-E2B-GGUF", found)
|
||||
self.assertIn("ggml-org/gemma-4-E2B-it-GGUF", found)
|
||||
# Nothing named it, so nothing says it is the wrong half of a pair.
|
||||
self.assertIn("ggml-org/Qwen3-0.6B-GGUF", found)
|
||||
|
||||
def test_what_is_on_disk_is_read_from_disk(self):
|
||||
self.assertEqual(ggml.installed_whisper_models(), [])
|
||||
path = ggml.whisper_model_path("ggml-base.bin")
|
||||
@@ -573,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)
|
||||
@@ -596,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()
|
||||
@@ -827,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."""
|
||||
|
||||
@@ -1025,3 +1355,205 @@ class WindowsOwnership(Local):
|
||||
# from here", and only one of those makes the pid file safe to drop.
|
||||
self.image("")
|
||||
self.assertIsNone(self.made._is_ours(1234))
|
||||
|
||||
|
||||
class Machine(Local):
|
||||
"""What this machine can hold, and what that makes worth pointing at."""
|
||||
|
||||
def _sysconf(self, phys_pages, page_size=4096):
|
||||
"""Stand where sysconf answers whatever this test wants it to.
|
||||
|
||||
`create` because Windows has no os.sysconf at all, and a patch that
|
||||
insists on the real attribute fails there before the test runs. What
|
||||
the code under test does about that absence is two lines down from
|
||||
what these are checking, and it is checked on its own below.
|
||||
"""
|
||||
return mock.patch.object(
|
||||
ggml.os, "sysconf", create=True,
|
||||
side_effect=lambda name: (page_size if name == "SC_PAGE_SIZE"
|
||||
else phys_pages))
|
||||
|
||||
def test_the_memory_is_read_the_way_each_system_reports_it(self):
|
||||
# Linux and most Macs answer through sysconf.
|
||||
with self._sysconf(4_194_304):
|
||||
self.assertEqual(ggml.total_memory(), 16 * ggml.GB)
|
||||
|
||||
def test_a_mac_without_the_page_count_is_asked_for_the_number(self):
|
||||
# Not every build of Python on a Mac carries SC_PHYS_PAGES, and a Mac
|
||||
# that answered nothing would be a Mac with none of this on it.
|
||||
def answer(args, **kwargs):
|
||||
self.assertEqual(args, ["sysctl", "-n", "hw.memsize"])
|
||||
return mock.Mock(stdout=f"{32 * ggml.GB}\n")
|
||||
|
||||
with mock.patch.object(ggml.os, "sysconf", create=True,
|
||||
side_effect=ValueError), \
|
||||
mock.patch.object(sys, "platform", "darwin"), \
|
||||
mock.patch.object(ggml.subprocess, "run", answer):
|
||||
self.assertEqual(ggml.total_memory(), 32 * ggml.GB)
|
||||
|
||||
def test_a_sysconf_that_shrugs_is_an_unknown_machine_and_not_a_tiny_one(self):
|
||||
# sysconf answers -1 for a limit it holds to be indeterminate and
|
||||
# CPython hands that back rather than raising, so the product came out
|
||||
# negative: a 64 GB workstation was told every model past 512 MB was
|
||||
# too big for it, and the machine line read "Memory: -4096 B".
|
||||
with self._sysconf(-1):
|
||||
self.assertEqual(ggml.total_memory(), 0)
|
||||
self.assertTrue(ggml.fits(574 << 20, memory=0))
|
||||
|
||||
def test_the_memory_is_read_once_and_kept(self):
|
||||
# A list of thirty rows asks seventy times, and on the Mac path the
|
||||
# answer comes from a program rather than a library call.
|
||||
calls = []
|
||||
with mock.patch.object(ggml, "_read_memory",
|
||||
lambda: calls.append(1) or 16 * ggml.GB):
|
||||
self.assertEqual(ggml.total_memory(), 16 * ggml.GB)
|
||||
self.assertEqual(ggml.total_memory(), 16 * ggml.GB)
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_a_system_that_answers_nothing_is_an_unknown_machine(self):
|
||||
with mock.patch.object(ggml.os, "sysconf", create=True,
|
||||
side_effect=ValueError), \
|
||||
mock.patch.object(sys, "platform", "linux"):
|
||||
self.assertEqual(ggml.total_memory(), 0)
|
||||
|
||||
def test_a_mac_is_taken_to_have_a_graphics_interface(self):
|
||||
with mock.patch.object(sys, "platform", "darwin"):
|
||||
self.assertEqual(ggml.accelerator(), "Metal")
|
||||
|
||||
def test_elsewhere_the_vulkan_loader_is_what_says_so(self):
|
||||
with mock.patch.object(sys, "platform", "linux"), \
|
||||
mock.patch.object(ggml.ctypes.util, "find_library",
|
||||
lambda name: "/usr/lib/libvulkan.so.1"):
|
||||
self.assertEqual(ggml.accelerator(), "Vulkan")
|
||||
with mock.patch.object(sys, "platform", "linux"), \
|
||||
mock.patch.object(ggml.ctypes.util, "find_library",
|
||||
lambda name: None):
|
||||
self.assertEqual(ggml.accelerator(), "")
|
||||
|
||||
def test_a_model_is_measured_against_half_the_memory(self):
|
||||
self.assertTrue(ggml.fits(2 * ggml.GB, memory=8 * ggml.GB))
|
||||
self.assertFalse(ggml.fits(4 * ggml.GB, memory=8 * ggml.GB))
|
||||
|
||||
def test_a_machine_whose_memory_could_not_be_read_holds_anything(self):
|
||||
# A wrong "too big" is worse advice than none.
|
||||
self.assertTrue(ggml.fits(40 * ggml.GB, memory=0))
|
||||
|
||||
def test_the_smallest_machine_is_not_the_one_where_everything_fits(self):
|
||||
# Half of 2 GB less the gigabyte of overhead is nothing, and a budget
|
||||
# of nothing used to read as the unknown machine above.
|
||||
self.assertFalse(ggml.fits(3 * ggml.GB, memory=2 * ggml.GB))
|
||||
|
||||
def test_a_crowded_machine_is_pointed_at_the_smaller_model(self):
|
||||
self.assertEqual(ggml.suggested_whisper(memory=3 * ggml.GB, graphics=""),
|
||||
ggml.SMALL_MACHINE_WHISPER)
|
||||
|
||||
def test_a_card_and_the_memory_for_it_are_pointed_at_the_accurate_one(self):
|
||||
self.assertEqual(
|
||||
ggml.suggested_whisper(memory=32 * ggml.GB, graphics="Vulkan"),
|
||||
ggml.ACCURATE_WHISPER)
|
||||
|
||||
def test_memory_without_a_card_is_pointed_at_the_fast_one(self):
|
||||
# Several times the work per second is several times a long wait on a
|
||||
# processor, whatever there is room for.
|
||||
self.assertEqual(
|
||||
ggml.suggested_whisper(memory=32 * ggml.GB, graphics=""),
|
||||
ggml.SUGGESTED_WHISPER)
|
||||
|
||||
def test_a_sixteen_gigabyte_machine_counts_as_a_roomy_one(self):
|
||||
# What a machine reports is what the firmware and the graphics left
|
||||
# of it: 16 GB answers about 15.4, and a threshold written at the
|
||||
# number on the box is one no machine ever reaches.
|
||||
self.assertEqual(
|
||||
ggml.suggested_whisper(memory=int(15.4 * ggml.GB), graphics="Metal"),
|
||||
ggml.ACCURATE_WHISPER)
|
||||
|
||||
def test_the_suggestion_that_fits_is_offered_first(self):
|
||||
first = ggml.suggested_llm(memory=6 * ggml.GB)[0]
|
||||
self.assertTrue(ggml.fits(ggml.SUGGESTED_LLM_SIZE[first],
|
||||
memory=6 * ggml.GB))
|
||||
# Nothing is dropped: what does not fit today fits once something else
|
||||
# is closed.
|
||||
self.assertEqual(sorted(ggml.suggested_llm(memory=6 * ggml.GB)),
|
||||
sorted(ggml.SUGGESTED_LLM))
|
||||
|
||||
def test_the_wanted_model_wins_when_there_is_room_for_it(self):
|
||||
items = [listed("ggml-tiny.bin", 70 << 20),
|
||||
listed("ggml-large-v3-turbo-q5_0.bin", 574 << 20)]
|
||||
self.assertEqual(
|
||||
ggml.recommended(items, "ggml-large-v3-turbo-q5_0.bin",
|
||||
memory=16 * ggml.GB),
|
||||
"ggml-large-v3-turbo-q5_0.bin")
|
||||
|
||||
def test_a_model_too_big_for_the_machine_is_not_recommended(self):
|
||||
items = [listed("small.gguf", 1 << 30), listed("huge.gguf", 12 * ggml.GB)]
|
||||
self.assertEqual(ggml.recommended(items, "huge.gguf",
|
||||
memory=8 * ggml.GB), "small.gguf")
|
||||
|
||||
def test_the_full_precision_weights_are_never_the_recommendation(self):
|
||||
# Twice the memory and twice the wait for a difference this job
|
||||
# cannot see.
|
||||
items = [listed("model-Q4_0.gguf", 2 * ggml.GB),
|
||||
listed("model-BF16.gguf", 3 * ggml.GB)]
|
||||
self.assertEqual(ggml.recommended(items, memory=32 * ggml.GB),
|
||||
"model-Q4_0.gguf")
|
||||
|
||||
def test_nothing_is_recommended_when_nothing_fits(self):
|
||||
self.assertEqual(
|
||||
ggml.recommended([listed("huge.gguf", 40 * ggml.GB)],
|
||||
memory=8 * ggml.GB), "")
|
||||
|
||||
|
||||
class Grouping(Local):
|
||||
"""One group per model, rather than one long list sorted by size."""
|
||||
|
||||
def test_every_spelling_of_a_quantisation_reads_as_its_number(self):
|
||||
# One list holds q5_1, Q4_K_M, MXFP4 and BF16, and the number is the
|
||||
# whole of what any of them says to somebody choosing a row.
|
||||
self.assertEqual(ggml.bit_depth("ggml-small-q5_1.bin"), 5)
|
||||
self.assertEqual(ggml.bit_depth("SmolLM3-Q4_K_M.gguf"), 4)
|
||||
self.assertEqual(ggml.bit_depth("gpt-oss-20b-MXFP4.gguf"), 4)
|
||||
self.assertEqual(ggml.bit_depth("gemma-4-E2B-it-Q8_0.gguf"), 8)
|
||||
# bf16 is not f16 read badly.
|
||||
self.assertEqual(ggml.bit_depth("gemma-4-E2B-it-BF16.gguf"), 16)
|
||||
self.assertEqual(ggml.bit_depth("mmproj-model-f16.gguf"), 16)
|
||||
# A whisper file with no mark is the full model, and its name is the
|
||||
# one convention here that does not carry the answer.
|
||||
self.assertEqual(ggml.bit_depth("ggml-large-v3-turbo.bin"), 0)
|
||||
|
||||
def test_a_quantisation_belongs_to_the_model_it_is_a_copy_of(self):
|
||||
self.assertEqual(ggml.whisper_family("ggml-small.en-q5_1.bin"), "small")
|
||||
self.assertEqual(ggml.whisper_family("ggml-large-v3-q5_0.bin"),
|
||||
"large-v3")
|
||||
self.assertEqual(ggml.whisper_family("ggml-large-v3-turbo.bin"),
|
||||
"large-v3-turbo")
|
||||
self.assertEqual(ggml.whisper_family("ggml-medium.en.bin"), "medium")
|
||||
|
||||
def test_turbo_is_a_model_and_not_a_quantisation(self):
|
||||
# The last chunk of the name is a quantisation for most of the list
|
||||
# and part of the model's name here.
|
||||
self.assertEqual(ggml.whisper_family("ggml-large-v3-turbo-q8_0.bin"),
|
||||
"large-v3-turbo")
|
||||
|
||||
def test_the_turbo_files_are_not_scattered_through_the_medium_ones(self):
|
||||
# Sorted by size alone, large-v3-turbo-q5_0 lands between the two
|
||||
# medium quantisations, half a screen from the model it is a copy of.
|
||||
models = [listed("ggml-medium-q5_0.bin", 539 << 20),
|
||||
listed("ggml-large-v3-turbo-q5_0.bin", 574 << 20),
|
||||
listed("ggml-medium-q8_0.bin", 823 << 20),
|
||||
listed("ggml-large-v3-turbo.bin", 1624 << 20)]
|
||||
groups = dict(ggml.whisper_groups(models))
|
||||
self.assertEqual([i.name for i in groups["large-v3-turbo"]],
|
||||
["ggml-large-v3-turbo-q5_0.bin",
|
||||
"ggml-large-v3-turbo.bin"])
|
||||
self.assertEqual([i.name for i in groups["medium"]],
|
||||
["ggml-medium-q5_0.bin", "ggml-medium-q8_0.bin"])
|
||||
|
||||
def test_the_smallest_model_comes_first_and_the_english_ones_last(self):
|
||||
models = [listed("ggml-small.en-q5_1.bin", 190 << 20),
|
||||
listed("ggml-small-q5_1.bin", 190 << 20),
|
||||
listed("ggml-tiny.bin", 77 << 20)]
|
||||
groups = ggml.whisper_groups(models)
|
||||
self.assertEqual([family for family, _ in groups], ["tiny", "small"])
|
||||
self.assertEqual([i.name for _, group in groups for i in group],
|
||||
["ggml-tiny.bin", "ggml-small-q5_1.bin",
|
||||
"ggml-small.en-q5_1.bin"])
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""The release build that makes Linux Vulkan a one-click install."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from dikte import ggml
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).parents[1]
|
||||
PACKAGING = ROOT / "packaging" / "whisper-vulkan"
|
||||
WORKFLOW = ROOT / ".github" / "workflows" / "whisper-vulkan.yml"
|
||||
|
||||
|
||||
class WhisperVulkanPackaging(unittest.TestCase):
|
||||
@unittest.skipUnless(sys.platform != "win32" and shutil.which("bash"),
|
||||
"bash syntax check is unavailable")
|
||||
def test_the_release_scripts_parse_as_shell(self):
|
||||
for name in ("build-package.sh", "validate-package.sh",
|
||||
"smoke-runtime.sh"):
|
||||
script = PACKAGING / name
|
||||
checked = subprocess.run(
|
||||
["bash", "-n", script], capture_output=True, text=True,
|
||||
)
|
||||
self.assertEqual("", checked.stderr)
|
||||
self.assertEqual(0, checked.returncode)
|
||||
|
||||
def test_the_workflow_builds_validates_smokes_and_publishes(self):
|
||||
workflow = WORKFLOW.read_text(encoding="utf-8")
|
||||
for step in ("Build deterministic archive",
|
||||
"Verify reviewed archive digest",
|
||||
"Validate archive and ELF contract",
|
||||
"CPU fallback smoke test (no Vulkan loader)",
|
||||
"Vulkan loader present, no device smoke test",
|
||||
"Vulkan plugin-load smoke test (Mesa llvmpipe)",
|
||||
"Publish dependency release"):
|
||||
self.assertIn(step, workflow)
|
||||
self.assertNotRegex(workflow, r"uses: [^\n]+@v\d+(?:\s|$)")
|
||||
|
||||
def test_publish_is_safe_for_dikte_and_limited_to_reviewed_master(self):
|
||||
workflow = WORKFLOW.read_text(encoding="utf-8")
|
||||
self.assertGreaterEqual(workflow.count("persist-credentials: false"), 2)
|
||||
self.assertIn("github.ref == 'refs/heads/master'", workflow)
|
||||
self.assertIn("--prerelease", workflow)
|
||||
self.assertIn("--latest=false", workflow)
|
||||
self.assertIn("--verify-tag", workflow)
|
||||
self.assertIn("refusing to replace existing tag", workflow)
|
||||
self.assertIn("^[0-9]+\\.[0-9]+\\.[0-9]+$", workflow)
|
||||
self.assertIn("^[0-9a-f]{40}$", workflow)
|
||||
publish_script = workflow.split(" - name: Publish dependency release", 1)[1]
|
||||
publish_script = publish_script.split(" run: |", 1)[1]
|
||||
self.assertNotIn("${{ inputs.", publish_script)
|
||||
|
||||
def test_bundle_ci_runs_only_for_what_the_bundle_is_built_from(self):
|
||||
"""A 45 minute build on a README typo is a tax on every other change.
|
||||
|
||||
What ties ggml.py to the release is checked in this file instead, and
|
||||
this file runs on every pull request in milliseconds."""
|
||||
workflow = WORKFLOW.read_text(encoding="utf-8")
|
||||
trigger = workflow.split("workflow_dispatch:", 1)[0]
|
||||
self.assertIn("- packaging/whisper-vulkan/**", trigger)
|
||||
self.assertIn("- .github/workflows/whisper-vulkan.yml", trigger)
|
||||
for path in ("dikte/ggml.py", "tests/test_ggml.py",
|
||||
"tests/test_packaging.py", "README.md", "README.tr.md"):
|
||||
self.assertNotIn(f"- {path}", trigger)
|
||||
|
||||
def test_the_smoke_tests_run_what_dikte_runs(self):
|
||||
"""-ng is what Dikte passes when its GPU setting is off, and a run
|
||||
with it never asks for a backend at all. The three runs that have to
|
||||
hold are the ones without it: no loader, a loader with nothing behind
|
||||
it, and a working device."""
|
||||
script = (PACKAGING / "smoke-runtime.sh").read_text(encoding="utf-8")
|
||||
code = "\n".join(line for line in script.splitlines()
|
||||
if not line.lstrip().startswith("#"))
|
||||
self.assertNotIn("-ng", code)
|
||||
for mode in ("cpu)", "noicd)", "vulkan)"):
|
||||
self.assertIn(mode, script)
|
||||
self.assertTrue((PACKAGING / "Dockerfile.runtime-noicd").is_file())
|
||||
|
||||
def test_an_unreviewed_version_is_reported_and_never_published(self):
|
||||
"""The digest of a version nobody has reviewed cannot be known before
|
||||
it is built, so the gate cannot be the only way through."""
|
||||
workflow = WORKFLOW.read_text(encoding="utf-8")
|
||||
self.assertIn("expected_sha256", workflow)
|
||||
self.assertIn(
|
||||
"refusing to publish an archive whose digest has not been reviewed",
|
||||
workflow)
|
||||
|
||||
def test_the_shape_of_the_inputs_is_checked_before_they_are_used(self):
|
||||
workflow = WORKFLOW.read_text(encoding="utf-8")
|
||||
self.assertLess(workflow.index("- name: Validate source coordinates"),
|
||||
workflow.index("- name: Check out pinned whisper.cpp"))
|
||||
|
||||
def test_the_validator_checks_tar_links_before_extraction(self):
|
||||
validator = (PACKAGING / "validate-package.sh").read_text(
|
||||
encoding="utf-8")
|
||||
for check in ("member.issym()", "member.islnk()", "member.isdev()"):
|
||||
self.assertIn(check, validator)
|
||||
|
||||
@unittest.skipUnless(sys.platform == "linux" and shutil.which("bash"),
|
||||
"Linux packaging test is unavailable")
|
||||
def test_the_validator_rejects_an_escaping_symlink(self):
|
||||
asset = "whisper-bin-ubuntu-vulkan-x64"
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = pathlib.Path(temporary)
|
||||
archive = output / f"{asset}.tar.gz"
|
||||
with tarfile.open(archive, "w:gz") as bundle:
|
||||
link = tarfile.TarInfo(f"{asset}/whisper-server")
|
||||
link.type = tarfile.SYMTYPE
|
||||
link.linkname = "/etc/passwd"
|
||||
bundle.addfile(link, io.BytesIO())
|
||||
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
|
||||
(output / f"{asset}.tar.gz.sha256").write_text(
|
||||
f"{digest} {asset}.tar.gz\n", encoding="utf-8",
|
||||
)
|
||||
checked = subprocess.run(
|
||||
["bash", PACKAGING / "validate-package.sh"],
|
||||
env=os.environ | {"OUT_DIR": str(output)},
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
self.assertNotEqual(0, checked.returncode)
|
||||
self.assertIn("unsafe symlink", checked.stderr)
|
||||
|
||||
def test_the_validator_checks_elf_architecture_dependencies_and_paths(self):
|
||||
validator = (PACKAGING / "validate-package.sh").read_text(
|
||||
encoding="utf-8")
|
||||
for check in ("Advanced Micro Devices X86-64", "unexpected DT_NEEDED",
|
||||
"path.read_bytes()"):
|
||||
self.assertIn(check, validator)
|
||||
|
||||
def test_the_builder_and_its_downloads_are_pinned(self):
|
||||
dockerfile = (PACKAGING / "Dockerfile.build").read_text(
|
||||
encoding="utf-8")
|
||||
self.assertRegex(dockerfile, r"FROM ubuntu@sha256:[0-9a-f]{64}")
|
||||
self.assertIn("CMAKE_SHA256=", dockerfile)
|
||||
self.assertIn("libvulkan-dev=", dockerfile)
|
||||
self.assertIn("shaderc=", dockerfile)
|
||||
key = (PACKAGING / "lunarg-signing-key-pub.asc").read_bytes()
|
||||
key = key.replace(b"\r\n", b"\n")
|
||||
self.assertEqual(
|
||||
"aa1c3c29673140e77f0d6a9aaeed5d9b5621e305ead51c59fae4458bbb4df92b",
|
||||
hashlib.sha256(key).hexdigest(),
|
||||
)
|
||||
|
||||
def test_the_bundle_has_portable_dynamic_backends(self):
|
||||
script = (PACKAGING / "build-package.sh").read_text(
|
||||
encoding="utf-8")
|
||||
for flag in ("GGML_BACKEND_DL=ON", "GGML_CPU_ALL_VARIANTS=ON",
|
||||
"GGML_NATIVE=OFF", "GGML_OPENMP=OFF",
|
||||
"GGML_VULKAN=ON"):
|
||||
self.assertIn(flag, script)
|
||||
self.assertIn("libggml-cpu*.so", script)
|
||||
self.assertIn("libggml-vulkan.so", script)
|
||||
|
||||
def test_the_dependency_release_matches_the_installer(self):
|
||||
workflow = WORKFLOW.read_text(encoding="utf-8")
|
||||
script = (PACKAGING / "build-package.sh").read_text(
|
||||
encoding="utf-8")
|
||||
self.assertEqual("whisper.cpp-v1.9.3",
|
||||
ggml.MANAGED_WHISPER_RELEASE)
|
||||
self.assertEqual("v1.9.3", ggml.MANAGED_WHISPER_VERSION)
|
||||
self.assertIn("RELEASE_TAG: whisper.cpp-v${{ inputs.whisper_version }}",
|
||||
workflow)
|
||||
self.assertIn("WHISPER_VERSION:=1.9.3", script)
|
||||
commit = "371b5a7561823ab2bb32142d2751e35e7534727b"
|
||||
self.assertIn(f"WHISPER_COMMIT:={commit}", script)
|
||||
self.assertIn(commit, workflow)
|
||||
self.assertIn(ggml.MANAGED_WHISPER_VULKAN, workflow)
|
||||
self.assertIn(ggml.MANAGED_WHISPER_SHA256, workflow)
|
||||
|
||||
def test_the_bundle_carries_metadata_and_all_required_licenses(self):
|
||||
script = (PACKAGING / "build-package.sh").read_text(
|
||||
encoding="utf-8")
|
||||
for name in ("BUILD-INFO.json", "SHA256SUMS", ".cdx.json"):
|
||||
self.assertIn(name, script)
|
||||
for name in ("cpp-httplib-MIT.txt", "nlohmann-json-MIT.txt"):
|
||||
self.assertTrue((PACKAGING / "licenses" / name).is_file())
|
||||
|
||||
def _make_test_sbom(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = pathlib.Path(temporary)
|
||||
(root / "whisper-server").write_bytes(b"elf")
|
||||
sbom = root / "whisper-bin-ubuntu-vulkan-x64.cdx.json"
|
||||
environment = os.environ | {
|
||||
"ROOT": str(root),
|
||||
"VERSION": "1.9.3",
|
||||
"COMMIT": "371b5a7561823ab2bb32142d2751e35e7534727b",
|
||||
"EPOCH": "1787219223",
|
||||
}
|
||||
with sbom.open("w", encoding="utf-8") as output:
|
||||
subprocess.run(
|
||||
[sys.executable, PACKAGING / "make-sbom.py"],
|
||||
env=environment, stdout=output, check=True,
|
||||
)
|
||||
return json.loads(sbom.read_text(encoding="utf-8")), sbom.name
|
||||
|
||||
def test_the_sbom_does_not_record_the_file_being_written(self):
|
||||
document, sbom_name = self._make_test_sbom()
|
||||
names = {component["name"] for component in document["components"]}
|
||||
self.assertNotIn(sbom_name, names)
|
||||
|
||||
def test_the_sbom_lists_ggml(self):
|
||||
document, _ = self._make_test_sbom()
|
||||
names = {component["name"] for component in document["components"]}
|
||||
self.assertIn("ggml", names)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+5
-2
@@ -42,9 +42,12 @@ class Directories(unittest.TestCase):
|
||||
|
||||
def test_a_mac_does_not_read_the_xdg_variables(self):
|
||||
"""A Mac with them set from some other tool still stores in one place."""
|
||||
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}):
|
||||
# Something no temporary directory can be called: the home this runs
|
||||
# under is a mkdtemp path, and a two-letter needle matched the "/c" in
|
||||
# somebody's TMPDIR rather than the variable being read.
|
||||
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/xdg-elsewhere"}):
|
||||
config_dir, _ = paths.directories("darwin")
|
||||
self.assertNotIn("/c", config_dir.as_posix())
|
||||
self.assertNotIn("xdg-elsewhere", config_dir.as_posix())
|
||||
|
||||
def test_windows_keeps_the_models_out_of_the_roaming_profile(self):
|
||||
"""Settings roam with the account; several gigabytes must not."""
|
||||
|
||||
+565
-3
@@ -6,13 +6,15 @@ save, so a setting added to one half and not the other is silently reset the
|
||||
next time anybody presses Save. That is the failure this catches.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from typing import ClassVar
|
||||
from unittest import mock
|
||||
|
||||
from PyQt6.QtCore import QPoint, QPointF, Qt
|
||||
from PyQt6.QtCore import QPoint, QPointF, QRect, Qt
|
||||
from PyQt6.QtGui import QWheelEvent
|
||||
from PyQt6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
@@ -21,11 +23,13 @@ from dikte import cleanup
|
||||
from dikte import config as cfg
|
||||
from dikte import ggml
|
||||
from dikte import hotkey
|
||||
from dikte import hub
|
||||
from dikte import ipc
|
||||
from dikte import overlay as overlay_module
|
||||
from dikte import paste
|
||||
from dikte import settings_ui
|
||||
from dikte import update
|
||||
from dikte.i18n import t
|
||||
from tests.support import DikteTest, only_these_tools
|
||||
|
||||
# The harness below replaces this method on the class so that opening a window
|
||||
@@ -48,6 +52,7 @@ CHANGED = {
|
||||
"restore_clipboard": True,
|
||||
"overlay_corner": "top-right",
|
||||
"overlay_screen": "DP-1",
|
||||
"overlay_follows_pointer": True,
|
||||
"max_seconds": 120,
|
||||
"skip_silent": False,
|
||||
"silence_db": -42.0,
|
||||
@@ -78,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",
|
||||
@@ -239,6 +246,39 @@ class Settings(DikteTest):
|
||||
label.resize(2000, line)
|
||||
self.assertLessEqual(label.minimumHeight(), line)
|
||||
|
||||
def test_a_label_written_before_the_layout_places_it_claims_nothing(self):
|
||||
# The publisher note is written while the settings window is still
|
||||
# being built, when the label is a handful of pixels wide. Wrapped
|
||||
# against that width the sentence became a hundred lines, and the
|
||||
# minimum taken from it did not stay a minimum: QLabel folds it into
|
||||
# its own cached size hints and clears that cache only when the text
|
||||
# changes. The group box stood thousands of pixels tall, with the
|
||||
# model box and everything under it off the bottom of the window,
|
||||
# until another publisher was picked.
|
||||
label = settings_ui.WrappedLabel()
|
||||
self.addCleanup(label.deleteLater)
|
||||
line = label.fontMetrics().height()
|
||||
label.resize(8, line)
|
||||
label.setText("Google Gemma 4, the small one. The default: nothing "
|
||||
"else this size follows an instruction as closely, and "
|
||||
"cleanup is all instruction.")
|
||||
self.assertEqual(label.minimumHeight(), 0)
|
||||
# Placed and shown, which is the first width worth measuring against.
|
||||
# The room the wrapping needs is claimed then, and it is the lines the
|
||||
# sentence takes at this width rather than at the last one. Counted
|
||||
# off the font rather than written down here, because how many lines
|
||||
# 400 pixels hold is a different answer on every machine.
|
||||
label.resize(400, line)
|
||||
label.show()
|
||||
wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere
|
||||
needed = label.fontMetrics().boundingRect(
|
||||
QRect(0, 0, 400, 0), wrap, label.text()).height()
|
||||
self.assertGreater(needed, line) # or the sentence never wrapped
|
||||
self.assertEqual(label.minimumHeight(), needed)
|
||||
# And the label's own hints are the wrapping at this width too, not
|
||||
# the hundred lines the eight pixel one asked for.
|
||||
self.assertLessEqual(label.sizeHint().height(), 3 * needed)
|
||||
|
||||
def test_saving_without_touching_anything_changes_nothing(self):
|
||||
"""Every widget has to load what is stored, or Save writes its default
|
||||
over it. This says so for the whole table at once."""
|
||||
@@ -275,6 +315,7 @@ class Settings(DikteTest):
|
||||
"""An OpenRouter id and a Claude alias are not the same field."""
|
||||
window = self.window(cfg.Config())
|
||||
boxes = {"openrouter": window.cleanup_model_row,
|
||||
"opencode": window.cleanup_opencode_model_row,
|
||||
"claude": window.cleanup_claude_model,
|
||||
"codex": window.cleanup_codex_model}
|
||||
for provider, box in boxes.items():
|
||||
@@ -298,6 +339,42 @@ class Settings(DikteTest):
|
||||
self.assertEqual(window.cleanup_codex_model.currentText(),
|
||||
"my-own-model")
|
||||
|
||||
def test_opencode_answering_refills_both_of_its_boxes(self):
|
||||
"""The fetched catalog replaces the built-in list in the cleanup and
|
||||
agent boxes alike, and neither loses what was picked."""
|
||||
conf = self.config(cleanup_opencode_model="my-own-model")
|
||||
window = self.window(conf)
|
||||
window._on_opencode_models_loaded(["glm-9", "kimi-k9"], "")
|
||||
for combo in (window.cleanup_opencode_model,
|
||||
window.assistant_opencode_model):
|
||||
with self.subTest(combo=combo.objectName() or "combo"):
|
||||
offered = [combo.itemText(i) for i in range(combo.count())]
|
||||
self.assertEqual(offered, ["glm-9", "kimi-k9"])
|
||||
self.assertEqual(window.cleanup_opencode_model.currentText(),
|
||||
"my-own-model")
|
||||
|
||||
def test_opencode_s_list_arriving_at_open_leaves_the_other_boxes_alone(self):
|
||||
conf = self.config(cleanup_opencode_model="my-own-model",
|
||||
meeting_model="some/meeting-model")
|
||||
window = self.window(conf)
|
||||
before = [window.meeting_model.itemText(i)
|
||||
for i in range(window.meeting_model.count())]
|
||||
window._on_hosted_models_loaded("opencode", ["glm-5.3", "kimi-k3"])
|
||||
combo = window.cleanup_opencode_model
|
||||
offered = [combo.itemText(i) for i in range(combo.count())]
|
||||
self.assertEqual(offered, ["glm-5.3", "kimi-k3"])
|
||||
self.assertEqual(combo.currentText(), "my-own-model")
|
||||
self.assertEqual([window.meeting_model.itemText(i)
|
||||
for i in range(window.meeting_model.count())], before)
|
||||
|
||||
def test_opencode_cleanup_offers_a_fetch_button_of_its_own(self):
|
||||
"""The OpenRouter button leaves the screen with its box, so OpenCode Go
|
||||
carries its own."""
|
||||
window = self.window(cfg.Config())
|
||||
window._select_data(window.cleanup_provider, "opencode")
|
||||
self.assertFalse(window.cleanup_opencode_model_row.isHidden())
|
||||
self.assertTrue(window.cleanup_model_row.isHidden())
|
||||
|
||||
def test_agy_answering_refills_both_of_its_boxes(self):
|
||||
"""The same arrangement as Codex: both boxes, nothing chosen is lost."""
|
||||
conf = self.config(cleanup_agy_model="my-own-model")
|
||||
@@ -340,10 +417,11 @@ class Settings(DikteTest):
|
||||
|
||||
def test_a_key_on_file_is_fetched_with_at_open(self):
|
||||
window = self.window(self.config(openrouter_api_key="sk-or-x",
|
||||
gemini_api_key="AIza-x"))
|
||||
gemini_api_key="AIza-x",
|
||||
opencode_api_key="opencode-x"))
|
||||
with mock.patch.object(settings_ui.threading, "Thread") as thread:
|
||||
REAL_LOAD_HOSTED_MODELS(window)
|
||||
self.assertEqual(thread.call_count, 2)
|
||||
self.assertEqual(thread.call_count, 3)
|
||||
|
||||
def test_the_update_line_names_the_version_that_is_running(self):
|
||||
window = self.window(cfg.Config())
|
||||
@@ -466,6 +544,20 @@ class Settings(DikteTest):
|
||||
self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe")
|
||||
self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3")
|
||||
|
||||
def test_the_file_model_is_saved_and_only_shown_for_openrouter(self):
|
||||
self.write_config({"transcribe_provider": "openrouter",
|
||||
"openrouter_file_model": "openai/whisper-large-v3"})
|
||||
conf = cfg.Config()
|
||||
window = self.window(conf)
|
||||
self.assertEqual(window.file_model.currentText(), "openai/whisper-large-v3")
|
||||
self.assertTrue(window.stt_form.isRowVisible(window.file_model_row))
|
||||
window.file_model.setCurrentText(" deepgram/nova-3 ")
|
||||
window._save()
|
||||
self.assertEqual(conf["openrouter_file_model"], "deepgram/nova-3")
|
||||
window.transcribe_provider.setCurrentIndex(
|
||||
window.transcribe_provider.findData("openai"))
|
||||
self.assertFalse(window.stt_form.isRowVisible(window.file_model_row))
|
||||
|
||||
def test_the_provider_box_offers_every_provider_config_knows(self):
|
||||
window = self.window(cfg.Config())
|
||||
offered = [window.transcribe_provider.itemData(i)
|
||||
@@ -1013,6 +1105,121 @@ class Overlay(DikteTest):
|
||||
screen_at.assert_not_called()
|
||||
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
||||
|
||||
def _screen(self, name, area):
|
||||
screen = mock.Mock()
|
||||
screen.name.return_value = name
|
||||
screen.availableGeometry.return_value = area
|
||||
return screen
|
||||
|
||||
def _kwin(self, *answer):
|
||||
kwin = mock.Mock()
|
||||
kwin.isValid.return_value = True
|
||||
kwin.call.return_value.arguments.return_value = list(answer)
|
||||
return kwin
|
||||
|
||||
def test_the_compositor_says_which_screen_the_pointer_is_on(self):
|
||||
"""Wayland tells a client where the pointer is only while it is over one
|
||||
of that client's own windows, so QCursor.pos() comes back at the origin
|
||||
and every indicator lands on whichever screen holds it. KWin knows."""
|
||||
screens = [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080)),
|
||||
self._screen("DP-2", settings_ui.QRect(1920, 0, 1920, 1080))]
|
||||
widget = self.overlay()
|
||||
with mock.patch.object(overlay_module, "_kwin", self._kwin("DP-2")), \
|
||||
mock.patch.object(QApplication, "screens", return_value=screens), \
|
||||
mock.patch.object(QApplication, "screenAt") as screen_at:
|
||||
widget._reposition()
|
||||
screen_at.assert_not_called()
|
||||
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
||||
|
||||
def test_the_pointer_decides_when_the_compositor_will_not_say(self):
|
||||
"""Every desktop but Plasma, and Plasma while KWin is being replaced."""
|
||||
screens = [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080))]
|
||||
widget = self.overlay()
|
||||
with mock.patch.object(overlay_module, "_kwin", self._kwin()), \
|
||||
mock.patch.object(QApplication, "screens", return_value=screens), \
|
||||
mock.patch.object(QApplication, "screenAt",
|
||||
return_value=screens[0]) as screen_at:
|
||||
widget._reposition()
|
||||
screen_at.assert_called()
|
||||
self.assertEqual(widget.pos(), QPoint(28, 995))
|
||||
|
||||
def _two_screens(self):
|
||||
return [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080)),
|
||||
self._screen("DP-2", settings_ui.QRect(1920, 0, 1920, 1080))]
|
||||
|
||||
def _ticks_on(self, widget, screens, kwin):
|
||||
"""Run the ribbon long enough for one look at where the pointer is."""
|
||||
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||||
mock.patch.object(QApplication, "screens", return_value=screens), \
|
||||
mock.patch.object(QApplication, "screenAt", return_value=screens[0]):
|
||||
for _ in range(overlay_module.FOLLOW_EVERY):
|
||||
widget._tick()
|
||||
|
||||
def test_it_can_be_told_to_keep_up_with_the_pointer(self):
|
||||
"""The screen it started on is not always the screen you end up on."""
|
||||
screens = self._two_screens()
|
||||
kwin = self._kwin("DP-2")
|
||||
widget = self.overlay(follow_pointer=True)
|
||||
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||||
mock.patch.object(QApplication, "screens", return_value=screens):
|
||||
widget.show_recording()
|
||||
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
||||
kwin.call.return_value.arguments.return_value = ["DP-1"]
|
||||
self._ticks_on(widget, screens, kwin)
|
||||
self.assertEqual(widget.pos(), QPoint(28, 995))
|
||||
|
||||
def test_it_stays_where_it_appeared_unless_it_was_told_otherwise(self):
|
||||
"""Left off, because an indicator that jumps desks mid-sentence is one
|
||||
more thing moving while you are trying to talk."""
|
||||
screens = self._two_screens()
|
||||
kwin = self._kwin("DP-2")
|
||||
widget = self.overlay()
|
||||
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||||
mock.patch.object(QApplication, "screens", return_value=screens):
|
||||
widget.show_recording()
|
||||
kwin.call.return_value.arguments.return_value = ["DP-1"]
|
||||
self._ticks_on(widget, screens, kwin)
|
||||
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
||||
|
||||
def test_a_named_screen_is_never_left_for_the_pointer(self):
|
||||
"""Naming one is the whole answer; following it would undo the naming."""
|
||||
screens = self._two_screens()
|
||||
kwin = self._kwin("DP-2")
|
||||
widget = self.overlay(screen_name="DP-1", follow_pointer=True)
|
||||
with mock.patch.object(QApplication, "screens", return_value=screens):
|
||||
widget.show_recording()
|
||||
self._ticks_on(widget, screens, kwin)
|
||||
kwin.call.assert_not_called()
|
||||
self.assertEqual(widget.pos(), QPoint(28, 995))
|
||||
|
||||
def test_the_one_on_top_goes_where_the_one_underneath_is(self):
|
||||
"""Asking for itself would put the pair on two monitors, with this one
|
||||
raised over a ribbon that is not underneath it."""
|
||||
screens = self._two_screens()
|
||||
kwin = self._kwin("DP-2")
|
||||
first = self.overlay()
|
||||
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||||
mock.patch.object(QApplication, "screens", return_value=screens):
|
||||
first.show_recording()
|
||||
kwin.call.return_value.arguments.return_value = ["DP-1"]
|
||||
second = self.overlay(below=first)
|
||||
second.show_busy("Asking Claude…")
|
||||
self.assertEqual(first.pos(), QPoint(1948, 995))
|
||||
self.assertEqual(second.pos(), QPoint(1948, 929))
|
||||
|
||||
def test_the_compositor_is_asked_only_now_and_then(self):
|
||||
"""Every tick would be thirty conversations a second about a hand
|
||||
moving a mouse."""
|
||||
screens = self._two_screens()
|
||||
kwin = self._kwin("DP-2")
|
||||
widget = self.overlay(follow_pointer=True)
|
||||
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||||
mock.patch.object(QApplication, "screens", return_value=screens):
|
||||
widget.show_recording()
|
||||
kwin.call.reset_mock()
|
||||
self._ticks_on(widget, screens, kwin)
|
||||
self.assertEqual(kwin.call.call_count, 1)
|
||||
|
||||
def test_a_warning_and_an_error_both_show(self):
|
||||
widget = self.overlay()
|
||||
widget.show_warning("cleanup failed")
|
||||
@@ -1164,6 +1371,28 @@ class LocalModels(DikteTest):
|
||||
self.assertIn("10", box.program_label.text())
|
||||
self.assertIn("20", box.status.text())
|
||||
|
||||
def test_a_download_says_something_before_the_first_byte(self):
|
||||
# Opening the connection takes ten or twenty seconds, and the byte
|
||||
# counts only start after it. The line underneath still read "has not
|
||||
# been downloaded yet" beside a button that now said Stop, so a
|
||||
# download that had started looked like a click that had not landed.
|
||||
box = self.window(cfg.Config()).local_llm
|
||||
box.load("", "ggml-org/SmolLM3-3B-GGUF")
|
||||
box.repo.blockSignals(True)
|
||||
box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF")
|
||||
box.repo.blockSignals(False)
|
||||
box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")],
|
||||
"ggml-org/SmolLM3-3B-GGUF")], "")
|
||||
with mock.patch.object(settings_ui.threading, "Thread"):
|
||||
box._download()
|
||||
self.assertIn("Starting", box.status.text())
|
||||
# And the same again for the stop, which is read between blocks and so
|
||||
# not read at all while the connection is still being opened.
|
||||
with mock.patch.object(settings_ui.threading, "Thread"):
|
||||
box._download()
|
||||
self.assertTrue(box._stop)
|
||||
self.assertIn("Stopping", box.status.text())
|
||||
|
||||
def test_a_long_model_name_is_not_cut_in_half(self):
|
||||
# The list under a combo box takes the box's width and elides what does
|
||||
# not fit, in the middle: "ggml-org/Qwen....7B-Base-GGUF".
|
||||
@@ -1176,6 +1405,319 @@ class LocalModels(DikteTest):
|
||||
for row in range(box.repo.count()))
|
||||
self.assertGreaterEqual(view.minimumWidth(), widest)
|
||||
|
||||
@staticmethod
|
||||
def _item(name, size=1 << 20):
|
||||
return hub.Item(name, f"https://example.invalid/{name}", size, "")
|
||||
|
||||
@staticmethod
|
||||
def _rows(box):
|
||||
"""Every row's text, headings included."""
|
||||
return [box.model.itemText(row) for row in range(box.model.count())]
|
||||
|
||||
@staticmethod
|
||||
def _repos(box):
|
||||
return [box.repo.itemText(row) for row in range(box.repo.count())]
|
||||
|
||||
@staticmethod
|
||||
def _roomy():
|
||||
"""Stand on a machine with room for every suggestion.
|
||||
|
||||
The order the publishers come in follows the memory, so a test that
|
||||
reads it has to say which machine it is standing on. A build runner
|
||||
with 7 GB in it puts the two Gemma 4 rows last and is right to.
|
||||
"""
|
||||
return mock.patch.object(ggml, "total_memory", return_value=64 << 30)
|
||||
|
||||
@staticmethod
|
||||
def _offered(box):
|
||||
"""The model names in the box, headings and duplicates left out."""
|
||||
names = []
|
||||
for row in range(box.model.count()):
|
||||
name = box.model.itemData(row)
|
||||
if name and name not in names:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
def test_a_row_with_nothing_to_fetch_does_not_offer_a_download(self):
|
||||
# The model the settings name is not in the list any more, so its row
|
||||
# was rebuilt from the name alone and carries no file to fetch. The
|
||||
# button stayed lit and the press did nothing at all.
|
||||
box = self.window(self.config(local_llm_model="gone.gguf")).local_llm
|
||||
box.load("gone.gguf", "ggml-org/SmolLM3-3B-GGUF")
|
||||
self.assertEqual(box.selected(), "gone.gguf")
|
||||
self.assertFalse(box.download_button.isEnabled())
|
||||
self.assertIn("gone.gguf", box.status.text())
|
||||
self.assertIn("publisher", box.status.text())
|
||||
|
||||
def test_a_model_without_its_program_does_not_say_it_is_ready(self):
|
||||
# The model runs on the program above it, and "Ready" over a missing
|
||||
# one is what had people asking why nothing transcribed.
|
||||
box = self.window(cfg.Config()).local_whisper
|
||||
path = ggml.whisper_model_path("ggml-small.bin")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"not really a model")
|
||||
box.load("ggml-small.bin")
|
||||
self.assertFalse(ggml.program_path(ggml.WHISPER))
|
||||
self.assertNotIn("Ready", box.status.text())
|
||||
self.assertIn("program", box.status.text())
|
||||
|
||||
def test_changing_the_publisher_changes_the_model(self):
|
||||
# The model chosen under the old publisher is not published by the new
|
||||
# one. Carried over, it was added back as "not downloaded" and selected
|
||||
# again, and the box looked as though the change had not taken.
|
||||
box = self.window(self.config(local_llm_model="gemma-3-4b-it-Q4_K_M.gguf",
|
||||
local_llm_repo="ggml-org/gemma-3-4b-it-GGUF")).local_llm
|
||||
box.load("gemma-3-4b-it-Q4_K_M.gguf", "ggml-org/gemma-3-4b-it-GGUF")
|
||||
box.repo.blockSignals(True)
|
||||
box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF")
|
||||
box.repo.blockSignals(False)
|
||||
box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")],
|
||||
"ggml-org/SmolLM3-3B-GGUF")], "")
|
||||
self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf")
|
||||
self.assertEqual(self._offered(box), ["SmolLM3-Q4_K_M.gguf"])
|
||||
|
||||
def test_a_list_for_a_publisher_that_is_no_longer_chosen_is_dropped(self):
|
||||
# Every change starts its own request, and they do not come back in the
|
||||
# order they went out.
|
||||
box = self.window(cfg.Config()).local_llm
|
||||
box.load("", "ggml-org/SmolLM3-3B-GGUF")
|
||||
box.repo.blockSignals(True)
|
||||
box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF")
|
||||
box.repo.blockSignals(False)
|
||||
box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")],
|
||||
"ggml-org/SmolLM3-3B-GGUF")], "")
|
||||
box._on_listed([("models", [self._item("gemma-3-4b-it-Q4_K_M.gguf")],
|
||||
"ggml-org/gemma-3-4b-it-GGUF")], "")
|
||||
self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf")
|
||||
|
||||
def test_the_publisher_box_is_not_asked_on_every_keystroke(self):
|
||||
box = self.window(cfg.Config()).local_llm
|
||||
with mock.patch.object(box, "_fetch_models") as fetch:
|
||||
for text in ("g", "gg", "ggm", "ggml-org/SmolLM3-3B-GGUF"):
|
||||
box.repo.setCurrentText(text)
|
||||
fetch.assert_not_called()
|
||||
box._later.setInterval(0)
|
||||
box._later.start()
|
||||
_app.processEvents()
|
||||
time.sleep(0.05)
|
||||
_app.processEvents()
|
||||
self.assertEqual(fetch.call_count, 1)
|
||||
def test_the_models_are_grouped_by_the_model_rather_than_by_size(self):
|
||||
# Sorted by size alone, the turbo files land between the two medium
|
||||
# ones, half a screen from the model they are a copy of.
|
||||
box = self.window(cfg.Config()).local_whisper
|
||||
with mock.patch.object(ggml, "total_memory", return_value=8 << 30), \
|
||||
mock.patch.object(ggml, "accelerator", return_value=""):
|
||||
box._on_listed([("models", [
|
||||
self._item("ggml-medium-q5_0.bin", 539 << 20),
|
||||
self._item("ggml-large-v3-turbo-q5_0.bin", 574 << 20),
|
||||
self._item("ggml-medium-q8_0.bin", 823 << 20),
|
||||
self._item("ggml-large-v3-turbo.bin", 1624 << 20),
|
||||
], "")], "")
|
||||
rows = self._rows(box)
|
||||
# The two medium files under one heading, the two turbo ones under
|
||||
# theirs, and the model rather than the file deciding the order.
|
||||
self.assertEqual(rows[rows.index("medium"):],
|
||||
["medium",
|
||||
"ggml-medium-q5_0.bin (539.0 MB, 5-bit)",
|
||||
"ggml-medium-q8_0.bin (823.0 MB, 8-bit)",
|
||||
"large-v3-turbo",
|
||||
"ggml-large-v3-turbo-q5_0.bin "
|
||||
"(574.0 MB, 5-bit, recommended)",
|
||||
"ggml-large-v3-turbo.bin (1.6 GB, 16-bit)"])
|
||||
# A heading is not a model, and nothing can be saved from one.
|
||||
self.assertIsNone(box.model.itemData(rows.index("medium")))
|
||||
|
||||
def test_the_row_for_this_machine_is_on_top_and_says_so(self):
|
||||
box = self.window(cfg.Config()).local_whisper
|
||||
with mock.patch.object(ggml, "total_memory", return_value=8 << 30), \
|
||||
mock.patch.object(ggml, "accelerator", return_value=""):
|
||||
box._on_listed([("models", [
|
||||
self._item("ggml-tiny.bin", 77 << 20),
|
||||
self._item("ggml-large-v3-turbo-q5_0.bin", 574 << 20),
|
||||
], "")], "")
|
||||
self.assertEqual(box.selected(), "ggml-large-v3-turbo-q5_0.bin")
|
||||
self.assertEqual(box.model.itemData(1), "ggml-large-v3-turbo-q5_0.bin")
|
||||
self.assertIn(t("recommended"), box.model.itemText(1))
|
||||
|
||||
def test_a_model_the_memory_cannot_hold_says_so_on_its_row(self):
|
||||
box = self.window(cfg.Config()).local_llm
|
||||
box.repo.blockSignals(True)
|
||||
box.repo.setCurrentText("ggml-org/x-GGUF")
|
||||
box.repo.blockSignals(False)
|
||||
with mock.patch.object(ggml, "total_memory", return_value=8 << 30):
|
||||
box._on_listed([("models", [
|
||||
self._item("small-Q4_0.gguf", 1 << 30),
|
||||
self._item("huge-Q8_0.gguf", 12 << 30),
|
||||
], "ggml-org/x-GGUF")], "")
|
||||
rows = {box.model.itemData(row): box.model.itemText(row)
|
||||
for row in range(box.model.count())}
|
||||
self.assertNotIn(t("too big for this machine"), rows["small-Q4_0.gguf"])
|
||||
self.assertIn(t("too big for this machine"), rows["huge-Q8_0.gguf"])
|
||||
|
||||
def test_a_recommended_row_is_not_listed_twice_after_a_download(self):
|
||||
# It has a row of its own on top as well as one in its group, and
|
||||
# reading the rows back the way a finished download does was doubling
|
||||
# it in the list every time.
|
||||
box = self.window(cfg.Config()).local_whisper
|
||||
with self._roomy():
|
||||
box._on_listed([("models", [
|
||||
self._item("ggml-tiny.bin", 77 << 20),
|
||||
self._item("ggml-large-v3-turbo-q5_0.bin", 574 << 20),
|
||||
], "")], "")
|
||||
before = self._offered(box)
|
||||
box._fill_models_from_current()
|
||||
self.assertEqual(self._offered(box), before)
|
||||
names = [box.model.itemData(row) for row in range(box.model.count())]
|
||||
self.assertEqual(len([n for n in names if n]), len(before) + 1)
|
||||
|
||||
def test_a_processor_build_is_not_recommended_the_accurate_model(self):
|
||||
# The Vulkan loader is on the machine but what was installed is the
|
||||
# processor build, so there is no card in play whatever the loader
|
||||
# says, and a 1 GB model on a processor is a wait somebody is sitting
|
||||
# through with a sentence half typed.
|
||||
binary = self.path("bin/whisper/v1.9.3/whisper-server")
|
||||
binary.parent.mkdir(parents=True)
|
||||
binary.write_text("")
|
||||
binary.chmod(0o755)
|
||||
self.path("bin/whisper/installed.json").write_text(json.dumps(
|
||||
{"tag": "v1.9.3", "binary": str(binary), "backend": "processor"}))
|
||||
self.patch_attr(ggml.shutil, "which", lambda name: None)
|
||||
box = self.window(cfg.Config()).local_whisper
|
||||
with mock.patch.object(ggml, "total_memory", return_value=32 << 30), \
|
||||
mock.patch.object(ggml, "accelerator", return_value="Vulkan"):
|
||||
self.assertEqual(box._suggested(), ggml.SUGGESTED_WHISPER)
|
||||
|
||||
def test_a_publisher_with_nothing_to_offer_says_why(self):
|
||||
# Half of what ggml-org publishes is split across files or past the
|
||||
# size cap, and an empty box read as though the click had not landed.
|
||||
box = self.window(cfg.Config()).local_llm
|
||||
box.repo.blockSignals(True)
|
||||
box.repo.setCurrentText("ggml-org/gpt-oss-120b-GGUF")
|
||||
box.repo.blockSignals(False)
|
||||
box._on_listed([("models", [], "ggml-org/gpt-oss-120b-GGUF")], "")
|
||||
self.assertIn("ggml-org/gpt-oss-120b-GGUF", box.status.text())
|
||||
self.assertIn("publisher", box.status.text())
|
||||
|
||||
def test_an_empty_box_nobody_has_asked_yet_is_not_a_publisher_fault(self):
|
||||
box = self.window(cfg.Config()).local_llm
|
||||
box.load("", "ggml-org/SmolLM3-3B-GGUF")
|
||||
self.assertNotIn("publisher", box.status.text())
|
||||
|
||||
def test_only_the_suggested_publishers_are_offered_to_start_with(self):
|
||||
# Forty repository ids is not a choice anybody can make.
|
||||
box = self.window(cfg.Config()).local_llm
|
||||
with self._roomy():
|
||||
box._on_listed([("repos", [ggml.SUGGESTED_LLM[0],
|
||||
"ggml-org/something-else-GGUF"], "")], "")
|
||||
self.assertEqual(self._repos(box), list(ggml.SUGGESTED_LLM))
|
||||
|
||||
def test_a_suggestion_missing_from_the_listing_is_still_offered(self):
|
||||
# The listing is the forty repositories touched most recently, and a
|
||||
# publisher that has not been updated in a season falls off it while
|
||||
# still being the one to point at.
|
||||
box = self.window(cfg.Config()).local_llm
|
||||
box._on_listed([("repos", ["ggml-org/something-else-GGUF"], "")], "")
|
||||
self.assertIn(ggml.SUGGESTED_LLM[0], self._repos(box))
|
||||
|
||||
def test_the_switch_brings_the_rest_and_keeps_them_apart(self):
|
||||
box = self.window(cfg.Config()).local_llm
|
||||
with self._roomy():
|
||||
box._on_listed([("repos", [ggml.SUGGESTED_LLM[0],
|
||||
"ggml-org/something-else-GGUF"], "")], "")
|
||||
box.every_repo.setChecked(True)
|
||||
rows = self._repos(box)
|
||||
self.assertEqual(rows[:len(ggml.SUGGESTED_LLM)],
|
||||
list(ggml.SUGGESTED_LLM))
|
||||
# A separator rather than a heading: the box is typed into as well as
|
||||
# chosen from, and a heading would land in the field as a repository.
|
||||
self.assertEqual(rows[len(ggml.SUGGESTED_LLM)], "")
|
||||
self.assertEqual(rows[-1], "ggml-org/something-else-GGUF")
|
||||
|
||||
def test_a_publisher_typed_in_is_not_dropped_by_the_next_fetch(self):
|
||||
box = self.window(cfg.Config()).local_llm
|
||||
box.repo.blockSignals(True)
|
||||
box.repo.setCurrentText("ggml-org/something-else-GGUF")
|
||||
box.repo.blockSignals(False)
|
||||
box._on_listed([("repos", [ggml.SUGGESTED_LLM[0],
|
||||
"ggml-org/something-else-GGUF"], "")], "")
|
||||
self.assertFalse(box.every_repo.isChecked())
|
||||
self.assertIn("ggml-org/something-else-GGUF", self._repos(box))
|
||||
self.assertEqual(box.repository(), "ggml-org/something-else-GGUF")
|
||||
|
||||
def test_the_chosen_publisher_is_said_in_words(self):
|
||||
# A repository id names the publisher, the parameter count and the
|
||||
# shape of the weights, and none of that says whether to click it.
|
||||
box = self.window(cfg.Config()).local_llm
|
||||
box.repo.setCurrentText(ggml.SUGGESTED_LLM[0])
|
||||
self.assertTrue(box.repo_note.text())
|
||||
box.repo.setCurrentText("ggml-org/nobody-wrote-a-note-GGUF")
|
||||
self.assertEqual(box.repo_note.text(), "")
|
||||
|
||||
def test_the_box_says_what_this_machine_will_run_on(self):
|
||||
box = self.window(cfg.Config()).local_whisper
|
||||
with mock.patch.object(ggml, "accelerator", return_value="Vulkan"), \
|
||||
mock.patch.object(ggml, "total_memory", return_value=32 << 30):
|
||||
box._show_machine()
|
||||
self.assertIn("Vulkan", box.machine_label.text())
|
||||
self.assertIn("32.0 GB", box.machine_label.text())
|
||||
|
||||
def test_a_machine_with_no_card_is_told_it_is_on_the_processor(self):
|
||||
box = self.window(cfg.Config()).local_whisper
|
||||
with mock.patch.object(ggml, "accelerator", return_value=""), \
|
||||
mock.patch.object(ggml, "total_memory", return_value=8 << 30):
|
||||
box._show_machine()
|
||||
self.assertIn("processor", box.machine_label.text())
|
||||
|
||||
def test_a_processor_build_where_the_vulkan_one_belongs_says_so(self):
|
||||
# The Vulkan whisper-server is published by hand, and until it is
|
||||
# there the download lands upstream's processor build. Said nowhere,
|
||||
# an idle graphics card looks exactly like one that is being used.
|
||||
binary = self.path("bin/whisper/v1.9.3/whisper-server")
|
||||
binary.parent.mkdir(parents=True)
|
||||
binary.write_text("")
|
||||
binary.chmod(0o755)
|
||||
self.path("bin/whisper/installed.json").write_text(json.dumps(
|
||||
{"tag": "v1.9.3", "binary": str(binary), "backend": "processor"}))
|
||||
# A whisper-server on this machine's PATH would win over the download.
|
||||
self.patch_attr(ggml.shutil, "which", lambda name: None)
|
||||
label = self.window(cfg.Config()).local_whisper.program_label.text()
|
||||
self.assertIn("v1.9.3", label)
|
||||
self.assertIn("Vulkan", label)
|
||||
|
||||
def test_an_ordinary_install_is_reported_without_a_word_about_vulkan(self):
|
||||
binary = self.path("bin/whisper/v1.9.3/whisper-server")
|
||||
binary.parent.mkdir(parents=True)
|
||||
binary.write_text("")
|
||||
binary.chmod(0o755)
|
||||
self.path("bin/whisper/installed.json").write_text(json.dumps(
|
||||
{"tag": "v1.9.3", "binary": str(binary)}))
|
||||
self.patch_attr(ggml.shutil, "which", lambda name: None)
|
||||
label = self.window(cfg.Config()).local_whisper.program_label.text()
|
||||
self.assertIn("v1.9.3", label)
|
||||
self.assertNotIn("Vulkan", label)
|
||||
|
||||
def test_a_downloaded_program_can_still_be_asked_for_again(self):
|
||||
# The button used to disappear the moment anything landed, which left
|
||||
# no way to pick up a newer whisper.cpp, or the Vulkan build on a
|
||||
# machine whose driver was installed after Dikte was.
|
||||
binary = self.path("bin/whisper/v1.9.3/whisper-server")
|
||||
binary.parent.mkdir(parents=True)
|
||||
binary.write_text("")
|
||||
binary.chmod(0o755)
|
||||
self.path("bin/whisper/installed.json").write_text(json.dumps(
|
||||
{"tag": "v1.9.3", "binary": str(binary)}))
|
||||
self.patch_attr(ggml.shutil, "which", lambda name: None)
|
||||
box = self.window(cfg.Config()).local_whisper
|
||||
self.assertTrue(box.install_button.isVisibleTo(box))
|
||||
self.assertEqual(box.install_button.text(), t("Download again"))
|
||||
|
||||
def test_a_system_copy_is_not_offered_for_download(self):
|
||||
# Nothing Dikte downloads would be run while one is on the PATH.
|
||||
self.patch_attr(ggml.shutil, "which", lambda name: "/usr/bin/" + name)
|
||||
box = self.window(cfg.Config()).local_whisper
|
||||
self.assertFalse(box.install_button.isVisibleTo(box))
|
||||
|
||||
def test_only_the_chosen_transcriber_is_on_screen(self):
|
||||
window = self.window(self.config(transcribe_provider="openai"))
|
||||
self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row))
|
||||
@@ -1194,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,
|
||||
|
||||
Reference in New Issue
Block a user