diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5894a68..88c772d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index 6be310e..c6f68ea 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,10 @@ sudo apt install pulseaudio-utils xclip xdotool ffmpeg `install.sh` adds the `dikte` command, a menu entry and an autostart entry. The settings window installs a GNOME or KDE global shortcut. -The settings window accepts **OpenAI**, **Groq** and **OpenRouter** keys. Speech -to text runs on any of them (`gpt-4o-transcribe` by default), cleanup always on -OpenRouter (`google/gemini-3.5-flash-lite`), so a single OpenRouter key can -cover both. They fall back to `OPENAI_API_KEY`, `GROQ_API_KEY` and +Three keys go in the settings window: **OpenAI**, **Groq** and **OpenRouter**. +Speech to text runs on any of them (`gpt-4o-transcribe` by default), cleanup +always on OpenRouter (`google/gemini-3.5-flash-lite`), so a single OpenRouter key +can cover both. They fall back to `OPENAI_API_KEY`, `GROQ_API_KEY` and `OPENROUTER_API_KEY`, and are stored in `~/.config/dikte/config.json`, mode 600. Cleanup can be switched off, in which case the raw transcript is pasted, and a thinking model's effort can be diff --git a/README.tr.md b/README.tr.md index 2bf0335..de07629 100644 --- a/README.tr.md +++ b/README.tr.md @@ -39,11 +39,12 @@ sudo apt install pulseaudio-utils xclip xdotool ffmpeg `install.sh` `dikte` komutunu, menü girdisini ve oturum açılışında otomatik başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar. -Ayarlar penceresinde **OpenAI**, **Groq** ve **OpenRouter** anahtarları bulunur. -Sesi yazıya çevirme bunlardan birinde çalışır (varsayılan `gpt-4o-transcribe`), +Ayarlar penceresinde üç anahtar istenir: **OpenAI**, **Groq** ve **OpenRouter**. +Sesi yazıya çevirme üçünden birinde çalışır (varsayılan `gpt-4o-transcribe`), temizleme her zaman OpenRouter'da (`google/gemini-3.5-flash-lite`), yani tek bir OpenRouter anahtarı ikisine de yeter. Boş bırakırsan `OPENAI_API_KEY`, -`GROQ_API_KEY` ve `OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json` +`GROQ_API_KEY` ve `OPENROUTER_API_KEY` kullanılır; +anahtarlar `~/.config/dikte/config.json` içinde, izinler 600. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin. diff --git a/api.py b/api.py index 139ea33..d022544 100644 --- a/api.py +++ b/api.py @@ -1,9 +1,9 @@ -"""OpenAI-compatible transcription and OpenRouter calls, stdlib only. +"""OpenAI, Groq and OpenRouter calls, stdlib only. -Transcription runs on either provider: OpenRouter mirrors OpenAI's +Transcription runs on any of the three: Groq and OpenRouter both mirror OpenAI's /audio/transcriptions endpoint field for field, so one multipart request serves -both and only the key, the base URL and the model id change. Cleanup is always -OpenRouter. +all of them and only the key, the base URL and the model id change. Cleanup is +always OpenRouter. """ import collections @@ -19,8 +19,8 @@ from i18n import t APP_URL = "https://github.com/yusufipk/dikte" USER_AGENT = f"dikte/1.0 (+{APP_URL})" OPENAI_URL = "https://api.openai.com/v1" -OPENROUTER_URL = "https://openrouter.ai/api/v1" GROQ_URL = "https://api.groq.com/openai/v1" +OPENROUTER_URL = "https://openrouter.ai/api/v1" # Where a transcription request goes; built by config.Config.transcribe_target(). # `service` is the name the user sees in an error, `provider` the one the code @@ -29,7 +29,12 @@ Target = collections.namedtuple("Target", "provider service api_key base_url mod def timestamp_model(provider, selected=""): - """Return a timestamp-capable model id for the selected provider.""" + """Which model answers with segment times. + + OpenAI keeps them to whisper-1 and OpenRouter namespaces that id. Everything + Groq transcribes with is a whisper, so the model already chosen does it and + the fallback is only for a provider left on its default. + """ if provider == "groq": return selected or "whisper-large-v3-turbo" return "openai/whisper-1" if provider == "openrouter" else "whisper-1" @@ -301,6 +306,11 @@ def openrouter_models(api_key="", transcription=False): def openai_models(api_key, base_url=OPENAI_URL, service="OpenAI"): + """The audio models of anything that speaks OpenAI's /models, Groq included. + + `service` is only the name an error is written in, so a Groq key that is + refused says Groq rather than OpenAI. + """ if not api_key: raise ApiError(t("{service} API key is empty. Add it in Settings.", service=service)) diff --git a/cli.py b/cli.py index facb667..b62047d 100644 --- a/cli.py +++ b/cli.py @@ -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) diff --git a/config.py b/config.py index fc113e3..1febe84 100644 --- a/config.py +++ b/config.py @@ -1,5 +1,6 @@ """Settings storage in ~/.config/dikte/config.json""" +import collections import hashlib import json import os @@ -367,7 +368,7 @@ DEFAULTS = { "groq_base_url": "https://api.groq.com/openai/v1", "openrouter_api_key": "", "openrouter_base_url": "https://openrouter.ai/api/v1", - "transcribe_provider": "openai", # openai | groq | openrouter + "transcribe_provider": "openai", # a key of TRANSCRIBERS "transcribe_model": "gpt-4o-transcribe", # used when provider is openai "groq_transcribe_model": "whisper-large-v3-turbo", "openrouter_transcribe_model": "openai/gpt-4o-transcribe", @@ -442,6 +443,22 @@ LEGACY_PROMPTS = { "154fc5aca1166f00eebda705f848f0391bfbf5fe", # 1.2 English } +# Every provider speech to text can run on, and the four settings that describe +# one. A fifth is a row here rather than another branch in transcribe_target(), +# another key row in the settings window and another line in save and load. The +# order is the order the provider box offers them in. `service` is the name the +# user sees; the environment variable that stands in for an empty key is the +# name of its setting, shouted. +Transcriber = collections.namedtuple("Transcriber", "service key url model") +TRANSCRIBERS = { + "openai": Transcriber("OpenAI", "openai_api_key", "openai_base_url", + "transcribe_model"), + "groq": Transcriber("Groq", "groq_api_key", "groq_base_url", + "groq_transcribe_model"), + "openrouter": Transcriber("OpenRouter", "openrouter_api_key", + "openrouter_base_url", "openrouter_transcribe_model"), +} + # Corners used to be stored with Turkish names. _CORNER_MIGRATION = { "sol-alt": "bottom-left", "sağ-alt": "bottom-right", @@ -490,28 +507,27 @@ class Config: def get(self, key, default=None): return self.data.get(key, DEFAULTS.get(key, default)) - def openai_key(self): - """Fall back to the environment when no key is stored.""" - return self["openai_api_key"].strip() or os.environ.get("OPENAI_API_KEY", "").strip() + def api_key(self, setting): + """A stored key, or the environment variable that shares its name.""" + return self[setting].strip() or os.environ.get(setting.upper(), "").strip() - def openrouter_key(self): - return self["openrouter_api_key"].strip() or os.environ.get("OPENROUTER_API_KEY", "").strip() + def openai_key(self): + return self.api_key("openai_api_key") def groq_key(self): - return self["groq_api_key"].strip() or os.environ.get("GROQ_API_KEY", "").strip() + return self.api_key("groq_api_key") + + def openrouter_key(self): + return self.api_key("openrouter_api_key") def transcribe_target(self): """Key, endpoint and model for whichever provider does speech to text.""" - if self["transcribe_provider"] == "openrouter": - return api.Target("openrouter", "OpenRouter", self.openrouter_key(), - self["openrouter_base_url"], - self["openrouter_transcribe_model"]) - if self["transcribe_provider"] == "groq": - return api.Target("groq", "Groq", self.groq_key(), - self["groq_base_url"], - self["groq_transcribe_model"]) - return api.Target("openai", "OpenAI", self.openai_key(), - self["openai_base_url"], self["transcribe_model"]) + name = self["transcribe_provider"] + if name not in TRANSCRIBERS: + name = DEFAULTS["transcribe_provider"] + who = TRANSCRIBERS[name] + return api.Target(name, who.service, self.api_key(who.key), + self[who.url], self[who.model]) def cleanup_prompt(self, with_timestamps=False, with_speakers=False, subtitles=False): diff --git a/settings_ui.py b/settings_ui.py index 9fbeda8..2bf1a9e 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -29,9 +29,8 @@ LANGUAGES = [ ("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"), ] CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"] -TRANSCRIBE_PROVIDERS = [ - ("OpenAI", "openai"), ("Groq", "groq"), ("OpenRouter", "openrouter"), -] +# The provider box offers what config knows how to reach, in that order. +TRANSCRIBE_PROVIDERS = [(who.service, name) for name, who in cfg.TRANSCRIBERS.items()] # Starting points for the model box; "Fetch model list" replaces them with # whatever the provider offers today. TRANSCRIBE_MODELS = { @@ -111,9 +110,8 @@ class SettingsWindow(QDialog): _models_loaded = pyqtSignal(list, str) _transcribe_models_loaded = pyqtSignal(list, str) - _test_done = pyqtSignal(bool, str) - _groq_test_done = pyqtSignal(bool, str) - _or_test_done = pyqtSignal(bool, str) + # Which key was tested, whether it worked, and what to write under it. + _test_done = pyqtSignal(str, bool, str) def __init__(self, conf, launch_command, meeting_command=None, meetings=None, ask_command=None, parent=None): @@ -125,7 +123,9 @@ class SettingsWindow(QDialog): self.meetings = meetings # Each provider keeps its own transcription model, so switching the # provider back and forth never overwrites the other one's. - self._models = {"openai": "", "groq": "", "openrouter": ""} + self._models = dict.fromkeys(cfg.TRANSCRIBERS, "") + self._key_fields = {} + self._testers = {} self._shown_provider = "" self.transcriber = FileTranscriber(conf, self) self.setWindowTitle(t("Dikte Settings")) @@ -156,8 +156,6 @@ class SettingsWindow(QDialog): self._models_loaded.connect(self._on_models_loaded) self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded) self._test_done.connect(self._on_test_done) - self._groq_test_done.connect(self._on_groq_test_done) - self._or_test_done.connect(self._on_or_test_done) self.transcriber.progress.connect(self._on_file_progress) self.transcriber.finished.connect(self._on_file_finished) self.transcriber.failed.connect(self._on_file_failed) @@ -249,35 +247,15 @@ class SettingsWindow(QDialog): # them and a key no longer belongs to a single job. keys = QGroupBox(t("Keys")) keys_form = QFormLayout(keys) - self.openai_key = QLineEdit() - self.openai_key.setEchoMode(QLineEdit.EchoMode.Password) - self.openai_key.setPlaceholderText(t("sk-… (falls back to OPENAI_API_KEY)")) - self.test_button = QPushButton(t("Test")) - self.test_button.clicked.connect(self._test_openai) - self.test_label = QLabel("") - self.test_label.setWordWrap(True) - keys_form.addRow("OpenAI", self._row(self.openai_key, self.test_button)) - keys_form.addRow("", self.test_label) - - self.groq_key = QLineEdit() - self.groq_key.setEchoMode(QLineEdit.EchoMode.Password) - self.groq_key.setPlaceholderText(t("gsk_… (falls back to GROQ_API_KEY)")) - self.groq_test_button = QPushButton(t("Test")) - self.groq_test_button.clicked.connect(self._test_groq) - self.groq_test_label = QLabel("") - self.groq_test_label.setWordWrap(True) - keys_form.addRow("Groq", self._row(self.groq_key, self.groq_test_button)) - keys_form.addRow("", self.groq_test_label) - - self.openrouter_key = QLineEdit() - self.openrouter_key.setEchoMode(QLineEdit.EchoMode.Password) - self.openrouter_key.setPlaceholderText(t("sk-or-… (falls back to OPENROUTER_API_KEY)")) - self.or_test_button = QPushButton(t("Test")) - self.or_test_button.clicked.connect(self._test_openrouter) - self.or_test_label = QLabel("") - self.or_test_label.setWordWrap(True) - keys_form.addRow("OpenRouter", self._row(self.openrouter_key, self.or_test_button)) - keys_form.addRow("", self.or_test_label) + self.openai_key = self._key_row( + keys_form, "openai", t("sk-… (falls back to OPENAI_API_KEY)"), + self._test_openai) + self.groq_key = self._key_row( + keys_form, "groq", t("gsk_… (falls back to GROQ_API_KEY)"), + self._test_groq) + self.openrouter_key = self._key_row( + keys_form, "openrouter", t("sk-or-… (falls back to OPENROUTER_API_KEY)"), + self._test_openrouter) outer.addWidget(keys) stt = QGroupBox(t("Speech to text")) @@ -383,7 +361,7 @@ class SettingsWindow(QDialog): how = QGroupBox(t("How it runs")) how_form = QFormLayout(how) self.assistant_shortcut = self._shortcut_box(t("none")) - install = QPushButton(t("Install as a global shortcut")) + install = QPushButton(t("Install as a KDE shortcut")) install.clicked.connect(self._install_ask_shortcut) remove = QPushButton(t("Remove")) remove.clicked.connect(self._remove_ask_shortcut) @@ -645,7 +623,7 @@ class SettingsWindow(QDialog): recording_form.addRow("", self.meeting_keep_audio) self.meeting_shortcut = self._shortcut_box(t("none")) - install = QPushButton(t("Install as a global shortcut")) + install = QPushButton(t("Install as a KDE shortcut")) install.clicked.connect(self._install_meeting_shortcut) remove = QPushButton(t("Remove")) remove.clicked.connect(self._remove_meeting_shortcut) @@ -799,7 +777,7 @@ class SettingsWindow(QDialog): form.addRow(t("Shortcut"), self.shortcut) layout.addLayout(form) - install = QPushButton(t("Install as a global shortcut")) + install = QPushButton(t("Install as a KDE shortcut")) install.clicked.connect(self._install_shortcut) remove = QPushButton(t("Remove")) remove.clicked.connect(self._remove_shortcut) @@ -906,6 +884,26 @@ class SettingsWindow(QDialog): box.lineEdit().setPlaceholderText(placeholder) return box + def _key_row(self, form, provider, placeholder, tester): + """A key field, its Test button and the line the answer lands on. + + The field and the pair the answer needs are filed under the provider's + name, so saving, loading and the test handler find them by name rather + than through three attributes each. + """ + field = QLineEdit() + field.setEchoMode(QLineEdit.EchoMode.Password) + field.setPlaceholderText(placeholder) + button = QPushButton(t("Test")) + button.clicked.connect(tester) + answer = QLabel("") + answer.setWordWrap(True) + form.addRow(cfg.TRANSCRIBERS[provider].service, self._row(field, button)) + form.addRow("", answer) + self._key_fields[provider] = field + self._testers[provider] = (button, answer) + return field + @staticmethod def _row(*widgets): """Widgets side by side in one form row; the first one takes the space.""" @@ -934,12 +932,9 @@ class SettingsWindow(QDialog): self.filter_hallucinations.setChecked(conf["filter_hallucinations"]) self.keep_audio.setChecked(conf["keep_audio"]) - self.openai_key.setText(conf["openai_api_key"]) - self.groq_key.setText(conf["groq_api_key"]) - self.openrouter_key.setText(conf["openrouter_api_key"]) - self._models = {"openai": conf["transcribe_model"], - "groq": conf["groq_transcribe_model"], - "openrouter": conf["openrouter_transcribe_model"]} + for name, who in cfg.TRANSCRIBERS.items(): + self._key_fields[name].setText(conf[who.key]) + self._models[name] = conf[who.model] self._shown_provider = "" self._select_data(self.transcribe_provider, conf["transcribe_provider"]) self._provider_changed() # selecting index 0 fires no signal @@ -1017,17 +1012,12 @@ class SettingsWindow(QDialog): conf["filter_hallucinations"] = self.filter_hallucinations.isChecked() conf["keep_audio"] = self.keep_audio.isChecked() - conf["openai_api_key"] = self.openai_key.text().strip() - conf["groq_api_key"] = self.groq_key.text().strip() - conf["openrouter_api_key"] = self.openrouter_key.text().strip() - provider = self.transcribe_provider.currentData() or "openai" self._models[provider] = self.transcribe_model.currentText().strip() conf["transcribe_provider"] = provider - for key, name in (("openai", "transcribe_model"), - ("groq", "groq_transcribe_model"), - ("openrouter", "openrouter_transcribe_model")): - conf[name] = self._models[key].strip() or cfg.DEFAULTS[name] + for name, who in cfg.TRANSCRIBERS.items(): + conf[who.key] = self._key_fields[name].text().strip() + conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model] conf["cleanup_enabled"] = self.cleanup_enabled.isChecked() conf["cleanup_model"] = self.cleanup_model.currentText().strip() @@ -1126,22 +1116,14 @@ class SettingsWindow(QDialog): provider = self.transcribe_provider.currentData() or "openai" self.refresh_transcribe_models.setEnabled(False) self.transcribe_status.setText(t("Fetching model list…")) - openai_key = self.openai_key.text().strip() or self.conf.openai_key() - groq_key = self.groq_key.text().strip() or self.conf.groq_key() - openrouter_key = self.openrouter_key.text().strip() or self.conf.openrouter_key() + key, base = self._typed_key(provider) + service = cfg.TRANSCRIBERS[provider].service def work(): try: - if provider == "openrouter": - models = api.openrouter_models(openrouter_key, transcription=True) - elif provider == "groq": - models = api.openai_models( - groq_key, self.conf["groq_base_url"], "Groq" - ) - else: - models = api.openai_models( - openai_key, self.conf["openai_base_url"] - ) + models = (api.openrouter_models(key, transcription=True) + if provider == "openrouter" + else api.openai_models(key, base, service)) self._transcribe_models_loaded.emit(models, "") except api.ApiError as exc: self._transcribe_models_loaded.emit([], str(exc)) @@ -1185,65 +1167,51 @@ class SettingsWindow(QDialog): self.models_label.setText(t("{count} models loaded.", count=len(models))) def _test_openai(self): - self.test_button.setEnabled(False) - self.test_label.setText(t("Trying…")) - key = self.openai_key.text().strip() or self.conf.openai_key() - base = self.conf["openai_base_url"] - - def work(): - try: - models = api.openai_models(key, base) - self._test_done.emit( - True, t("Connection works. {count} audio models visible.", count=len(models)) - ) - except api.ApiError as exc: - self._test_done.emit(False, str(exc)) - - threading.Thread(target=work, daemon=True).start() - - def _test_openrouter(self): - self.or_test_button.setEnabled(False) - self.or_test_label.setText(t("Trying…")) - key = self.openrouter_key.text().strip() or self.conf.openrouter_key() - - def work(): - try: - self._or_test_done.emit(True, api.openrouter_key_status(key)) - except api.ApiError as exc: - self._or_test_done.emit(False, str(exc)) - - threading.Thread(target=work, daemon=True).start() + key, base = self._typed_key("openai") + self._test_key("openai", lambda: t( + "Connection works. {count} audio models visible.", + count=len(api.openai_models(key, base)), + )) def _test_groq(self): - self.groq_test_button.setEnabled(False) - self.groq_test_label.setText(t("Trying…")) - key = self.groq_key.text().strip() or self.conf.groq_key() + key, base = self._typed_key("groq") + self._test_key("groq", lambda: t( + "Connection works. {count} audio models visible.", + count=len(api.openai_models(key, base, cfg.TRANSCRIBERS["groq"].service)), + )) + + def _test_openrouter(self): + key, _ = self._typed_key("openrouter") + self._test_key("openrouter", lambda: api.openrouter_key_status(key)) + + def _typed_key(self, provider): + """(key, base URL) for a provider, preferring what is in the field now.""" + who = cfg.TRANSCRIBERS[provider] + typed = self._key_fields[provider].text().strip() + return typed or self.conf.api_key(who.key), self.conf[who.url] + + def _test_key(self, provider, ask): + """Run `ask` off the interface thread and write its answer under the key. + + `ask` returns the line to show, or raises ApiError with the line to show + instead; either way it is read from a field before the thread starts. + """ + button, answer = self._testers[provider] + button.setEnabled(False) + answer.setText(t("Trying…")) def work(): try: - models = api.openai_models( - key, self.conf["groq_base_url"], "Groq" - ) - self._groq_test_done.emit( - True, t("Connection works. {count} audio models visible.", - count=len(models)) - ) + self._test_done.emit(provider, True, ask()) except api.ApiError as exc: - self._groq_test_done.emit(False, str(exc)) + self._test_done.emit(provider, False, str(exc)) threading.Thread(target=work, daemon=True).start() - def _on_or_test_done(self, ok, message): - self.or_test_button.setEnabled(True) - self.or_test_label.setText(("✓ " if ok else "✗ ") + message) - - def _on_test_done(self, ok, message): - self.test_button.setEnabled(True) - self.test_label.setText(("✓ " if ok else "✗ ") + message) - - def _on_groq_test_done(self, ok, message): - self.groq_test_button.setEnabled(True) - self.groq_test_label.setText(("✓ " if ok else "✗ ") + message) + def _on_test_done(self, provider, ok, message): + button, answer = self._testers[provider] + button.setEnabled(True) + answer.setText(("✓ " if ok else "✗ ") + message) # ---- audio file ------------------------------------------------------ @@ -1329,7 +1297,7 @@ class SettingsWindow(QDialog): def _install_shortcut(self): combo = self.shortcut.currentText().strip() or "Ctrl+Space" - clashes = hotkey.conflicting_shortcuts(combo) if hotkey.desktop_name() == "KDE" else [] + clashes = hotkey.conflicting_shortcuts(combo) if clashes: answer = QMessageBox.question( self, t("Shortcut conflict"), diff --git a/tests/__init__.py b/tests/__init__.py index d422cec..e83e504 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py index 30e7e3e..2a8730e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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() diff --git a/tests/test_cli.py b/tests/test_cli.py index 98145c3..9a11a46 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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")) diff --git a/tests/test_config.py b/tests/test_config.py index 7120a0c..fa7c2c4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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") diff --git a/tests/test_groq.py b/tests/test_groq.py deleted file mode 100644 index 3f15f06..0000000 --- a/tests/test_groq.py +++ /dev/null @@ -1,37 +0,0 @@ -import json -import pathlib -import tempfile -import unittest -from unittest import mock - -import api -import config -import settings_ui - - -class GroqCompatibilityTests(unittest.TestCase): - def test_old_config_gains_groq_defaults(self): - with tempfile.TemporaryDirectory() as directory: - path = pathlib.Path(directory) / "config.json" - path.write_text(json.dumps({"transcribe_provider": "openai"}), encoding="utf-8") - with mock.patch.object(config, "CONFIG_FILE", path): - conf = config.Config() - self.assertEqual(conf["groq_base_url"], api.GROQ_URL) - self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3-turbo") - - def test_groq_target_and_timestamp_model(self): - with mock.patch.object(config.Config, "load"): - conf = config.Config() - conf["transcribe_provider"] = "groq" - conf["groq_api_key"] = "test-key" - target = conf.transcribe_target() - self.assertEqual(target.provider, "groq") - self.assertEqual(target.base_url, api.GROQ_URL) - self.assertEqual(api.timestamp_model(target.provider, target.model), target.model) - - def test_groq_is_available_in_settings(self): - self.assertIn(("Groq", "groq"), settings_ui.TRANSCRIBE_PROVIDERS) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_ui.py b/tests/test_ui.py index a616e94..5ca37b2 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -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):