diff --git a/README.md b/README.md index de518bd..22e246f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.tr.md b/README.tr.md index 3e8a877..4034a1f 100644 --- a/README.tr.md +++ b/README.tr.md @@ -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. diff --git a/api.py b/api.py index 95d794c..139ea33 100644 --- a/api.py +++ b/api.py @@ -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) diff --git a/config.py b/config.py index 396bd58..fc113e3 100644 --- a/config.py +++ b/config.py @@ -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"]) diff --git a/i18n.py b/i18n.py index 13ec3d2..73836ed 100644 --- a/i18n.py +++ b/i18n.py @@ -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…", diff --git a/settings_ui.py b/settings_ui.py index 9638ae8..9fbeda8 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -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): diff --git a/tests/test_groq.py b/tests/test_groq.py new file mode 100644 index 0000000..3f15f06 --- /dev/null +++ b/tests/test_groq.py @@ -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()