Merge pull request #6 from muzafferemre06/feature/groq-transcription

Add Groq as a transcription provider
This commit is contained in:
Yusuf İpek
2026-08-01 18:32:46 +03:00
committed by GitHub
13 changed files with 307 additions and 120 deletions
+4 -4
View File
@@ -79,10 +79,10 @@ next port breaks all of them.
## What a pull request should carry ## What a pull request should carry
A change to behaviour comes with a test for it. Adding a provider means a test A change to behaviour comes with a test for it. Adding a provider means a row in
that the request goes to the right URL with the right fields; adding a platform `config.TRANSCRIBERS` and a test that the request goes to the right URL with the
means a test for whatever the parsing of its device list, clipboard or shortcuts right fields; adding a platform means a test for whatever the parsing of its
looks like. Adding a setting means both halves of `settings_ui.py`: the round 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. 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 Match the surrounding code: it is plain Python with no framework, comments
+5 -4
View File
@@ -39,10 +39,11 @@ sudo apt install pulseaudio-utils xclip xdotool ffmpeg
`install.sh` adds the `dikte` command, a menu entry and an autostart entry. The `install.sh` adds the `dikte` command, a menu entry and an autostart entry. The
settings window installs a GNOME or KDE global shortcut. settings window installs a GNOME or KDE global shortcut.
Two keys go in the settings window: **OpenAI** and **OpenRouter**. Speech to text Three keys go in the settings window: **OpenAI**, **Groq** and **OpenRouter**.
runs on either one (`gpt-4o-transcribe` by default), cleanup always on Speech to text runs on any of them (`gpt-4o-transcribe` by default), cleanup
OpenRouter (`google/gemini-3.5-flash-lite`), so a single OpenRouter key can always on OpenRouter (`google/gemini-3.5-flash-lite`), so a single OpenRouter key
cover both. They fall back to `OPENAI_API_KEY` and `OPENROUTER_API_KEY`, and are 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, 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 in which case the raw transcript is pasted, and a thinking model's effort can be
set next to it. set next to it.
+5 -4
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 `install.sh` `dikte` komutunu, menü girdisini ve oturum açılışında otomatik
başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar. başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar.
Ayarlar penceresinde iki anahtar istenir: **OpenAI** ve **OpenRouter**. Sesi Ayarlar penceresinde üç anahtar istenir: **OpenAI**, **Groq** ve **OpenRouter**.
yazıya çevirme ikisinden birinde çalışır (varsayılan `gpt-4o-transcribe`), 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 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` ve OpenRouter anahtarı ikisine de yeter. Boş bırakırsan `OPENAI_API_KEY`,
`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 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. yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin.
+24 -11
View File
@@ -1,9 +1,9 @@
"""OpenAI 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 /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 all of them and only the key, the base URL and the model id change. Cleanup is
OpenRouter. always OpenRouter.
""" """
import collections import collections
@@ -19,6 +19,7 @@ from i18n import t
APP_URL = "https://github.com/yusufipk/dikte" APP_URL = "https://github.com/yusufipk/dikte"
USER_AGENT = f"dikte/1.0 (+{APP_URL})" USER_AGENT = f"dikte/1.0 (+{APP_URL})"
OPENAI_URL = "https://api.openai.com/v1" OPENAI_URL = "https://api.openai.com/v1"
GROQ_URL = "https://api.groq.com/openai/v1"
OPENROUTER_URL = "https://openrouter.ai/api/v1" OPENROUTER_URL = "https://openrouter.ai/api/v1"
# Where a transcription request goes; built by config.Config.transcribe_target(). # Where a transcription request goes; built by config.Config.transcribe_target().
@@ -27,8 +28,15 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1"
Target = collections.namedtuple("Target", "provider service api_key base_url model") Target = collections.namedtuple("Target", "provider service api_key base_url model")
def timestamp_model(provider): def timestamp_model(provider, selected=""):
"""Only whisper-1 returns segment times, and OpenRouter namespaces the id.""" """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" return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
@@ -126,7 +134,7 @@ def _transcribe_request(target, wav_path, language, prompt, response_format,
fields.append(("language", language)) fields.append(("language", language))
# OpenRouter takes the hint field and throws it away, so spare it the bytes. # OpenRouter takes the hint field and throws it away, so spare it the bytes.
# The same words still reach the cleanup model as a glossary. # The same words still reach the cleanup model as a glossary.
if prompt and target.provider == "openai": if prompt and target.provider != "openrouter":
fields.append(("prompt", prompt)) fields.append(("prompt", prompt))
if granularity: if granularity:
fields.append(("timestamp_granularities[]", granularity)) fields.append(("timestamp_granularities[]", granularity))
@@ -153,7 +161,7 @@ def transcribe(target, wav_path, language="", prompt="", timeout=300):
def transcribe_segments(target, wav_path, language="", prompt="", timeout=300): def transcribe_segments(target, wav_path, language="", prompt="", timeout=300):
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response.""" """[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
data = _transcribe_request( data = _transcribe_request(
target._replace(model=timestamp_model(target.provider)), target._replace(model=timestamp_model(target.provider, target.model)),
wav_path, language, prompt, "verbose_json", wav_path, language, prompt, "verbose_json",
granularity="segment", timeout=timeout, granularity="segment", timeout=timeout,
) )
@@ -297,17 +305,22 @@ def openrouter_models(api_key="", transcription=False):
return sorted(m["id"] for m in models if m.get("id")) return sorted(m["id"] for m in models if m.get("id"))
def openai_models(api_key, base_url=OPENAI_URL): 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: if not api_key:
raise ApiError(t("{service} API key is empty. Add it in Settings.", raise ApiError(t("{service} API key is empty. Add it in Settings.",
service="OpenAI")) service=service))
try: try:
data = _get_json( data = _get_json(
f"{base_url.rstrip('/')}/models", f"{base_url.rstrip('/')}/models",
{"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT}, {"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT},
) )
except ApiError as exc: except ApiError as exc:
raise explain(exc, "OpenAI") from None raise explain(exc, service) from None
ids = [m["id"] for m in data.get("data", []) if m.get("id")] ids = [m["id"] for m in data.get("data", []) if m.get("id")]
audio = [i for i in ids if "transcribe" in i or "whisper" in i] audio = [i for i in ids if "transcribe" in i or "whisper" in i]
return sorted(audio or ids) return sorted(audio or ids)
+20 -16
View File
@@ -662,12 +662,14 @@ def cmd_devices(opts):
def cmd_models(opts): def cmd_models(opts):
conf = cfg.Config() conf = cfg.Config()
who = cfg.TRANSCRIBERS[opts.provider]
try: try:
if opts.provider == "openai": if opts.provider == "openrouter":
models = api.openai_models(conf.openai_key(), conf["openai_base_url"])
else:
models = api.openrouter_models(conf.openrouter_key(), models = api.openrouter_models(conf.openrouter_key(),
transcription=opts.transcription) transcription=opts.transcription)
else:
models = api.openai_models(conf.api_key(who.key), conf[who.url],
who.service)
except api.ApiError as exc: except api.ApiError as exc:
return fail(opts, exc) return fail(opts, exc)
return out(opts, {"ok": True, "provider": opts.provider, "models": models}, return out(opts, {"ok": True, "provider": opts.provider, "models": models},
@@ -677,19 +679,21 @@ def cmd_models(opts):
def cmd_test_key(opts): def cmd_test_key(opts):
conf = cfg.Config() conf = cfg.Config()
results = {} results = {}
if opts.which in ("openai", "all"): for name, who in cfg.TRANSCRIBERS.items():
if opts.which not in (name, "all"):
continue
try: try:
count = len(api.openai_models(conf.openai_key(), conf["openai_base_url"])) if name == "openrouter":
results["openai"] = {"ok": True, # The one key that also pays for cleanup, so it reports credit
"message": f"connection works, {count} models visible"} # 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: except api.ApiError as exc:
results["openai"] = {"ok": False, "message": str(exc)} results[name] = {"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)}
everything_ok = all(item["ok"] for item in results.values()) everything_ok = all(item["ok"] for item in results.values())
lines = [f"{'' if item['ok'] else ''} {name}: {item['message']}" lines = [f"{'' if item['ok'] else ''} {name}: {item['message']}"
for name, item in results.items()] for name, item in results.items()]
@@ -986,14 +990,14 @@ def build_parser():
# --- the machine ------------------------------------------------------ # --- the machine ------------------------------------------------------
leaf(subs, "devices", "microphones and monitors").set_defaults(func=cmd_devices) leaf(subs, "devices", "microphones and monitors").set_defaults(func=cmd_devices)
models = leaf(subs, "models", "model ids a provider offers") 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") default="openrouter")
models.add_argument("--transcription", action="store_true", models.add_argument("--transcription", action="store_true",
help="only the speech-to-text ones") help="only the speech-to-text ones")
models.set_defaults(func=cmd_models) models.set_defaults(func=cmd_models)
test = leaf(subs, "test-key", "check the API keys") test = leaf(subs, "test-key", "check the API keys")
test.add_argument("which", nargs="?", default="all", test.add_argument("which", nargs="?", default="all",
choices=("all", "openai", "openrouter")) choices=("all", *cfg.TRANSCRIBERS))
test.set_defaults(func=cmd_test_key) test.set_defaults(func=cmd_test_key)
leaf(subs, "doctor", "keys, programs, and what is missing").set_defaults(func=cmd_doctor) leaf(subs, "doctor", "keys, programs, and what is missing").set_defaults(func=cmd_doctor)
+36 -10
View File
@@ -1,5 +1,6 @@
"""Settings storage in ~/.config/dikte/config.json""" """Settings storage in ~/.config/dikte/config.json"""
import collections
import hashlib import hashlib
import json import json
import os import os
@@ -363,10 +364,13 @@ DEFAULTS = {
"ui_language": "auto", # auto | tr | en "ui_language": "auto", # auto | tr | en
"openai_api_key": "", "openai_api_key": "",
"openai_base_url": "https://api.openai.com/v1", "openai_base_url": "https://api.openai.com/v1",
"groq_api_key": "",
"groq_base_url": "https://api.groq.com/openai/v1",
"openrouter_api_key": "", "openrouter_api_key": "",
"openrouter_base_url": "https://openrouter.ai/api/v1", "openrouter_base_url": "https://openrouter.ai/api/v1",
"transcribe_provider": "openai", # openai | openrouter "transcribe_provider": "openai", # a key of TRANSCRIBERS
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai "transcribe_model": "gpt-4o-transcribe", # used when provider is openai
"groq_transcribe_model": "whisper-large-v3-turbo",
"openrouter_transcribe_model": "openai/gpt-4o-transcribe", "openrouter_transcribe_model": "openai/gpt-4o-transcribe",
"language": "tr", "language": "tr",
"transcribe_prompt": "", "transcribe_prompt": "",
@@ -439,6 +443,22 @@ LEGACY_PROMPTS = {
"154fc5aca1166f00eebda705f848f0391bfbf5fe", # 1.2 English "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. # Corners used to be stored with Turkish names.
_CORNER_MIGRATION = { _CORNER_MIGRATION = {
"sol-alt": "bottom-left", "sağ-alt": "bottom-right", "sol-alt": "bottom-left", "sağ-alt": "bottom-right",
@@ -487,21 +507,27 @@ class Config:
def get(self, key, default=None): def get(self, key, default=None):
return self.data.get(key, DEFAULTS.get(key, default)) return self.data.get(key, DEFAULTS.get(key, default))
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 openai_key(self): def openai_key(self):
"""Fall back to the environment when no key is stored.""" return self.api_key("openai_api_key")
return self["openai_api_key"].strip() or os.environ.get("OPENAI_API_KEY", "").strip()
def groq_key(self):
return self.api_key("groq_api_key")
def openrouter_key(self): def openrouter_key(self):
return self["openrouter_api_key"].strip() or os.environ.get("OPENROUTER_API_KEY", "").strip() return self.api_key("openrouter_api_key")
def transcribe_target(self): def transcribe_target(self):
"""Key, endpoint and model for whichever provider does speech to text.""" """Key, endpoint and model for whichever provider does speech to text."""
if self["transcribe_provider"] == "openrouter": name = self["transcribe_provider"]
return api.Target("openrouter", "OpenRouter", self.openrouter_key(), if name not in TRANSCRIBERS:
self["openrouter_base_url"], name = DEFAULTS["transcribe_provider"]
self["openrouter_transcribe_model"]) who = TRANSCRIBERS[name]
return api.Target("openai", "OpenAI", self.openai_key(), return api.Target(name, who.service, self.api_key(who.key),
self["openai_base_url"], self["transcribe_model"]) self[who.url], self[who.model])
def cleanup_prompt(self, with_timestamps=False, with_speakers=False, def cleanup_prompt(self, with_timestamps=False, with_speakers=False,
subtitles=False): subtitles=False):
+1
View File
@@ -177,6 +177,7 @@ TR = {
"Model": "Model", "Model": "Model",
"Provider": "Sağlayıcı", "Provider": "Sağlayıcı",
"sk-… (falls back to OPENAI_API_KEY)": "sk-… (boşsa OPENAI_API_KEY kullanılır)", "sk-… (falls back to OPENAI_API_KEY)": "sk-… (boşsa OPENAI_API_KEY kullanılır)",
"gsk_… (falls back to GROQ_API_KEY)": "gsk_… (boşsa GROQ_API_KEY kullanılır)",
"sk-or-… (falls back to OPENROUTER_API_KEY)": "sk-or-… (boşsa OPENROUTER_API_KEY kullanılır)", "sk-or-… (falls back to OPENROUTER_API_KEY)": "sk-or-… (boşsa OPENROUTER_API_KEY kullanılır)",
"Test": "Test et", "Test": "Test et",
"Trying…": "Deneniyor…", "Trying…": "Deneniyor…",
+82 -65
View File
@@ -29,11 +29,13 @@ LANGUAGES = [
("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"), ("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"),
] ]
CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"] CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"]
TRANSCRIBE_PROVIDERS = [("OpenAI", "openai"), ("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 # Starting points for the model box; "Fetch model list" replaces them with
# whatever the provider offers today. # whatever the provider offers today.
TRANSCRIBE_MODELS = { TRANSCRIBE_MODELS = {
"openai": ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"], "openai": ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"],
"groq": ["whisper-large-v3-turbo", "whisper-large-v3"],
"openrouter": [ "openrouter": [
"openai/gpt-4o-transcribe", "openai/gpt-4o-mini-transcribe", "openai/gpt-4o-transcribe", "openai/gpt-4o-mini-transcribe",
"openai/whisper-1", "openai/whisper-large-v3", "openai/whisper-1", "openai/whisper-large-v3",
@@ -108,8 +110,8 @@ class SettingsWindow(QDialog):
_models_loaded = pyqtSignal(list, str) _models_loaded = pyqtSignal(list, str)
_transcribe_models_loaded = pyqtSignal(list, str) _transcribe_models_loaded = pyqtSignal(list, str)
_test_done = pyqtSignal(bool, str) # Which key was tested, whether it worked, and what to write under it.
_or_test_done = pyqtSignal(bool, str) _test_done = pyqtSignal(str, bool, str)
def __init__(self, conf, launch_command, meeting_command=None, def __init__(self, conf, launch_command, meeting_command=None,
meetings=None, ask_command=None, parent=None): meetings=None, ask_command=None, parent=None):
@@ -121,7 +123,9 @@ class SettingsWindow(QDialog):
self.meetings = meetings self.meetings = meetings
# Each provider keeps its own transcription model, so switching the # Each provider keeps its own transcription model, so switching the
# provider back and forth never overwrites the other one's. # provider back and forth never overwrites the other one's.
self._models = {"openai": "", "openrouter": ""} self._models = dict.fromkeys(cfg.TRANSCRIBERS, "")
self._key_fields = {}
self._testers = {}
self._shown_provider = "" self._shown_provider = ""
self.transcriber = FileTranscriber(conf, self) self.transcriber = FileTranscriber(conf, self)
self.setWindowTitle(t("Dikte Settings")) self.setWindowTitle(t("Dikte Settings"))
@@ -152,7 +156,6 @@ class SettingsWindow(QDialog):
self._models_loaded.connect(self._on_models_loaded) self._models_loaded.connect(self._on_models_loaded)
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded) self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
self._test_done.connect(self._on_test_done) self._test_done.connect(self._on_test_done)
self._or_test_done.connect(self._on_or_test_done)
self.transcriber.progress.connect(self._on_file_progress) self.transcriber.progress.connect(self._on_file_progress)
self.transcriber.finished.connect(self._on_file_finished) self.transcriber.finished.connect(self._on_file_finished)
self.transcriber.failed.connect(self._on_file_failed) self.transcriber.failed.connect(self._on_file_failed)
@@ -244,25 +247,15 @@ class SettingsWindow(QDialog):
# them and a key no longer belongs to a single job. # them and a key no longer belongs to a single job.
keys = QGroupBox(t("Keys")) keys = QGroupBox(t("Keys"))
keys_form = QFormLayout(keys) keys_form = QFormLayout(keys)
self.openai_key = QLineEdit() self.openai_key = self._key_row(
self.openai_key.setEchoMode(QLineEdit.EchoMode.Password) keys_form, "openai", t("sk-… (falls back to OPENAI_API_KEY)"),
self.openai_key.setPlaceholderText(t("sk-… (falls back to OPENAI_API_KEY)")) self._test_openai)
self.test_button = QPushButton(t("Test")) self.groq_key = self._key_row(
self.test_button.clicked.connect(self._test_openai) keys_form, "groq", t("gsk_… (falls back to GROQ_API_KEY)"),
self.test_label = QLabel("") self._test_groq)
self.test_label.setWordWrap(True) self.openrouter_key = self._key_row(
keys_form.addRow("OpenAI", self._row(self.openai_key, self.test_button)) keys_form, "openrouter", t("sk-or-… (falls back to OPENROUTER_API_KEY)"),
keys_form.addRow("", self.test_label) self._test_openrouter)
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)
outer.addWidget(keys) outer.addWidget(keys)
stt = QGroupBox(t("Speech to text")) stt = QGroupBox(t("Speech to text"))
@@ -891,6 +884,26 @@ class SettingsWindow(QDialog):
box.lineEdit().setPlaceholderText(placeholder) box.lineEdit().setPlaceholderText(placeholder)
return box 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 @staticmethod
def _row(*widgets): def _row(*widgets):
"""Widgets side by side in one form row; the first one takes the space.""" """Widgets side by side in one form row; the first one takes the space."""
@@ -919,10 +932,9 @@ class SettingsWindow(QDialog):
self.filter_hallucinations.setChecked(conf["filter_hallucinations"]) self.filter_hallucinations.setChecked(conf["filter_hallucinations"])
self.keep_audio.setChecked(conf["keep_audio"]) self.keep_audio.setChecked(conf["keep_audio"])
self.openai_key.setText(conf["openai_api_key"]) for name, who in cfg.TRANSCRIBERS.items():
self.openrouter_key.setText(conf["openrouter_api_key"]) self._key_fields[name].setText(conf[who.key])
self._models = {"openai": conf["transcribe_model"], self._models[name] = conf[who.model]
"openrouter": conf["openrouter_transcribe_model"]}
self._shown_provider = "" self._shown_provider = ""
self._select_data(self.transcribe_provider, conf["transcribe_provider"]) self._select_data(self.transcribe_provider, conf["transcribe_provider"])
self._provider_changed() # selecting index 0 fires no signal self._provider_changed() # selecting index 0 fires no signal
@@ -1000,15 +1012,12 @@ class SettingsWindow(QDialog):
conf["filter_hallucinations"] = self.filter_hallucinations.isChecked() conf["filter_hallucinations"] = self.filter_hallucinations.isChecked()
conf["keep_audio"] = self.keep_audio.isChecked() conf["keep_audio"] = self.keep_audio.isChecked()
conf["openai_api_key"] = self.openai_key.text().strip()
conf["openrouter_api_key"] = self.openrouter_key.text().strip()
provider = self.transcribe_provider.currentData() or "openai" provider = self.transcribe_provider.currentData() or "openai"
self._models[provider] = self.transcribe_model.currentText().strip() self._models[provider] = self.transcribe_model.currentText().strip()
conf["transcribe_provider"] = provider conf["transcribe_provider"] = provider
for key, name in (("openai", "transcribe_model"), for name, who in cfg.TRANSCRIBERS.items():
("openrouter", "openrouter_transcribe_model")): conf[who.key] = self._key_fields[name].text().strip()
conf[name] = self._models[key].strip() or cfg.DEFAULTS[name] conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model]
conf["cleanup_enabled"] = self.cleanup_enabled.isChecked() conf["cleanup_enabled"] = self.cleanup_enabled.isChecked()
conf["cleanup_model"] = self.cleanup_model.currentText().strip() conf["cleanup_model"] = self.cleanup_model.currentText().strip()
@@ -1107,15 +1116,14 @@ class SettingsWindow(QDialog):
provider = self.transcribe_provider.currentData() or "openai" provider = self.transcribe_provider.currentData() or "openai"
self.refresh_transcribe_models.setEnabled(False) self.refresh_transcribe_models.setEnabled(False)
self.transcribe_status.setText(t("Fetching model list…")) self.transcribe_status.setText(t("Fetching model list…"))
openai_key = self.openai_key.text().strip() or self.conf.openai_key() key, base = self._typed_key(provider)
openrouter_key = self.openrouter_key.text().strip() or self.conf.openrouter_key() service = cfg.TRANSCRIBERS[provider].service
base = self.conf["openai_base_url"]
def work(): def work():
try: try:
models = (api.openrouter_models(openrouter_key, transcription=True) models = (api.openrouter_models(key, transcription=True)
if provider == "openrouter" if provider == "openrouter"
else api.openai_models(openai_key, base)) else api.openai_models(key, base, service))
self._transcribe_models_loaded.emit(models, "") self._transcribe_models_loaded.emit(models, "")
except api.ApiError as exc: except api.ApiError as exc:
self._transcribe_models_loaded.emit([], str(exc)) self._transcribe_models_loaded.emit([], str(exc))
@@ -1159,42 +1167,51 @@ class SettingsWindow(QDialog):
self.models_label.setText(t("{count} models loaded.", count=len(models))) self.models_label.setText(t("{count} models loaded.", count=len(models)))
def _test_openai(self): def _test_openai(self):
self.test_button.setEnabled(False) key, base = self._typed_key("openai")
self.test_label.setText(t("Trying…")) self._test_key("openai", lambda: t(
key = self.openai_key.text().strip() or self.conf.openai_key() "Connection works. {count} audio models visible.",
base = self.conf["openai_base_url"] count=len(api.openai_models(key, base)),
))
def work(): def _test_groq(self):
try: key, base = self._typed_key("groq")
models = api.openai_models(key, base) self._test_key("groq", lambda: t(
self._test_done.emit( "Connection works. {count} audio models visible.",
True, t("Connection works. {count} audio models visible.", count=len(models)) count=len(api.openai_models(key, base, cfg.TRANSCRIBERS["groq"].service)),
) ))
except api.ApiError as exc:
self._test_done.emit(False, str(exc))
threading.Thread(target=work, daemon=True).start()
def _test_openrouter(self): def _test_openrouter(self):
self.or_test_button.setEnabled(False) key, _ = self._typed_key("openrouter")
self.or_test_label.setText(t("Trying…")) self._test_key("openrouter", lambda: api.openrouter_key_status(key))
key = self.openrouter_key.text().strip() or self.conf.openrouter_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(): def work():
try: try:
self._or_test_done.emit(True, api.openrouter_key_status(key)) self._test_done.emit(provider, True, ask())
except api.ApiError as exc: except api.ApiError as exc:
self._or_test_done.emit(False, str(exc)) self._test_done.emit(provider, False, str(exc))
threading.Thread(target=work, daemon=True).start() threading.Thread(target=work, daemon=True).start()
def _on_or_test_done(self, ok, message): def _on_test_done(self, provider, ok, message):
self.or_test_button.setEnabled(True) button, answer = self._testers[provider]
self.or_test_label.setText(("" if ok else "") + message) button.setEnabled(True)
answer.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)
# ---- audio file ------------------------------------------------------ # ---- audio file ------------------------------------------------------
+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 # 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 # back to it, and the tests for "there is no key" would pass only on a machine
# without one. # 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) os.environ.pop(_var, None)
# The interface language leaks through module-level state, so the tests fix it # 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") 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", OPENROUTER = api.Target("openrouter", "OpenRouter", "sk-or-test",
api.OPENROUTER_URL, "openai/gpt-4o-transcribe") api.OPENROUTER_URL, "openai/gpt-4o-transcribe")
@@ -32,6 +33,18 @@ class TimestampModel(unittest.TestCase):
def test_openrouter_namespaces_the_id(self): def test_openrouter_namespaces_the_id(self):
self.assertEqual(api.timestamp_model("openrouter"), "openai/whisper-1") 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): class Explain(DikteTest):
def error(self, status): def error(self, status):
@@ -176,13 +189,28 @@ class Transcribe(DikteTest):
self.assertEqual(multipart_fields(calls[0])["language"], "tr") self.assertEqual(multipart_fields(calls[0])["language"], "tr")
self.assertNotIn("language", multipart_fields(calls[1])) 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.""" """OpenRouter takes the field and throws it away, so spare it the bytes."""
with fake_urlopen({"text": "hi"}) as calls: with fake_urlopen({"text": "hi"}) as calls:
api.transcribe(OPENAI, self.wav, prompt="Paraşüt, OpenFrame") 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") api.transcribe(OPENROUTER, self.wav, prompt="Paraşüt, OpenFrame")
self.assertIn("prompt", multipart_fields(calls[0])) 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): def test_openrouter_is_attributed(self):
with fake_urlopen({"text": "hi"}) as calls: with fake_urlopen({"text": "hi"}) as calls:
@@ -242,6 +270,12 @@ class TranscribeSegments(DikteTest):
api.transcribe_segments(OPENROUTER, self.wav) api.transcribe_segments(OPENROUTER, self.wav)
self.assertEqual(multipart_fields(calls[0])["model"], "openai/whisper-1") 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): def test_the_segments_come_back_as_numbers(self):
with fake_urlopen(self.reply([ with fake_urlopen(self.reply([
{"start": "0.5", "end": "2.25", "text": " hello "}, {"start": "0.5", "end": "2.25", "text": " hello "},
@@ -432,6 +466,18 @@ class ModelLists(DikteTest):
with self.assertRaises(api.ApiError): with self.assertRaises(api.ApiError):
api.openai_models("") 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+34 -1
View File
@@ -15,7 +15,7 @@ from unittest import mock
import cli import cli
import config as cfg import config as cfg
import ipc import ipc
from tests.support import DikteTest from tests.support import DikteTest, fake_urlopen
class Options: class Options:
@@ -316,6 +316,39 @@ class ConfigCommands(DikteTest):
{"cleanup", "subtitles", "meeting", "agent"}) {"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): class Finding(DikteTest):
def test_no_history_at_all(self): def test_no_history_at_all(self):
self.assertIsNone(cli._find_history("last")) self.assertIsNone(cli._find_history("last"))
+19
View File
@@ -125,6 +125,10 @@ class Keys(DikteTest):
def test_no_key_anywhere(self): def test_no_key_anywhere(self):
self.assertEqual(cfg.Config().openai_key(), "") 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): class TranscribeTarget(DikteTest):
def test_openai_by_default(self): def test_openai_by_default(self):
@@ -145,6 +149,21 @@ class TranscribeTarget(DikteTest):
self.assertEqual(target.api_key, "sk-or-test") self.assertEqual(target.api_key, "sk-or-test")
self.assertEqual(target.model, "openai/whisper-1") 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): def test_a_self_hosted_endpoint(self):
conf = self.config(openai_base_url="http://localhost:8080/v1") conf = self.config(openai_base_url="http://localhost:8080/v1")
self.assertEqual(conf.transcribe_target().base_url, "http://localhost:8080/v1") self.assertEqual(conf.transcribe_target().base_url, "http://localhost:8080/v1")
+28 -2
View File
@@ -36,9 +36,11 @@ CHANGED = {
"filter_hallucinations": False, "filter_hallucinations": False,
"keep_audio": True, "keep_audio": True,
"openai_api_key": "sk-test-key", "openai_api_key": "sk-test-key",
"groq_api_key": "gsk-test-key",
"openrouter_api_key": "sk-or-test-key", "openrouter_api_key": "sk-or-test-key",
"transcribe_provider": "openrouter", "transcribe_provider": "openrouter",
"transcribe_model": "whisper-1", "transcribe_model": "whisper-1",
"groq_transcribe_model": "whisper-large-v3",
"openrouter_transcribe_model": "openai/whisper-1", "openrouter_transcribe_model": "openai/whisper-1",
"cleanup_enabled": False, "cleanup_enabled": False,
"cleanup_model": "some/other-model", "cleanup_model": "some/other-model",
@@ -145,14 +147,38 @@ class Settings(DikteTest):
def test_each_provider_keeps_its_own_transcription_model(self): def test_each_provider_keeps_its_own_transcription_model(self):
self.write_config({"transcribe_provider": "openai", self.write_config({"transcribe_provider": "openai",
"transcribe_model": "gpt-4o-transcribe", "transcribe_model": "gpt-4o-transcribe",
"groq_transcribe_model": "whisper-large-v3",
"openrouter_transcribe_model": "openai/whisper-1"}) "openrouter_transcribe_model": "openai/whisper-1"})
conf = cfg.Config() conf = cfg.Config()
window = self.window(conf) window = self.window(conf)
window.transcribe_provider.setCurrentIndex( for provider in ("groq", "openrouter"):
window.transcribe_provider.findData("openrouter")) window.transcribe_provider.setCurrentIndex(
window.transcribe_provider.findData(provider))
window._save() window._save()
self.assertEqual(conf["transcribe_provider"], "openrouter") self.assertEqual(conf["transcribe_provider"], "openrouter")
self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe") 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): def test_saving_applies_the_lowered_history_limit_at_once(self):
for index in range(10): for index in range(10):