Put the three providers in a table, and test the request rather than the list

Reworked on top of master. The Groq request itself needed nothing beyond a key,
a base URL and a model id, but two providers fit in an if and an else where
three do not, and each one was written out four times: in transcribe_target(),
in the key rows of the settings window, in save and in load.

config.TRANSCRIBERS holds them now, one row each: the name the user sees and
the three settings that keep its key, its endpoint and its model. The variable
an empty key falls back to is the name of its setting, shouted. The provider
box, save, load, `dikte models --provider` and `dikte test-key` all read that
table, so a fourth provider is a row and a default rather than a branch in five
files. The key field, its Test button and its answer line are built once and
the three signals became one that carries which key was asked about.

The tests moved into the files of the modules they cover and check what a
provider actually changes: that the request goes to api.groq.com with the right
model and fields, that the glossary now reaches everything except OpenRouter,
that a Groq error says Groq, and that the settings window carries the key and
the model there and back. GROQ_API_KEY is cleared for the test run like the
other two, so a developer who has one does not send a test to the network.

Co-authored-by: Muzaffer Emre <[email protected]>
This commit is contained in:
yusufipk
2026-08-01 22:09:41 +07:00
co-authored by Muzaffer Emre
parent 22edd2f247
commit d04efe235a
13 changed files with 298 additions and 212 deletions
+4 -4
View File
@@ -79,10 +79,10 @@ next port breaks all of them.
## What a pull request should carry
A change to behaviour comes with a test for it. Adding a provider means a test
that the request goes to the right URL with the right fields; adding a platform
means a test for whatever the parsing of its device list, clipboard or shortcuts
looks like. Adding a setting means both halves of `settings_ui.py`: the round
A change to behaviour comes with a test for it. Adding a provider means a row in
`config.TRANSCRIBERS` and a test that the request goes to the right URL with the
right fields; adding a platform means a test for whatever the parsing of its
device list, clipboard or shortcuts looks like. Adding a setting means both halves of `settings_ui.py`: the round
trip in `tests/test_ui.py` is what catches only one of them being written.
Match the surrounding code: it is plain Python with no framework, comments
+4 -4
View File
@@ -39,10 +39,10 @@ sudo apt install pulseaudio-utils xclip xdotool ffmpeg
`install.sh` adds the `dikte` command, a menu entry and an autostart entry. The
settings window installs a GNOME or KDE global shortcut.
The settings window accepts **OpenAI**, **Groq** and **OpenRouter** keys. Speech
to text runs on any of them (`gpt-4o-transcribe` by default), cleanup always on
OpenRouter (`google/gemini-3.5-flash-lite`), so a single OpenRouter key can
cover both. They fall back to `OPENAI_API_KEY`, `GROQ_API_KEY` and
Three keys go in the settings window: **OpenAI**, **Groq** and **OpenRouter**.
Speech to text runs on any of them (`gpt-4o-transcribe` by default), cleanup
always on OpenRouter (`google/gemini-3.5-flash-lite`), so a single OpenRouter key
can cover both. They fall back to `OPENAI_API_KEY`, `GROQ_API_KEY` and
`OPENROUTER_API_KEY`, and are
stored in `~/.config/dikte/config.json`, mode 600. Cleanup can be switched off,
in which case the raw transcript is pasted, and a thinking model's effort can be
+4 -3
View File
@@ -39,11 +39,12 @@ sudo apt install pulseaudio-utils xclip xdotool ffmpeg
`install.sh` `dikte` komutunu, menü girdisini ve oturum açılışında otomatik
başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar.
Ayarlar penceresinde **OpenAI**, **Groq** ve **OpenRouter** anahtarları bulunur.
Sesi yazıya çevirme bunlardan birinde çalışır (varsayılan `gpt-4o-transcribe`),
Ayarlar penceresinde üç anahtar istenir: **OpenAI**, **Groq** ve **OpenRouter**.
Sesi yazıya çevirme üçünden birinde çalışır (varsayılan `gpt-4o-transcribe`),
temizleme her zaman OpenRouter'da (`google/gemini-3.5-flash-lite`), yani tek bir
OpenRouter anahtarı ikisine de yeter. Boş bırakırsan `OPENAI_API_KEY`,
`GROQ_API_KEY` ve `OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json`
`GROQ_API_KEY` ve `OPENROUTER_API_KEY` kullanılır;
anahtarlar `~/.config/dikte/config.json`
içinde, izinler 600. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript
yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin.
+16 -6
View File
@@ -1,9 +1,9 @@
"""OpenAI-compatible transcription and OpenRouter calls, stdlib only.
"""OpenAI, Groq and OpenRouter calls, stdlib only.
Transcription runs on either provider: OpenRouter mirrors OpenAI's
Transcription runs on any of the three: Groq and OpenRouter both mirror OpenAI's
/audio/transcriptions endpoint field for field, so one multipart request serves
both and only the key, the base URL and the model id change. Cleanup is always
OpenRouter.
all of them and only the key, the base URL and the model id change. Cleanup is
always OpenRouter.
"""
import collections
@@ -19,8 +19,8 @@ from i18n import t
APP_URL = "https://github.com/yusufipk/dikte"
USER_AGENT = f"dikte/1.0 (+{APP_URL})"
OPENAI_URL = "https://api.openai.com/v1"
OPENROUTER_URL = "https://openrouter.ai/api/v1"
GROQ_URL = "https://api.groq.com/openai/v1"
OPENROUTER_URL = "https://openrouter.ai/api/v1"
# Where a transcription request goes; built by config.Config.transcribe_target().
# `service` is the name the user sees in an error, `provider` the one the code
@@ -29,7 +29,12 @@ Target = collections.namedtuple("Target", "provider service api_key base_url mod
def timestamp_model(provider, selected=""):
"""Return a timestamp-capable model id for the selected provider."""
"""Which model answers with segment times.
OpenAI keeps them to whisper-1 and OpenRouter namespaces that id. Everything
Groq transcribes with is a whisper, so the model already chosen does it and
the fallback is only for a provider left on its default.
"""
if provider == "groq":
return selected or "whisper-large-v3-turbo"
return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
@@ -301,6 +306,11 @@ def openrouter_models(api_key="", transcription=False):
def openai_models(api_key, base_url=OPENAI_URL, service="OpenAI"):
"""The audio models of anything that speaks OpenAI's /models, Groq included.
`service` is only the name an error is written in, so a Groq key that is
refused says Groq rather than OpenAI.
"""
if not api_key:
raise ApiError(t("{service} API key is empty. Add it in Settings.",
service=service))
+20 -16
View File
@@ -662,12 +662,14 @@ def cmd_devices(opts):
def cmd_models(opts):
conf = cfg.Config()
who = cfg.TRANSCRIBERS[opts.provider]
try:
if opts.provider == "openai":
models = api.openai_models(conf.openai_key(), conf["openai_base_url"])
else:
if opts.provider == "openrouter":
models = api.openrouter_models(conf.openrouter_key(),
transcription=opts.transcription)
else:
models = api.openai_models(conf.api_key(who.key), conf[who.url],
who.service)
except api.ApiError as exc:
return fail(opts, exc)
return out(opts, {"ok": True, "provider": opts.provider, "models": models},
@@ -677,19 +679,21 @@ def cmd_models(opts):
def cmd_test_key(opts):
conf = cfg.Config()
results = {}
if opts.which in ("openai", "all"):
for name, who in cfg.TRANSCRIBERS.items():
if opts.which not in (name, "all"):
continue
try:
count = len(api.openai_models(conf.openai_key(), conf["openai_base_url"]))
results["openai"] = {"ok": True,
"message": f"connection works, {count} models visible"}
if name == "openrouter":
# The one key that also pays for cleanup, so it reports credit
# rather than a model count.
message = api.openrouter_key_status(conf.openrouter_key())
else:
count = len(api.openai_models(conf.api_key(who.key), conf[who.url],
who.service))
message = f"connection works, {count} models visible"
results[name] = {"ok": True, "message": message}
except api.ApiError as exc:
results["openai"] = {"ok": False, "message": str(exc)}
if opts.which in ("openrouter", "all"):
try:
results["openrouter"] = {"ok": True,
"message": api.openrouter_key_status(conf.openrouter_key())}
except api.ApiError as exc:
results["openrouter"] = {"ok": False, "message": str(exc)}
results[name] = {"ok": False, "message": str(exc)}
everything_ok = all(item["ok"] for item in results.values())
lines = [f"{'' if item['ok'] else ''} {name}: {item['message']}"
for name, item in results.items()]
@@ -986,14 +990,14 @@ def build_parser():
# --- the machine ------------------------------------------------------
leaf(subs, "devices", "microphones and monitors").set_defaults(func=cmd_devices)
models = leaf(subs, "models", "model ids a provider offers")
models.add_argument("--provider", choices=("openrouter", "openai"),
models.add_argument("--provider", choices=tuple(cfg.TRANSCRIBERS),
default="openrouter")
models.add_argument("--transcription", action="store_true",
help="only the speech-to-text ones")
models.set_defaults(func=cmd_models)
test = leaf(subs, "test-key", "check the API keys")
test.add_argument("which", nargs="?", default="all",
choices=("all", "openai", "openrouter"))
choices=("all", *cfg.TRANSCRIBERS))
test.set_defaults(func=cmd_test_key)
leaf(subs, "doctor", "keys, programs, and what is missing").set_defaults(func=cmd_doctor)
+33 -17
View File
@@ -1,5 +1,6 @@
"""Settings storage in ~/.config/dikte/config.json"""
import collections
import hashlib
import json
import os
@@ -367,7 +368,7 @@ DEFAULTS = {
"groq_base_url": "https://api.groq.com/openai/v1",
"openrouter_api_key": "",
"openrouter_base_url": "https://openrouter.ai/api/v1",
"transcribe_provider": "openai", # openai | groq | openrouter
"transcribe_provider": "openai", # a key of TRANSCRIBERS
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai
"groq_transcribe_model": "whisper-large-v3-turbo",
"openrouter_transcribe_model": "openai/gpt-4o-transcribe",
@@ -442,6 +443,22 @@ LEGACY_PROMPTS = {
"154fc5aca1166f00eebda705f848f0391bfbf5fe", # 1.2 English
}
# Every provider speech to text can run on, and the four settings that describe
# one. A fifth is a row here rather than another branch in transcribe_target(),
# another key row in the settings window and another line in save and load. The
# order is the order the provider box offers them in. `service` is the name the
# user sees; the environment variable that stands in for an empty key is the
# name of its setting, shouted.
Transcriber = collections.namedtuple("Transcriber", "service key url model")
TRANSCRIBERS = {
"openai": Transcriber("OpenAI", "openai_api_key", "openai_base_url",
"transcribe_model"),
"groq": Transcriber("Groq", "groq_api_key", "groq_base_url",
"groq_transcribe_model"),
"openrouter": Transcriber("OpenRouter", "openrouter_api_key",
"openrouter_base_url", "openrouter_transcribe_model"),
}
# Corners used to be stored with Turkish names.
_CORNER_MIGRATION = {
"sol-alt": "bottom-left", "sağ-alt": "bottom-right",
@@ -490,28 +507,27 @@ class Config:
def get(self, key, default=None):
return self.data.get(key, DEFAULTS.get(key, default))
def openai_key(self):
"""Fall back to the environment when no key is stored."""
return self["openai_api_key"].strip() or os.environ.get("OPENAI_API_KEY", "").strip()
def api_key(self, setting):
"""A stored key, or the environment variable that shares its name."""
return self[setting].strip() or os.environ.get(setting.upper(), "").strip()
def openrouter_key(self):
return self["openrouter_api_key"].strip() or os.environ.get("OPENROUTER_API_KEY", "").strip()
def openai_key(self):
return self.api_key("openai_api_key")
def groq_key(self):
return self["groq_api_key"].strip() or os.environ.get("GROQ_API_KEY", "").strip()
return self.api_key("groq_api_key")
def openrouter_key(self):
return self.api_key("openrouter_api_key")
def transcribe_target(self):
"""Key, endpoint and model for whichever provider does speech to text."""
if self["transcribe_provider"] == "openrouter":
return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
self["openrouter_base_url"],
self["openrouter_transcribe_model"])
if self["transcribe_provider"] == "groq":
return api.Target("groq", "Groq", self.groq_key(),
self["groq_base_url"],
self["groq_transcribe_model"])
return api.Target("openai", "OpenAI", self.openai_key(),
self["openai_base_url"], self["transcribe_model"])
name = self["transcribe_provider"]
if name not in TRANSCRIBERS:
name = DEFAULTS["transcribe_provider"]
who = TRANSCRIBERS[name]
return api.Target(name, who.service, self.api_key(who.key),
self[who.url], self[who.model])
def cleanup_prompt(self, with_timestamps=False, with_speakers=False,
subtitles=False):
+87 -119
View File
@@ -29,9 +29,8 @@ LANGUAGES = [
("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"),
]
CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"]
TRANSCRIBE_PROVIDERS = [
("OpenAI", "openai"), ("Groq", "groq"), ("OpenRouter", "openrouter"),
]
# The provider box offers what config knows how to reach, in that order.
TRANSCRIBE_PROVIDERS = [(who.service, name) for name, who in cfg.TRANSCRIBERS.items()]
# Starting points for the model box; "Fetch model list" replaces them with
# whatever the provider offers today.
TRANSCRIBE_MODELS = {
@@ -111,9 +110,8 @@ class SettingsWindow(QDialog):
_models_loaded = pyqtSignal(list, str)
_transcribe_models_loaded = pyqtSignal(list, str)
_test_done = pyqtSignal(bool, str)
_groq_test_done = pyqtSignal(bool, str)
_or_test_done = pyqtSignal(bool, str)
# Which key was tested, whether it worked, and what to write under it.
_test_done = pyqtSignal(str, bool, str)
def __init__(self, conf, launch_command, meeting_command=None,
meetings=None, ask_command=None, parent=None):
@@ -125,7 +123,9 @@ class SettingsWindow(QDialog):
self.meetings = meetings
# Each provider keeps its own transcription model, so switching the
# provider back and forth never overwrites the other one's.
self._models = {"openai": "", "groq": "", "openrouter": ""}
self._models = dict.fromkeys(cfg.TRANSCRIBERS, "")
self._key_fields = {}
self._testers = {}
self._shown_provider = ""
self.transcriber = FileTranscriber(conf, self)
self.setWindowTitle(t("Dikte Settings"))
@@ -156,8 +156,6 @@ class SettingsWindow(QDialog):
self._models_loaded.connect(self._on_models_loaded)
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
self._test_done.connect(self._on_test_done)
self._groq_test_done.connect(self._on_groq_test_done)
self._or_test_done.connect(self._on_or_test_done)
self.transcriber.progress.connect(self._on_file_progress)
self.transcriber.finished.connect(self._on_file_finished)
self.transcriber.failed.connect(self._on_file_failed)
@@ -249,35 +247,15 @@ class SettingsWindow(QDialog):
# them and a key no longer belongs to a single job.
keys = QGroupBox(t("Keys"))
keys_form = QFormLayout(keys)
self.openai_key = QLineEdit()
self.openai_key.setEchoMode(QLineEdit.EchoMode.Password)
self.openai_key.setPlaceholderText(t("sk-… (falls back to OPENAI_API_KEY)"))
self.test_button = QPushButton(t("Test"))
self.test_button.clicked.connect(self._test_openai)
self.test_label = QLabel("")
self.test_label.setWordWrap(True)
keys_form.addRow("OpenAI", self._row(self.openai_key, self.test_button))
keys_form.addRow("", self.test_label)
self.groq_key = QLineEdit()
self.groq_key.setEchoMode(QLineEdit.EchoMode.Password)
self.groq_key.setPlaceholderText(t("gsk_… (falls back to GROQ_API_KEY)"))
self.groq_test_button = QPushButton(t("Test"))
self.groq_test_button.clicked.connect(self._test_groq)
self.groq_test_label = QLabel("")
self.groq_test_label.setWordWrap(True)
keys_form.addRow("Groq", self._row(self.groq_key, self.groq_test_button))
keys_form.addRow("", self.groq_test_label)
self.openrouter_key = QLineEdit()
self.openrouter_key.setEchoMode(QLineEdit.EchoMode.Password)
self.openrouter_key.setPlaceholderText(t("sk-or-… (falls back to OPENROUTER_API_KEY)"))
self.or_test_button = QPushButton(t("Test"))
self.or_test_button.clicked.connect(self._test_openrouter)
self.or_test_label = QLabel("")
self.or_test_label.setWordWrap(True)
keys_form.addRow("OpenRouter", self._row(self.openrouter_key, self.or_test_button))
keys_form.addRow("", self.or_test_label)
self.openai_key = self._key_row(
keys_form, "openai", t("sk-… (falls back to OPENAI_API_KEY)"),
self._test_openai)
self.groq_key = self._key_row(
keys_form, "groq", t("gsk_… (falls back to GROQ_API_KEY)"),
self._test_groq)
self.openrouter_key = self._key_row(
keys_form, "openrouter", t("sk-or-… (falls back to OPENROUTER_API_KEY)"),
self._test_openrouter)
outer.addWidget(keys)
stt = QGroupBox(t("Speech to text"))
@@ -383,7 +361,7 @@ class SettingsWindow(QDialog):
how = QGroupBox(t("How it runs"))
how_form = QFormLayout(how)
self.assistant_shortcut = self._shortcut_box(t("none"))
install = QPushButton(t("Install as a global shortcut"))
install = QPushButton(t("Install as a KDE shortcut"))
install.clicked.connect(self._install_ask_shortcut)
remove = QPushButton(t("Remove"))
remove.clicked.connect(self._remove_ask_shortcut)
@@ -645,7 +623,7 @@ class SettingsWindow(QDialog):
recording_form.addRow("", self.meeting_keep_audio)
self.meeting_shortcut = self._shortcut_box(t("none"))
install = QPushButton(t("Install as a global shortcut"))
install = QPushButton(t("Install as a KDE shortcut"))
install.clicked.connect(self._install_meeting_shortcut)
remove = QPushButton(t("Remove"))
remove.clicked.connect(self._remove_meeting_shortcut)
@@ -799,7 +777,7 @@ class SettingsWindow(QDialog):
form.addRow(t("Shortcut"), self.shortcut)
layout.addLayout(form)
install = QPushButton(t("Install as a global shortcut"))
install = QPushButton(t("Install as a KDE shortcut"))
install.clicked.connect(self._install_shortcut)
remove = QPushButton(t("Remove"))
remove.clicked.connect(self._remove_shortcut)
@@ -906,6 +884,26 @@ class SettingsWindow(QDialog):
box.lineEdit().setPlaceholderText(placeholder)
return box
def _key_row(self, form, provider, placeholder, tester):
"""A key field, its Test button and the line the answer lands on.
The field and the pair the answer needs are filed under the provider's
name, so saving, loading and the test handler find them by name rather
than through three attributes each.
"""
field = QLineEdit()
field.setEchoMode(QLineEdit.EchoMode.Password)
field.setPlaceholderText(placeholder)
button = QPushButton(t("Test"))
button.clicked.connect(tester)
answer = QLabel("")
answer.setWordWrap(True)
form.addRow(cfg.TRANSCRIBERS[provider].service, self._row(field, button))
form.addRow("", answer)
self._key_fields[provider] = field
self._testers[provider] = (button, answer)
return field
@staticmethod
def _row(*widgets):
"""Widgets side by side in one form row; the first one takes the space."""
@@ -934,12 +932,9 @@ class SettingsWindow(QDialog):
self.filter_hallucinations.setChecked(conf["filter_hallucinations"])
self.keep_audio.setChecked(conf["keep_audio"])
self.openai_key.setText(conf["openai_api_key"])
self.groq_key.setText(conf["groq_api_key"])
self.openrouter_key.setText(conf["openrouter_api_key"])
self._models = {"openai": conf["transcribe_model"],
"groq": conf["groq_transcribe_model"],
"openrouter": conf["openrouter_transcribe_model"]}
for name, who in cfg.TRANSCRIBERS.items():
self._key_fields[name].setText(conf[who.key])
self._models[name] = conf[who.model]
self._shown_provider = ""
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
self._provider_changed() # selecting index 0 fires no signal
@@ -1017,17 +1012,12 @@ class SettingsWindow(QDialog):
conf["filter_hallucinations"] = self.filter_hallucinations.isChecked()
conf["keep_audio"] = self.keep_audio.isChecked()
conf["openai_api_key"] = self.openai_key.text().strip()
conf["groq_api_key"] = self.groq_key.text().strip()
conf["openrouter_api_key"] = self.openrouter_key.text().strip()
provider = self.transcribe_provider.currentData() or "openai"
self._models[provider] = self.transcribe_model.currentText().strip()
conf["transcribe_provider"] = provider
for key, name in (("openai", "transcribe_model"),
("groq", "groq_transcribe_model"),
("openrouter", "openrouter_transcribe_model")):
conf[name] = self._models[key].strip() or cfg.DEFAULTS[name]
for name, who in cfg.TRANSCRIBERS.items():
conf[who.key] = self._key_fields[name].text().strip()
conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model]
conf["cleanup_enabled"] = self.cleanup_enabled.isChecked()
conf["cleanup_model"] = self.cleanup_model.currentText().strip()
@@ -1126,22 +1116,14 @@ class SettingsWindow(QDialog):
provider = self.transcribe_provider.currentData() or "openai"
self.refresh_transcribe_models.setEnabled(False)
self.transcribe_status.setText(t("Fetching model list…"))
openai_key = self.openai_key.text().strip() or self.conf.openai_key()
groq_key = self.groq_key.text().strip() or self.conf.groq_key()
openrouter_key = self.openrouter_key.text().strip() or self.conf.openrouter_key()
key, base = self._typed_key(provider)
service = cfg.TRANSCRIBERS[provider].service
def work():
try:
if provider == "openrouter":
models = api.openrouter_models(openrouter_key, transcription=True)
elif provider == "groq":
models = api.openai_models(
groq_key, self.conf["groq_base_url"], "Groq"
)
else:
models = api.openai_models(
openai_key, self.conf["openai_base_url"]
)
models = (api.openrouter_models(key, transcription=True)
if provider == "openrouter"
else api.openai_models(key, base, service))
self._transcribe_models_loaded.emit(models, "")
except api.ApiError as exc:
self._transcribe_models_loaded.emit([], str(exc))
@@ -1185,65 +1167,51 @@ class SettingsWindow(QDialog):
self.models_label.setText(t("{count} models loaded.", count=len(models)))
def _test_openai(self):
self.test_button.setEnabled(False)
self.test_label.setText(t("Trying…"))
key = self.openai_key.text().strip() or self.conf.openai_key()
base = self.conf["openai_base_url"]
def work():
try:
models = api.openai_models(key, base)
self._test_done.emit(
True, t("Connection works. {count} audio models visible.", count=len(models))
)
except api.ApiError as exc:
self._test_done.emit(False, str(exc))
threading.Thread(target=work, daemon=True).start()
def _test_openrouter(self):
self.or_test_button.setEnabled(False)
self.or_test_label.setText(t("Trying…"))
key = self.openrouter_key.text().strip() or self.conf.openrouter_key()
def work():
try:
self._or_test_done.emit(True, api.openrouter_key_status(key))
except api.ApiError as exc:
self._or_test_done.emit(False, str(exc))
threading.Thread(target=work, daemon=True).start()
key, base = self._typed_key("openai")
self._test_key("openai", lambda: t(
"Connection works. {count} audio models visible.",
count=len(api.openai_models(key, base)),
))
def _test_groq(self):
self.groq_test_button.setEnabled(False)
self.groq_test_label.setText(t("Trying…"))
key = self.groq_key.text().strip() or self.conf.groq_key()
key, base = self._typed_key("groq")
self._test_key("groq", lambda: t(
"Connection works. {count} audio models visible.",
count=len(api.openai_models(key, base, cfg.TRANSCRIBERS["groq"].service)),
))
def _test_openrouter(self):
key, _ = self._typed_key("openrouter")
self._test_key("openrouter", lambda: api.openrouter_key_status(key))
def _typed_key(self, provider):
"""(key, base URL) for a provider, preferring what is in the field now."""
who = cfg.TRANSCRIBERS[provider]
typed = self._key_fields[provider].text().strip()
return typed or self.conf.api_key(who.key), self.conf[who.url]
def _test_key(self, provider, ask):
"""Run `ask` off the interface thread and write its answer under the key.
`ask` returns the line to show, or raises ApiError with the line to show
instead; either way it is read from a field before the thread starts.
"""
button, answer = self._testers[provider]
button.setEnabled(False)
answer.setText(t("Trying…"))
def work():
try:
models = api.openai_models(
key, self.conf["groq_base_url"], "Groq"
)
self._groq_test_done.emit(
True, t("Connection works. {count} audio models visible.",
count=len(models))
)
self._test_done.emit(provider, True, ask())
except api.ApiError as exc:
self._groq_test_done.emit(False, str(exc))
self._test_done.emit(provider, False, str(exc))
threading.Thread(target=work, daemon=True).start()
def _on_or_test_done(self, ok, message):
self.or_test_button.setEnabled(True)
self.or_test_label.setText(("" if ok else "") + message)
def _on_test_done(self, ok, message):
self.test_button.setEnabled(True)
self.test_label.setText(("" if ok else "") + message)
def _on_groq_test_done(self, ok, message):
self.groq_test_button.setEnabled(True)
self.groq_test_label.setText(("" if ok else "") + message)
def _on_test_done(self, provider, ok, message):
button, answer = self._testers[provider]
button.setEnabled(True)
answer.setText(("" if ok else "") + message)
# ---- audio file ------------------------------------------------------
@@ -1329,7 +1297,7 @@ class SettingsWindow(QDialog):
def _install_shortcut(self):
combo = self.shortcut.currentText().strip() or "Ctrl+Space"
clashes = hotkey.conflicting_shortcuts(combo) if hotkey.desktop_name() == "KDE" else []
clashes = hotkey.conflicting_shortcuts(combo)
if clashes:
answer = QMessageBox.question(
self, t("Shortcut conflict"),
+1 -1
View File
@@ -22,7 +22,7 @@ atexit.register(shutil.rmtree, _SANDBOX, True)
# A key sitting in the environment would otherwise reach the code that falls
# back to it, and the tests for "there is no key" would pass only on a machine
# without one.
for _var in ("OPENAI_API_KEY", "OPENROUTER_API_KEY"):
for _var in ("OPENAI_API_KEY", "GROQ_API_KEY", "OPENROUTER_API_KEY"):
os.environ.pop(_var, None)
# The interface language leaks through module-level state, so the tests fix it
+48 -2
View File
@@ -21,6 +21,7 @@ from tests.support import (
)
OPENAI = api.Target("openai", "OpenAI", "sk-test", api.OPENAI_URL, "gpt-4o-transcribe")
GROQ = api.Target("groq", "Groq", "gsk-test", api.GROQ_URL, "whisper-large-v3-turbo")
OPENROUTER = api.Target("openrouter", "OpenRouter", "sk-or-test",
api.OPENROUTER_URL, "openai/gpt-4o-transcribe")
@@ -32,6 +33,18 @@ class TimestampModel(unittest.TestCase):
def test_openrouter_namespaces_the_id(self):
self.assertEqual(api.timestamp_model("openrouter"), "openai/whisper-1")
def test_groq_keeps_the_model_that_was_chosen(self):
"""Every model it transcribes with is a whisper, so all of them do times."""
self.assertEqual(api.timestamp_model("groq", "whisper-large-v3"),
"whisper-large-v3")
def test_groq_with_nothing_chosen_falls_back(self):
self.assertEqual(api.timestamp_model("groq"), "whisper-large-v3-turbo")
def test_the_others_ignore_what_was_chosen(self):
self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"),
"whisper-1")
class Explain(DikteTest):
def error(self, status):
@@ -176,13 +189,28 @@ class Transcribe(DikteTest):
self.assertEqual(multipart_fields(calls[0])["language"], "tr")
self.assertNotIn("language", multipart_fields(calls[1]))
def test_the_glossary_goes_to_openai_only(self):
def test_the_glossary_goes_everywhere_but_openrouter(self):
"""OpenRouter takes the field and throws it away, so spare it the bytes."""
with fake_urlopen({"text": "hi"}) as calls:
api.transcribe(OPENAI, self.wav, prompt="Paraşüt, OpenFrame")
api.transcribe(GROQ, self.wav, prompt="Paraşüt, OpenFrame")
api.transcribe(OPENROUTER, self.wav, prompt="Paraşüt, OpenFrame")
self.assertIn("prompt", multipart_fields(calls[0]))
self.assertNotIn("prompt", multipart_fields(calls[1]))
self.assertIn("prompt", multipart_fields(calls[1]))
self.assertNotIn("prompt", multipart_fields(calls[2]))
def test_groq_goes_to_groq(self):
with fake_urlopen({"text": "hi"}) as calls:
api.transcribe(GROQ, self.wav)
self.assertEqual(calls[0].full_url,
"https://api.groq.com/openai/v1/audio/transcriptions")
self.assertEqual(multipart_fields(calls[0])["model"], "whisper-large-v3-turbo")
def test_a_refused_groq_key_is_explained_in_groq_s_name(self):
with fake_urlopen(http_error(401, '{"error": {"message": "bad key"}}')), \
self.assertRaises(api.ApiError) as caught:
api.transcribe(GROQ, self.wav)
self.assertIn("Groq", str(caught.exception))
def test_openrouter_is_attributed(self):
with fake_urlopen({"text": "hi"}) as calls:
@@ -242,6 +270,12 @@ class TranscribeSegments(DikteTest):
api.transcribe_segments(OPENROUTER, self.wav)
self.assertEqual(multipart_fields(calls[0])["model"], "openai/whisper-1")
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:
api.transcribe_segments(target, self.wav)
self.assertEqual(multipart_fields(calls[0])["model"], "whisper-large-v3")
def test_the_segments_come_back_as_numbers(self):
with fake_urlopen(self.reply([
{"start": "0.5", "end": "2.25", "text": " hello "},
@@ -432,6 +466,18 @@ class ModelLists(DikteTest):
with self.assertRaises(api.ApiError):
api.openai_models("")
def test_the_same_list_read_from_groq(self):
with fake_urlopen({"data": [{"id": "llama-3.3-70b"},
{"id": "whisper-large-v3"}]}) as calls:
models = api.openai_models("gsk-test", api.GROQ_URL, "Groq")
self.assertEqual(calls[0].full_url, "https://api.groq.com/openai/v1/models")
self.assertEqual(models, ["whisper-large-v3"])
def test_a_missing_groq_key_says_groq(self):
with self.assertRaises(api.ApiError) as caught:
api.openai_models("", api.GROQ_URL, "Groq")
self.assertIn("Groq", str(caught.exception))
if __name__ == "__main__":
unittest.main()
+34 -1
View File
@@ -15,7 +15,7 @@ from unittest import mock
import cli
import config as cfg
import ipc
from tests.support import DikteTest
from tests.support import DikteTest, fake_urlopen
class Options:
@@ -316,6 +316,39 @@ class ConfigCommands(DikteTest):
{"cleanup", "subtitles", "meeting", "agent"})
class Providers(DikteTest):
"""The terminal reaches every provider the settings window does."""
def run_cmd(self, func, **values):
with captured() as (out, err):
code = func(Options(**values))
return code, out.getvalue(), err.getvalue()
def test_a_provider_the_settings_window_offers_is_a_choice_here_too(self):
parser = cli.build_parser()
for provider in cfg.TRANSCRIBERS:
with self.subTest(provider=provider):
opts = parser.parse_args(["models", "--provider", provider])
self.assertEqual(opts.provider, provider)
self.assertEqual(parser.parse_args(["test-key", provider]).which,
provider)
def test_the_model_list_is_read_from_the_chosen_provider(self):
self.write_config({"groq_api_key": "gsk-test"})
with fake_urlopen({"data": [{"id": "whisper-large-v3"}]}) as calls:
code, out, _ = self.run_cmd(cli.cmd_models, provider="groq",
transcription=False)
self.assertEqual(code, 0)
self.assertEqual(calls[0].full_url, "https://api.groq.com/openai/v1/models")
self.assertEqual(out.strip(), "whisper-large-v3")
def test_a_key_that_is_not_there_is_reported_under_its_own_name(self):
code, out, _ = self.run_cmd(cli.cmd_test_key, which="groq")
self.assertEqual(code, 1)
self.assertIn("groq", out)
self.assertIn("Groq", out)
class Finding(DikteTest):
def test_no_history_at_all(self):
self.assertIsNone(cli._find_history("last"))
+19
View File
@@ -125,6 +125,10 @@ class Keys(DikteTest):
def test_no_key_anywhere(self):
self.assertEqual(cfg.Config().openai_key(), "")
def test_every_provider_falls_back_to_the_variable_of_its_own_name(self):
with mock.patch.dict(os.environ, {"GROQ_API_KEY": "gsk-env"}):
self.assertEqual(cfg.Config().groq_key(), "gsk-env")
class TranscribeTarget(DikteTest):
def test_openai_by_default(self):
@@ -145,6 +149,21 @@ class TranscribeTarget(DikteTest):
self.assertEqual(target.api_key, "sk-or-test")
self.assertEqual(target.model, "openai/whisper-1")
def test_groq_when_it_is_picked(self):
conf = self.config(transcribe_provider="groq", groq_api_key="gsk-test",
groq_transcribe_model="whisper-large-v3")
target = conf.transcribe_target()
self.assertEqual(target.provider, "groq")
self.assertEqual(target.service, "Groq")
self.assertEqual(target.api_key, "gsk-test")
self.assertEqual(target.base_url, api.GROQ_URL)
self.assertEqual(target.model, "whisper-large-v3")
def test_a_provider_this_version_has_never_heard_of(self):
"""A config written by a fork, or by a version that dropped one."""
target = self.config(transcribe_provider="deepgram").transcribe_target()
self.assertEqual(target.provider, "openai")
def test_a_self_hosted_endpoint(self):
conf = self.config(openai_base_url="http://localhost:8080/v1")
self.assertEqual(conf.transcribe_target().base_url, "http://localhost:8080/v1")
-37
View File
@@ -1,37 +0,0 @@
import json
import pathlib
import tempfile
import unittest
from unittest import mock
import api
import config
import settings_ui
class GroqCompatibilityTests(unittest.TestCase):
def test_old_config_gains_groq_defaults(self):
with tempfile.TemporaryDirectory() as directory:
path = pathlib.Path(directory) / "config.json"
path.write_text(json.dumps({"transcribe_provider": "openai"}), encoding="utf-8")
with mock.patch.object(config, "CONFIG_FILE", path):
conf = config.Config()
self.assertEqual(conf["groq_base_url"], api.GROQ_URL)
self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3-turbo")
def test_groq_target_and_timestamp_model(self):
with mock.patch.object(config.Config, "load"):
conf = config.Config()
conf["transcribe_provider"] = "groq"
conf["groq_api_key"] = "test-key"
target = conf.transcribe_target()
self.assertEqual(target.provider, "groq")
self.assertEqual(target.base_url, api.GROQ_URL)
self.assertEqual(api.timestamp_model(target.provider, target.model), target.model)
def test_groq_is_available_in_settings(self):
self.assertIn(("Groq", "groq"), settings_ui.TRANSCRIBE_PROVIDERS)
if __name__ == "__main__":
unittest.main()
+27 -1
View File
@@ -36,9 +36,11 @@ CHANGED = {
"filter_hallucinations": False,
"keep_audio": True,
"openai_api_key": "sk-test-key",
"groq_api_key": "gsk-test-key",
"openrouter_api_key": "sk-or-test-key",
"transcribe_provider": "openrouter",
"transcribe_model": "whisper-1",
"groq_transcribe_model": "whisper-large-v3",
"openrouter_transcribe_model": "openai/whisper-1",
"cleanup_enabled": False,
"cleanup_model": "some/other-model",
@@ -145,14 +147,38 @@ class Settings(DikteTest):
def test_each_provider_keeps_its_own_transcription_model(self):
self.write_config({"transcribe_provider": "openai",
"transcribe_model": "gpt-4o-transcribe",
"groq_transcribe_model": "whisper-large-v3",
"openrouter_transcribe_model": "openai/whisper-1"})
conf = cfg.Config()
window = self.window(conf)
for provider in ("groq", "openrouter"):
window.transcribe_provider.setCurrentIndex(
window.transcribe_provider.findData("openrouter"))
window.transcribe_provider.findData(provider))
window._save()
self.assertEqual(conf["transcribe_provider"], "openrouter")
self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe")
self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3")
def test_the_provider_box_offers_every_provider_config_knows(self):
window = self.window(cfg.Config())
offered = [window.transcribe_provider.itemData(i)
for i in range(window.transcribe_provider.count())]
self.assertEqual(offered, list(cfg.TRANSCRIBERS))
def test_the_answer_to_a_test_lands_under_the_key_it_was_asked_about(self):
"""One signal serves all three buttons, so it carries which one asked."""
window = self.window(cfg.Config())
window._on_test_done("groq", True, "it works")
button, answer = window._testers["groq"]
self.assertEqual(answer.text(), "✓ it works")
self.assertTrue(button.isEnabled())
self.assertEqual(window._testers["openai"][1].text(), "")
def test_a_key_lands_in_the_field_of_its_own_provider(self):
self.write_config({"groq_api_key": "gsk-mine"})
window = self.window(cfg.Config())
self.assertEqual(window.groq_key.text(), "gsk-mine")
self.assertEqual(window.openai_key.text(), "")
def test_saving_applies_the_lowered_history_limit_at_once(self):
for index in range(10):