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
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
+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
settings window installs a GNOME or KDE global shortcut.
Two keys go in the settings window: **OpenAI** and **OpenRouter**. Speech to text
runs on either one (`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` and `OPENROUTER_API_KEY`, and are
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
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
başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar.
Ayarlar penceresinde iki anahtar istenir: **OpenAI** ve **OpenRouter**. Sesi
yazıya çevirme ikisinden 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` ve
`OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json`
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`
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.
+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
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,6 +19,7 @@ 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"
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().
@@ -27,8 +28,15 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1"
Target = collections.namedtuple("Target", "provider service api_key base_url model")
def timestamp_model(provider):
"""Only whisper-1 returns segment times, and OpenRouter namespaces the id."""
def timestamp_model(provider, selected=""):
"""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"
@@ -126,7 +134,7 @@ def _transcribe_request(target, wav_path, language, prompt, response_format,
fields.append(("language", language))
# 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.
if prompt and target.provider == "openai":
if prompt and target.provider != "openrouter":
fields.append(("prompt", prompt))
if 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):
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
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",
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"))
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:
raise ApiError(t("{service} API key is empty. Add it in Settings.",
service="OpenAI"))
service=service))
try:
data = _get_json(
f"{base_url.rstrip('/')}/models",
{"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT},
)
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")]
audio = [i for i in ids if "transcribe" in i or "whisper" in i]
return sorted(audio or ids)
+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)
+36 -10
View File
@@ -1,5 +1,6 @@
"""Settings storage in ~/.config/dikte/config.json"""
import collections
import hashlib
import json
import os
@@ -363,10 +364,13 @@ DEFAULTS = {
"ui_language": "auto", # auto | tr | en
"openai_api_key": "",
"openai_base_url": "https://api.openai.com/v1",
"groq_api_key": "",
"groq_base_url": "https://api.groq.com/openai/v1",
"openrouter_api_key": "",
"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
"groq_transcribe_model": "whisper-large-v3-turbo",
"openrouter_transcribe_model": "openai/gpt-4o-transcribe",
"language": "tr",
"transcribe_prompt": "",
@@ -439,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",
@@ -487,21 +507,27 @@ class Config:
def get(self, key, default=None):
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):
"""Fall back to the environment when no key is stored."""
return self["openai_api_key"].strip() or os.environ.get("OPENAI_API_KEY", "").strip()
return self.api_key("openai_api_key")
def groq_key(self):
return self.api_key("groq_api_key")
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):
"""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"])
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):
+1
View File
@@ -177,6 +177,7 @@ TR = {
"Model": "Model",
"Provider": "Sağlayıcı",
"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)",
"Test": "Test et",
"Trying…": "Deneniyor…",
+82 -65
View File
@@ -29,11 +29,13 @@ LANGUAGES = [
("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"),
]
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
# whatever the provider offers today.
TRANSCRIBE_MODELS = {
"openai": ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"],
"groq": ["whisper-large-v3-turbo", "whisper-large-v3"],
"openrouter": [
"openai/gpt-4o-transcribe", "openai/gpt-4o-mini-transcribe",
"openai/whisper-1", "openai/whisper-large-v3",
@@ -108,8 +110,8 @@ class SettingsWindow(QDialog):
_models_loaded = pyqtSignal(list, str)
_transcribe_models_loaded = pyqtSignal(list, str)
_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):
@@ -121,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": "", "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"))
@@ -152,7 +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._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)
@@ -244,25 +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.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"))
@@ -891,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."""
@@ -919,10 +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.openrouter_key.setText(conf["openrouter_api_key"])
self._models = {"openai": conf["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
@@ -1000,15 +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["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"),
("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()
@@ -1107,15 +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()
openrouter_key = self.openrouter_key.text().strip() or self.conf.openrouter_key()
base = self.conf["openai_base_url"]
key, base = self._typed_key(provider)
service = cfg.TRANSCRIBERS[provider].service
def work():
try:
models = (api.openrouter_models(openrouter_key, transcription=True)
models = (api.openrouter_models(key, transcription=True)
if provider == "openrouter"
else api.openai_models(openai_key, base))
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))
@@ -1159,42 +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"]
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 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_groq(self):
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):
self.or_test_button.setEnabled(False)
self.or_test_label.setText(t("Trying…"))
key = self.openrouter_key.text().strip() or self.conf.openrouter_key()
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:
self._or_test_done.emit(True, api.openrouter_key_status(key))
self._test_done.emit(provider, True, ask())
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()
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_test_done(self, provider, ok, message):
button, answer = self._testers[provider]
button.setEnabled(True)
answer.setText(("" if ok else "") + message)
# ---- 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
# 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")
+28 -2
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)
window.transcribe_provider.setCurrentIndex(
window.transcribe_provider.findData("openrouter"))
for provider in ("groq", "openrouter"):
window.transcribe_provider.setCurrentIndex(
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):