Add Groq transcription support

This commit is contained in:
Muzaffer Emre
2026-07-31 18:48:21 +03:00
parent da3dc3c908
commit dd73735617
7 changed files with 145 additions and 41 deletions
+4 -3
View File
@@ -32,10 +32,11 @@ dikte # the settings window opens on first run
`install.sh` adds the `dikte` command, a menu entry, an autostart entry and the
KDE 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
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` and `OPENROUTER_API_KEY`, and are
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.
+4 -4
View File
@@ -32,11 +32,11 @@ dikte # ilk açılışta ayarlar penceresi gelir
`install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik
başlatmayı ve KDE 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 **OpenAI**, **Groq** ve **OpenRouter** anahtarları bulunur.
Sesi yazıya çevirme bunlardan 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.
+11 -8
View File
@@ -1,4 +1,4 @@
"""OpenAI and OpenRouter calls, stdlib only.
"""OpenAI-compatible transcription and OpenRouter calls, stdlib only.
Transcription runs on either provider: OpenRouter mirrors OpenAI's
/audio/transcriptions endpoint field for field, so one multipart request serves
@@ -20,6 +20,7 @@ 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"
# 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
@@ -27,8 +28,10 @@ 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=""):
"""Return a timestamp-capable model id for the selected provider."""
if provider == "groq":
return selected or "whisper-large-v3-turbo"
return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
@@ -126,7 +129,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 +156,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 +300,17 @@ 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"):
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)
+11 -1
View File
@@ -363,10 +363,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", # openai | groq | openrouter
"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": "",
@@ -494,12 +497,19 @@ class Config:
def openrouter_key(self):
return self["openrouter_api_key"].strip() or os.environ.get("OPENROUTER_API_KEY", "").strip()
def groq_key(self):
return self["groq_api_key"].strip() or os.environ.get("GROQ_API_KEY", "").strip()
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"])
+1
View File
@@ -171,6 +171,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…",
+77 -25
View File
@@ -29,11 +29,14 @@ LANGUAGES = [
("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"),
]
CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"]
TRANSCRIBE_PROVIDERS = [("OpenAI", "openai"), ("OpenRouter", "openrouter")]
TRANSCRIBE_PROVIDERS = [
("OpenAI", "openai"), ("Groq", "groq"), ("OpenRouter", "openrouter"),
]
# 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",
@@ -109,6 +112,7 @@ 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)
def __init__(self, conf, launch_command, meeting_command=None,
@@ -121,7 +125,7 @@ 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 = {"openai": "", "groq": "", "openrouter": ""}
self._shown_provider = ""
self.transcriber = FileTranscriber(conf, self)
self.setWindowTitle(t("Dikte Settings"))
@@ -152,6 +156,7 @@ 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)
@@ -254,6 +259,16 @@ class SettingsWindow(QDialog):
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)"))
@@ -368,7 +383,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 KDE shortcut"))
install = QPushButton(t("Install as a global shortcut"))
install.clicked.connect(self._install_ask_shortcut)
remove = QPushButton(t("Remove"))
remove.clicked.connect(self._remove_ask_shortcut)
@@ -630,7 +645,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 KDE shortcut"))
install = QPushButton(t("Install as a global shortcut"))
install.clicked.connect(self._install_meeting_shortcut)
remove = QPushButton(t("Remove"))
remove.clicked.connect(self._remove_meeting_shortcut)
@@ -784,7 +799,7 @@ class SettingsWindow(QDialog):
form.addRow(t("Shortcut"), self.shortcut)
layout.addLayout(form)
install = QPushButton(t("Install as a KDE shortcut"))
install = QPushButton(t("Install as a global shortcut"))
install.clicked.connect(self._install_shortcut)
remove = QPushButton(t("Remove"))
remove.clicked.connect(self._remove_shortcut)
@@ -920,8 +935,10 @@ class SettingsWindow(QDialog):
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"]}
self._shown_provider = ""
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
@@ -1001,12 +1018,14 @@ class SettingsWindow(QDialog):
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]
@@ -1108,14 +1127,21 @@ class SettingsWindow(QDialog):
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()
base = self.conf["openai_base_url"]
def work():
try:
models = (api.openrouter_models(openrouter_key, transcription=True)
if provider == "openrouter"
else api.openai_models(openai_key, base))
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"]
)
self._transcribe_models_loaded.emit(models, "")
except api.ApiError as exc:
self._transcribe_models_loaded.emit([], str(exc))
@@ -1188,6 +1214,25 @@ class SettingsWindow(QDialog):
threading.Thread(target=work, daemon=True).start()
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()
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))
)
except api.ApiError as exc:
self._groq_test_done.emit(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)
@@ -1196,6 +1241,10 @@ class SettingsWindow(QDialog):
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)
# ---- audio file ------------------------------------------------------
def _choose_file(self):
@@ -1280,7 +1329,7 @@ class SettingsWindow(QDialog):
def _install_shortcut(self):
combo = self.shortcut.currentText().strip() or "Ctrl+Space"
clashes = hotkey.conflicting_shortcuts(combo)
clashes = hotkey.conflicting_shortcuts(combo) if hotkey.desktop_name() == "KDE" else []
if clashes:
answer = QMessageBox.question(
self, t("Shortcut conflict"),
@@ -1289,7 +1338,7 @@ class SettingsWindow(QDialog):
)
if answer != QMessageBox.StandardButton.Yes:
return
ok, message = hotkey.install_kde_shortcut(combo, self.launch_command)
ok, message = hotkey.install_shortcut(combo, self.launch_command)
QMessageBox.information(self, t("Shortcut"), message)
if ok:
self.conf["shortcut"] = combo
@@ -1297,14 +1346,15 @@ class SettingsWindow(QDialog):
self._refresh_shortcut_status()
def _remove_shortcut(self):
hotkey.remove_kde_shortcut()
hotkey.remove_shortcut()
self._refresh_shortcut_status()
def _refresh_shortcut_status(self):
current = hotkey.kde_shortcut_status()
current = hotkey.shortcut_status()
self.shortcut_status.setText(
t("Registered in KDE: {shortcut}", shortcut=current) if current
else t("No KDE shortcut installed.")
t("Registered in {desktop}: {shortcut}",
desktop=hotkey.desktop_name(), shortcut=current) if current
else t("No global shortcut installed.")
)
def _install_meeting_shortcut(self):
@@ -1322,7 +1372,7 @@ class SettingsWindow(QDialog):
)
if answer != QMessageBox.StandardButton.Yes:
return
ok, message = hotkey.install_kde_shortcut(
ok, message = hotkey.install_shortcut(
combo, self.meeting_command, name="Dikte: start/end a meeting recording",
desktop_id=hotkey.MEETING_DESKTOP_ID,
)
@@ -1333,14 +1383,15 @@ class SettingsWindow(QDialog):
self._refresh_meeting_shortcut_status()
def _remove_meeting_shortcut(self):
hotkey.remove_kde_shortcut(hotkey.MEETING_DESKTOP_ID)
hotkey.remove_shortcut(hotkey.MEETING_DESKTOP_ID)
self._refresh_meeting_shortcut_status()
def _refresh_meeting_shortcut_status(self):
current = hotkey.kde_shortcut_status(hotkey.MEETING_DESKTOP_ID)
current = hotkey.shortcut_status(hotkey.MEETING_DESKTOP_ID)
self.meeting_shortcut_status.setText(
t("Registered in KDE: {shortcut}", shortcut=current) if current
else t("No KDE shortcut installed. The tray menu starts a meeting too.")
t("Registered in {desktop}: {shortcut}",
desktop=hotkey.desktop_name(), shortcut=current) if current
else t("No global shortcut installed. The tray menu starts a meeting too.")
)
# ---- Claude ----------------------------------------------------------
@@ -1360,7 +1411,7 @@ class SettingsWindow(QDialog):
)
if answer != QMessageBox.StandardButton.Yes:
return
ok, message = hotkey.install_kde_shortcut(
ok, message = hotkey.install_shortcut(
combo, self.ask_command, name="Dikte: ask Claude Code",
desktop_id=hotkey.ASK_DESKTOP_ID,
)
@@ -1371,14 +1422,15 @@ class SettingsWindow(QDialog):
self._refresh_ask_shortcut_status()
def _remove_ask_shortcut(self):
hotkey.remove_kde_shortcut(hotkey.ASK_DESKTOP_ID)
hotkey.remove_shortcut(hotkey.ASK_DESKTOP_ID)
self._refresh_ask_shortcut_status()
def _refresh_ask_shortcut_status(self):
current = hotkey.kde_shortcut_status(hotkey.ASK_DESKTOP_ID)
current = hotkey.shortcut_status(hotkey.ASK_DESKTOP_ID)
self.assistant_shortcut_status.setText(
t("Registered in KDE: {shortcut}", shortcut=current) if current
else t("No KDE shortcut installed. The tray menu asks it too.")
t("Registered in {desktop}: {shortcut}",
desktop=hotkey.desktop_name(), shortcut=current) if current
else t("No global shortcut installed. The tray menu asks it too.")
)
def _assistant_provider_changed(self):
+37
View File
@@ -0,0 +1,37 @@
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()