mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Merge pull request #6 from muzafferemre06/feature/groq-transcription
Add Groq as a transcription provider
This commit is contained in:
+4
-4
@@ -79,10 +79,10 @@ next port breaks all of them.
|
|||||||
|
|
||||||
## What a pull request should carry
|
## What a pull request should carry
|
||||||
|
|
||||||
A change to behaviour comes with a test for it. Adding a provider means a test
|
A change to behaviour comes with a test for it. Adding a provider means a row in
|
||||||
that the request goes to the right URL with the right fields; adding a platform
|
`config.TRANSCRIBERS` and a test that the request goes to the right URL with the
|
||||||
means a test for whatever the parsing of its device list, clipboard or shortcuts
|
right fields; adding a platform means a test for whatever the parsing of its
|
||||||
looks like. Adding a setting means both halves of `settings_ui.py`: the round
|
device list, clipboard or shortcuts looks like. Adding a setting means both halves of `settings_ui.py`: the round
|
||||||
trip in `tests/test_ui.py` is what catches only one of them being written.
|
trip in `tests/test_ui.py` is what catches only one of them being written.
|
||||||
|
|
||||||
Match the surrounding code: it is plain Python with no framework, comments
|
Match the surrounding code: it is plain Python with no framework, comments
|
||||||
|
|||||||
@@ -39,10 +39,11 @@ sudo apt install pulseaudio-utils xclip xdotool ffmpeg
|
|||||||
`install.sh` adds the `dikte` command, a menu entry and an autostart entry. The
|
`install.sh` adds the `dikte` command, a menu entry and an autostart entry. The
|
||||||
settings window installs a GNOME or KDE global shortcut.
|
settings window installs a GNOME or KDE global shortcut.
|
||||||
|
|
||||||
Two keys go in the settings window: **OpenAI** and **OpenRouter**. Speech to text
|
Three keys go in the settings window: **OpenAI**, **Groq** and **OpenRouter**.
|
||||||
runs on either one (`gpt-4o-transcribe` by default), cleanup always on
|
Speech to text runs on any of them (`gpt-4o-transcribe` by default), cleanup
|
||||||
OpenRouter (`google/gemini-3.5-flash-lite`), so a single OpenRouter key can
|
always on OpenRouter (`google/gemini-3.5-flash-lite`), so a single OpenRouter key
|
||||||
cover both. They fall back to `OPENAI_API_KEY` and `OPENROUTER_API_KEY`, and are
|
can cover both. They fall back to `OPENAI_API_KEY`, `GROQ_API_KEY` and
|
||||||
|
`OPENROUTER_API_KEY`, and are
|
||||||
stored in `~/.config/dikte/config.json`, mode 600. Cleanup can be switched off,
|
stored in `~/.config/dikte/config.json`, mode 600. Cleanup can be switched off,
|
||||||
in which case the raw transcript is pasted, and a thinking model's effort can be
|
in which case the raw transcript is pasted, and a thinking model's effort can be
|
||||||
set next to it.
|
set next to it.
|
||||||
|
|||||||
+5
-4
@@ -39,11 +39,12 @@ sudo apt install pulseaudio-utils xclip xdotool ffmpeg
|
|||||||
`install.sh` `dikte` komutunu, menü girdisini ve oturum açılışında otomatik
|
`install.sh` `dikte` komutunu, menü girdisini ve oturum açılışında otomatik
|
||||||
başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar.
|
başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar.
|
||||||
|
|
||||||
Ayarlar penceresinde iki anahtar istenir: **OpenAI** ve **OpenRouter**. Sesi
|
Ayarlar penceresinde üç anahtar istenir: **OpenAI**, **Groq** ve **OpenRouter**.
|
||||||
yazıya çevirme ikisinden birinde çalışır (varsayılan `gpt-4o-transcribe`),
|
Sesi yazıya çevirme üçünden birinde çalışır (varsayılan `gpt-4o-transcribe`),
|
||||||
temizleme her zaman OpenRouter'da (`google/gemini-3.5-flash-lite`), yani tek bir
|
temizleme her zaman OpenRouter'da (`google/gemini-3.5-flash-lite`), yani tek bir
|
||||||
OpenRouter anahtarı ikisine de yeter. Boş bırakırsan `OPENAI_API_KEY` ve
|
OpenRouter anahtarı ikisine de yeter. Boş bırakırsan `OPENAI_API_KEY`,
|
||||||
`OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json`
|
`GROQ_API_KEY` ve `OPENROUTER_API_KEY` kullanılır;
|
||||||
|
anahtarlar `~/.config/dikte/config.json`
|
||||||
içinde, izinler 600. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript
|
içinde, izinler 600. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript
|
||||||
yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin.
|
yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin.
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""OpenAI and OpenRouter calls, stdlib only.
|
"""OpenAI, Groq and OpenRouter calls, stdlib only.
|
||||||
|
|
||||||
Transcription runs on either provider: OpenRouter mirrors OpenAI's
|
Transcription runs on any of the three: Groq and OpenRouter both mirror OpenAI's
|
||||||
/audio/transcriptions endpoint field for field, so one multipart request serves
|
/audio/transcriptions endpoint field for field, so one multipart request serves
|
||||||
both and only the key, the base URL and the model id change. Cleanup is always
|
all of them and only the key, the base URL and the model id change. Cleanup is
|
||||||
OpenRouter.
|
always OpenRouter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
@@ -19,6 +19,7 @@ from i18n import t
|
|||||||
APP_URL = "https://github.com/yusufipk/dikte"
|
APP_URL = "https://github.com/yusufipk/dikte"
|
||||||
USER_AGENT = f"dikte/1.0 (+{APP_URL})"
|
USER_AGENT = f"dikte/1.0 (+{APP_URL})"
|
||||||
OPENAI_URL = "https://api.openai.com/v1"
|
OPENAI_URL = "https://api.openai.com/v1"
|
||||||
|
GROQ_URL = "https://api.groq.com/openai/v1"
|
||||||
OPENROUTER_URL = "https://openrouter.ai/api/v1"
|
OPENROUTER_URL = "https://openrouter.ai/api/v1"
|
||||||
|
|
||||||
# Where a transcription request goes; built by config.Config.transcribe_target().
|
# Where a transcription request goes; built by config.Config.transcribe_target().
|
||||||
@@ -27,8 +28,15 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1"
|
|||||||
Target = collections.namedtuple("Target", "provider service api_key base_url model")
|
Target = collections.namedtuple("Target", "provider service api_key base_url model")
|
||||||
|
|
||||||
|
|
||||||
def timestamp_model(provider):
|
def timestamp_model(provider, selected=""):
|
||||||
"""Only whisper-1 returns segment times, and OpenRouter namespaces the id."""
|
"""Which model answers with segment times.
|
||||||
|
|
||||||
|
OpenAI keeps them to whisper-1 and OpenRouter namespaces that id. Everything
|
||||||
|
Groq transcribes with is a whisper, so the model already chosen does it and
|
||||||
|
the fallback is only for a provider left on its default.
|
||||||
|
"""
|
||||||
|
if provider == "groq":
|
||||||
|
return selected or "whisper-large-v3-turbo"
|
||||||
return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
|
return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
|
||||||
|
|
||||||
|
|
||||||
@@ -126,7 +134,7 @@ def _transcribe_request(target, wav_path, language, prompt, response_format,
|
|||||||
fields.append(("language", language))
|
fields.append(("language", language))
|
||||||
# OpenRouter takes the hint field and throws it away, so spare it the bytes.
|
# OpenRouter takes the hint field and throws it away, so spare it the bytes.
|
||||||
# The same words still reach the cleanup model as a glossary.
|
# The same words still reach the cleanup model as a glossary.
|
||||||
if prompt and target.provider == "openai":
|
if prompt and target.provider != "openrouter":
|
||||||
fields.append(("prompt", prompt))
|
fields.append(("prompt", prompt))
|
||||||
if granularity:
|
if granularity:
|
||||||
fields.append(("timestamp_granularities[]", granularity))
|
fields.append(("timestamp_granularities[]", granularity))
|
||||||
@@ -153,7 +161,7 @@ def transcribe(target, wav_path, language="", prompt="", timeout=300):
|
|||||||
def transcribe_segments(target, wav_path, language="", prompt="", timeout=300):
|
def transcribe_segments(target, wav_path, language="", prompt="", timeout=300):
|
||||||
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
|
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
|
||||||
data = _transcribe_request(
|
data = _transcribe_request(
|
||||||
target._replace(model=timestamp_model(target.provider)),
|
target._replace(model=timestamp_model(target.provider, target.model)),
|
||||||
wav_path, language, prompt, "verbose_json",
|
wav_path, language, prompt, "verbose_json",
|
||||||
granularity="segment", timeout=timeout,
|
granularity="segment", timeout=timeout,
|
||||||
)
|
)
|
||||||
@@ -297,17 +305,22 @@ def openrouter_models(api_key="", transcription=False):
|
|||||||
return sorted(m["id"] for m in models if m.get("id"))
|
return sorted(m["id"] for m in models if m.get("id"))
|
||||||
|
|
||||||
|
|
||||||
def openai_models(api_key, base_url=OPENAI_URL):
|
def openai_models(api_key, base_url=OPENAI_URL, service="OpenAI"):
|
||||||
|
"""The audio models of anything that speaks OpenAI's /models, Groq included.
|
||||||
|
|
||||||
|
`service` is only the name an error is written in, so a Groq key that is
|
||||||
|
refused says Groq rather than OpenAI.
|
||||||
|
"""
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
||||||
service="OpenAI"))
|
service=service))
|
||||||
try:
|
try:
|
||||||
data = _get_json(
|
data = _get_json(
|
||||||
f"{base_url.rstrip('/')}/models",
|
f"{base_url.rstrip('/')}/models",
|
||||||
{"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT},
|
{"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT},
|
||||||
)
|
)
|
||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
raise explain(exc, "OpenAI") from None
|
raise explain(exc, service) from None
|
||||||
ids = [m["id"] for m in data.get("data", []) if m.get("id")]
|
ids = [m["id"] for m in data.get("data", []) if m.get("id")]
|
||||||
audio = [i for i in ids if "transcribe" in i or "whisper" in i]
|
audio = [i for i in ids if "transcribe" in i or "whisper" in i]
|
||||||
return sorted(audio or ids)
|
return sorted(audio or ids)
|
||||||
|
|||||||
@@ -662,12 +662,14 @@ def cmd_devices(opts):
|
|||||||
|
|
||||||
def cmd_models(opts):
|
def cmd_models(opts):
|
||||||
conf = cfg.Config()
|
conf = cfg.Config()
|
||||||
|
who = cfg.TRANSCRIBERS[opts.provider]
|
||||||
try:
|
try:
|
||||||
if opts.provider == "openai":
|
if opts.provider == "openrouter":
|
||||||
models = api.openai_models(conf.openai_key(), conf["openai_base_url"])
|
|
||||||
else:
|
|
||||||
models = api.openrouter_models(conf.openrouter_key(),
|
models = api.openrouter_models(conf.openrouter_key(),
|
||||||
transcription=opts.transcription)
|
transcription=opts.transcription)
|
||||||
|
else:
|
||||||
|
models = api.openai_models(conf.api_key(who.key), conf[who.url],
|
||||||
|
who.service)
|
||||||
except api.ApiError as exc:
|
except api.ApiError as exc:
|
||||||
return fail(opts, exc)
|
return fail(opts, exc)
|
||||||
return out(opts, {"ok": True, "provider": opts.provider, "models": models},
|
return out(opts, {"ok": True, "provider": opts.provider, "models": models},
|
||||||
@@ -677,19 +679,21 @@ def cmd_models(opts):
|
|||||||
def cmd_test_key(opts):
|
def cmd_test_key(opts):
|
||||||
conf = cfg.Config()
|
conf = cfg.Config()
|
||||||
results = {}
|
results = {}
|
||||||
if opts.which in ("openai", "all"):
|
for name, who in cfg.TRANSCRIBERS.items():
|
||||||
|
if opts.which not in (name, "all"):
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
count = len(api.openai_models(conf.openai_key(), conf["openai_base_url"]))
|
if name == "openrouter":
|
||||||
results["openai"] = {"ok": True,
|
# The one key that also pays for cleanup, so it reports credit
|
||||||
"message": f"connection works, {count} models visible"}
|
# rather than a model count.
|
||||||
|
message = api.openrouter_key_status(conf.openrouter_key())
|
||||||
|
else:
|
||||||
|
count = len(api.openai_models(conf.api_key(who.key), conf[who.url],
|
||||||
|
who.service))
|
||||||
|
message = f"connection works, {count} models visible"
|
||||||
|
results[name] = {"ok": True, "message": message}
|
||||||
except api.ApiError as exc:
|
except api.ApiError as exc:
|
||||||
results["openai"] = {"ok": False, "message": str(exc)}
|
results[name] = {"ok": False, "message": str(exc)}
|
||||||
if opts.which in ("openrouter", "all"):
|
|
||||||
try:
|
|
||||||
results["openrouter"] = {"ok": True,
|
|
||||||
"message": api.openrouter_key_status(conf.openrouter_key())}
|
|
||||||
except api.ApiError as exc:
|
|
||||||
results["openrouter"] = {"ok": False, "message": str(exc)}
|
|
||||||
everything_ok = all(item["ok"] for item in results.values())
|
everything_ok = all(item["ok"] for item in results.values())
|
||||||
lines = [f"{'✓' if item['ok'] else '✗'} {name}: {item['message']}"
|
lines = [f"{'✓' if item['ok'] else '✗'} {name}: {item['message']}"
|
||||||
for name, item in results.items()]
|
for name, item in results.items()]
|
||||||
@@ -986,14 +990,14 @@ def build_parser():
|
|||||||
# --- the machine ------------------------------------------------------
|
# --- the machine ------------------------------------------------------
|
||||||
leaf(subs, "devices", "microphones and monitors").set_defaults(func=cmd_devices)
|
leaf(subs, "devices", "microphones and monitors").set_defaults(func=cmd_devices)
|
||||||
models = leaf(subs, "models", "model ids a provider offers")
|
models = leaf(subs, "models", "model ids a provider offers")
|
||||||
models.add_argument("--provider", choices=("openrouter", "openai"),
|
models.add_argument("--provider", choices=tuple(cfg.TRANSCRIBERS),
|
||||||
default="openrouter")
|
default="openrouter")
|
||||||
models.add_argument("--transcription", action="store_true",
|
models.add_argument("--transcription", action="store_true",
|
||||||
help="only the speech-to-text ones")
|
help="only the speech-to-text ones")
|
||||||
models.set_defaults(func=cmd_models)
|
models.set_defaults(func=cmd_models)
|
||||||
test = leaf(subs, "test-key", "check the API keys")
|
test = leaf(subs, "test-key", "check the API keys")
|
||||||
test.add_argument("which", nargs="?", default="all",
|
test.add_argument("which", nargs="?", default="all",
|
||||||
choices=("all", "openai", "openrouter"))
|
choices=("all", *cfg.TRANSCRIBERS))
|
||||||
test.set_defaults(func=cmd_test_key)
|
test.set_defaults(func=cmd_test_key)
|
||||||
leaf(subs, "doctor", "keys, programs, and what is missing").set_defaults(func=cmd_doctor)
|
leaf(subs, "doctor", "keys, programs, and what is missing").set_defaults(func=cmd_doctor)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Settings storage in ~/.config/dikte/config.json"""
|
"""Settings storage in ~/.config/dikte/config.json"""
|
||||||
|
|
||||||
|
import collections
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -363,10 +364,13 @@ DEFAULTS = {
|
|||||||
"ui_language": "auto", # auto | tr | en
|
"ui_language": "auto", # auto | tr | en
|
||||||
"openai_api_key": "",
|
"openai_api_key": "",
|
||||||
"openai_base_url": "https://api.openai.com/v1",
|
"openai_base_url": "https://api.openai.com/v1",
|
||||||
|
"groq_api_key": "",
|
||||||
|
"groq_base_url": "https://api.groq.com/openai/v1",
|
||||||
"openrouter_api_key": "",
|
"openrouter_api_key": "",
|
||||||
"openrouter_base_url": "https://openrouter.ai/api/v1",
|
"openrouter_base_url": "https://openrouter.ai/api/v1",
|
||||||
"transcribe_provider": "openai", # openai | openrouter
|
"transcribe_provider": "openai", # a key of TRANSCRIBERS
|
||||||
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai
|
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai
|
||||||
|
"groq_transcribe_model": "whisper-large-v3-turbo",
|
||||||
"openrouter_transcribe_model": "openai/gpt-4o-transcribe",
|
"openrouter_transcribe_model": "openai/gpt-4o-transcribe",
|
||||||
"language": "tr",
|
"language": "tr",
|
||||||
"transcribe_prompt": "",
|
"transcribe_prompt": "",
|
||||||
@@ -439,6 +443,22 @@ LEGACY_PROMPTS = {
|
|||||||
"154fc5aca1166f00eebda705f848f0391bfbf5fe", # 1.2 English
|
"154fc5aca1166f00eebda705f848f0391bfbf5fe", # 1.2 English
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Every provider speech to text can run on, and the four settings that describe
|
||||||
|
# one. A fifth is a row here rather than another branch in transcribe_target(),
|
||||||
|
# another key row in the settings window and another line in save and load. The
|
||||||
|
# order is the order the provider box offers them in. `service` is the name the
|
||||||
|
# user sees; the environment variable that stands in for an empty key is the
|
||||||
|
# name of its setting, shouted.
|
||||||
|
Transcriber = collections.namedtuple("Transcriber", "service key url model")
|
||||||
|
TRANSCRIBERS = {
|
||||||
|
"openai": Transcriber("OpenAI", "openai_api_key", "openai_base_url",
|
||||||
|
"transcribe_model"),
|
||||||
|
"groq": Transcriber("Groq", "groq_api_key", "groq_base_url",
|
||||||
|
"groq_transcribe_model"),
|
||||||
|
"openrouter": Transcriber("OpenRouter", "openrouter_api_key",
|
||||||
|
"openrouter_base_url", "openrouter_transcribe_model"),
|
||||||
|
}
|
||||||
|
|
||||||
# Corners used to be stored with Turkish names.
|
# Corners used to be stored with Turkish names.
|
||||||
_CORNER_MIGRATION = {
|
_CORNER_MIGRATION = {
|
||||||
"sol-alt": "bottom-left", "sağ-alt": "bottom-right",
|
"sol-alt": "bottom-left", "sağ-alt": "bottom-right",
|
||||||
@@ -487,21 +507,27 @@ class Config:
|
|||||||
def get(self, key, default=None):
|
def get(self, key, default=None):
|
||||||
return self.data.get(key, DEFAULTS.get(key, default))
|
return self.data.get(key, DEFAULTS.get(key, default))
|
||||||
|
|
||||||
|
def api_key(self, setting):
|
||||||
|
"""A stored key, or the environment variable that shares its name."""
|
||||||
|
return self[setting].strip() or os.environ.get(setting.upper(), "").strip()
|
||||||
|
|
||||||
def openai_key(self):
|
def openai_key(self):
|
||||||
"""Fall back to the environment when no key is stored."""
|
return self.api_key("openai_api_key")
|
||||||
return self["openai_api_key"].strip() or os.environ.get("OPENAI_API_KEY", "").strip()
|
|
||||||
|
def groq_key(self):
|
||||||
|
return self.api_key("groq_api_key")
|
||||||
|
|
||||||
def openrouter_key(self):
|
def openrouter_key(self):
|
||||||
return self["openrouter_api_key"].strip() or os.environ.get("OPENROUTER_API_KEY", "").strip()
|
return self.api_key("openrouter_api_key")
|
||||||
|
|
||||||
def transcribe_target(self):
|
def transcribe_target(self):
|
||||||
"""Key, endpoint and model for whichever provider does speech to text."""
|
"""Key, endpoint and model for whichever provider does speech to text."""
|
||||||
if self["transcribe_provider"] == "openrouter":
|
name = self["transcribe_provider"]
|
||||||
return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
|
if name not in TRANSCRIBERS:
|
||||||
self["openrouter_base_url"],
|
name = DEFAULTS["transcribe_provider"]
|
||||||
self["openrouter_transcribe_model"])
|
who = TRANSCRIBERS[name]
|
||||||
return api.Target("openai", "OpenAI", self.openai_key(),
|
return api.Target(name, who.service, self.api_key(who.key),
|
||||||
self["openai_base_url"], self["transcribe_model"])
|
self[who.url], self[who.model])
|
||||||
|
|
||||||
def cleanup_prompt(self, with_timestamps=False, with_speakers=False,
|
def cleanup_prompt(self, with_timestamps=False, with_speakers=False,
|
||||||
subtitles=False):
|
subtitles=False):
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ TR = {
|
|||||||
"Model": "Model",
|
"Model": "Model",
|
||||||
"Provider": "Sağlayıcı",
|
"Provider": "Sağlayıcı",
|
||||||
"sk-… (falls back to OPENAI_API_KEY)": "sk-… (boşsa OPENAI_API_KEY kullanılır)",
|
"sk-… (falls back to OPENAI_API_KEY)": "sk-… (boşsa OPENAI_API_KEY kullanılır)",
|
||||||
|
"gsk_… (falls back to GROQ_API_KEY)": "gsk_… (boşsa GROQ_API_KEY kullanılır)",
|
||||||
"sk-or-… (falls back to OPENROUTER_API_KEY)": "sk-or-… (boşsa OPENROUTER_API_KEY kullanılır)",
|
"sk-or-… (falls back to OPENROUTER_API_KEY)": "sk-or-… (boşsa OPENROUTER_API_KEY kullanılır)",
|
||||||
"Test": "Test et",
|
"Test": "Test et",
|
||||||
"Trying…": "Deneniyor…",
|
"Trying…": "Deneniyor…",
|
||||||
|
|||||||
+82
-65
@@ -29,11 +29,13 @@ LANGUAGES = [
|
|||||||
("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"),
|
("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"),
|
||||||
]
|
]
|
||||||
CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"]
|
CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"]
|
||||||
TRANSCRIBE_PROVIDERS = [("OpenAI", "openai"), ("OpenRouter", "openrouter")]
|
# The provider box offers what config knows how to reach, in that order.
|
||||||
|
TRANSCRIBE_PROVIDERS = [(who.service, name) for name, who in cfg.TRANSCRIBERS.items()]
|
||||||
# Starting points for the model box; "Fetch model list" replaces them with
|
# Starting points for the model box; "Fetch model list" replaces them with
|
||||||
# whatever the provider offers today.
|
# whatever the provider offers today.
|
||||||
TRANSCRIBE_MODELS = {
|
TRANSCRIBE_MODELS = {
|
||||||
"openai": ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"],
|
"openai": ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"],
|
||||||
|
"groq": ["whisper-large-v3-turbo", "whisper-large-v3"],
|
||||||
"openrouter": [
|
"openrouter": [
|
||||||
"openai/gpt-4o-transcribe", "openai/gpt-4o-mini-transcribe",
|
"openai/gpt-4o-transcribe", "openai/gpt-4o-mini-transcribe",
|
||||||
"openai/whisper-1", "openai/whisper-large-v3",
|
"openai/whisper-1", "openai/whisper-large-v3",
|
||||||
@@ -108,8 +110,8 @@ class SettingsWindow(QDialog):
|
|||||||
|
|
||||||
_models_loaded = pyqtSignal(list, str)
|
_models_loaded = pyqtSignal(list, str)
|
||||||
_transcribe_models_loaded = pyqtSignal(list, str)
|
_transcribe_models_loaded = pyqtSignal(list, str)
|
||||||
_test_done = pyqtSignal(bool, str)
|
# Which key was tested, whether it worked, and what to write under it.
|
||||||
_or_test_done = pyqtSignal(bool, str)
|
_test_done = pyqtSignal(str, bool, str)
|
||||||
|
|
||||||
def __init__(self, conf, launch_command, meeting_command=None,
|
def __init__(self, conf, launch_command, meeting_command=None,
|
||||||
meetings=None, ask_command=None, parent=None):
|
meetings=None, ask_command=None, parent=None):
|
||||||
@@ -121,7 +123,9 @@ class SettingsWindow(QDialog):
|
|||||||
self.meetings = meetings
|
self.meetings = meetings
|
||||||
# Each provider keeps its own transcription model, so switching the
|
# Each provider keeps its own transcription model, so switching the
|
||||||
# provider back and forth never overwrites the other one's.
|
# provider back and forth never overwrites the other one's.
|
||||||
self._models = {"openai": "", "openrouter": ""}
|
self._models = dict.fromkeys(cfg.TRANSCRIBERS, "")
|
||||||
|
self._key_fields = {}
|
||||||
|
self._testers = {}
|
||||||
self._shown_provider = ""
|
self._shown_provider = ""
|
||||||
self.transcriber = FileTranscriber(conf, self)
|
self.transcriber = FileTranscriber(conf, self)
|
||||||
self.setWindowTitle(t("Dikte Settings"))
|
self.setWindowTitle(t("Dikte Settings"))
|
||||||
@@ -152,7 +156,6 @@ class SettingsWindow(QDialog):
|
|||||||
self._models_loaded.connect(self._on_models_loaded)
|
self._models_loaded.connect(self._on_models_loaded)
|
||||||
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
||||||
self._test_done.connect(self._on_test_done)
|
self._test_done.connect(self._on_test_done)
|
||||||
self._or_test_done.connect(self._on_or_test_done)
|
|
||||||
self.transcriber.progress.connect(self._on_file_progress)
|
self.transcriber.progress.connect(self._on_file_progress)
|
||||||
self.transcriber.finished.connect(self._on_file_finished)
|
self.transcriber.finished.connect(self._on_file_finished)
|
||||||
self.transcriber.failed.connect(self._on_file_failed)
|
self.transcriber.failed.connect(self._on_file_failed)
|
||||||
@@ -244,25 +247,15 @@ class SettingsWindow(QDialog):
|
|||||||
# them and a key no longer belongs to a single job.
|
# them and a key no longer belongs to a single job.
|
||||||
keys = QGroupBox(t("Keys"))
|
keys = QGroupBox(t("Keys"))
|
||||||
keys_form = QFormLayout(keys)
|
keys_form = QFormLayout(keys)
|
||||||
self.openai_key = QLineEdit()
|
self.openai_key = self._key_row(
|
||||||
self.openai_key.setEchoMode(QLineEdit.EchoMode.Password)
|
keys_form, "openai", t("sk-… (falls back to OPENAI_API_KEY)"),
|
||||||
self.openai_key.setPlaceholderText(t("sk-… (falls back to OPENAI_API_KEY)"))
|
self._test_openai)
|
||||||
self.test_button = QPushButton(t("Test"))
|
self.groq_key = self._key_row(
|
||||||
self.test_button.clicked.connect(self._test_openai)
|
keys_form, "groq", t("gsk_… (falls back to GROQ_API_KEY)"),
|
||||||
self.test_label = QLabel("")
|
self._test_groq)
|
||||||
self.test_label.setWordWrap(True)
|
self.openrouter_key = self._key_row(
|
||||||
keys_form.addRow("OpenAI", self._row(self.openai_key, self.test_button))
|
keys_form, "openrouter", t("sk-or-… (falls back to OPENROUTER_API_KEY)"),
|
||||||
keys_form.addRow("", self.test_label)
|
self._test_openrouter)
|
||||||
|
|
||||||
self.openrouter_key = QLineEdit()
|
|
||||||
self.openrouter_key.setEchoMode(QLineEdit.EchoMode.Password)
|
|
||||||
self.openrouter_key.setPlaceholderText(t("sk-or-… (falls back to OPENROUTER_API_KEY)"))
|
|
||||||
self.or_test_button = QPushButton(t("Test"))
|
|
||||||
self.or_test_button.clicked.connect(self._test_openrouter)
|
|
||||||
self.or_test_label = QLabel("")
|
|
||||||
self.or_test_label.setWordWrap(True)
|
|
||||||
keys_form.addRow("OpenRouter", self._row(self.openrouter_key, self.or_test_button))
|
|
||||||
keys_form.addRow("", self.or_test_label)
|
|
||||||
outer.addWidget(keys)
|
outer.addWidget(keys)
|
||||||
|
|
||||||
stt = QGroupBox(t("Speech to text"))
|
stt = QGroupBox(t("Speech to text"))
|
||||||
@@ -891,6 +884,26 @@ class SettingsWindow(QDialog):
|
|||||||
box.lineEdit().setPlaceholderText(placeholder)
|
box.lineEdit().setPlaceholderText(placeholder)
|
||||||
return box
|
return box
|
||||||
|
|
||||||
|
def _key_row(self, form, provider, placeholder, tester):
|
||||||
|
"""A key field, its Test button and the line the answer lands on.
|
||||||
|
|
||||||
|
The field and the pair the answer needs are filed under the provider's
|
||||||
|
name, so saving, loading and the test handler find them by name rather
|
||||||
|
than through three attributes each.
|
||||||
|
"""
|
||||||
|
field = QLineEdit()
|
||||||
|
field.setEchoMode(QLineEdit.EchoMode.Password)
|
||||||
|
field.setPlaceholderText(placeholder)
|
||||||
|
button = QPushButton(t("Test"))
|
||||||
|
button.clicked.connect(tester)
|
||||||
|
answer = QLabel("")
|
||||||
|
answer.setWordWrap(True)
|
||||||
|
form.addRow(cfg.TRANSCRIBERS[provider].service, self._row(field, button))
|
||||||
|
form.addRow("", answer)
|
||||||
|
self._key_fields[provider] = field
|
||||||
|
self._testers[provider] = (button, answer)
|
||||||
|
return field
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _row(*widgets):
|
def _row(*widgets):
|
||||||
"""Widgets side by side in one form row; the first one takes the space."""
|
"""Widgets side by side in one form row; the first one takes the space."""
|
||||||
@@ -919,10 +932,9 @@ class SettingsWindow(QDialog):
|
|||||||
self.filter_hallucinations.setChecked(conf["filter_hallucinations"])
|
self.filter_hallucinations.setChecked(conf["filter_hallucinations"])
|
||||||
self.keep_audio.setChecked(conf["keep_audio"])
|
self.keep_audio.setChecked(conf["keep_audio"])
|
||||||
|
|
||||||
self.openai_key.setText(conf["openai_api_key"])
|
for name, who in cfg.TRANSCRIBERS.items():
|
||||||
self.openrouter_key.setText(conf["openrouter_api_key"])
|
self._key_fields[name].setText(conf[who.key])
|
||||||
self._models = {"openai": conf["transcribe_model"],
|
self._models[name] = conf[who.model]
|
||||||
"openrouter": conf["openrouter_transcribe_model"]}
|
|
||||||
self._shown_provider = ""
|
self._shown_provider = ""
|
||||||
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
|
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
|
||||||
self._provider_changed() # selecting index 0 fires no signal
|
self._provider_changed() # selecting index 0 fires no signal
|
||||||
@@ -1000,15 +1012,12 @@ class SettingsWindow(QDialog):
|
|||||||
conf["filter_hallucinations"] = self.filter_hallucinations.isChecked()
|
conf["filter_hallucinations"] = self.filter_hallucinations.isChecked()
|
||||||
conf["keep_audio"] = self.keep_audio.isChecked()
|
conf["keep_audio"] = self.keep_audio.isChecked()
|
||||||
|
|
||||||
conf["openai_api_key"] = self.openai_key.text().strip()
|
|
||||||
conf["openrouter_api_key"] = self.openrouter_key.text().strip()
|
|
||||||
|
|
||||||
provider = self.transcribe_provider.currentData() or "openai"
|
provider = self.transcribe_provider.currentData() or "openai"
|
||||||
self._models[provider] = self.transcribe_model.currentText().strip()
|
self._models[provider] = self.transcribe_model.currentText().strip()
|
||||||
conf["transcribe_provider"] = provider
|
conf["transcribe_provider"] = provider
|
||||||
for key, name in (("openai", "transcribe_model"),
|
for name, who in cfg.TRANSCRIBERS.items():
|
||||||
("openrouter", "openrouter_transcribe_model")):
|
conf[who.key] = self._key_fields[name].text().strip()
|
||||||
conf[name] = self._models[key].strip() or cfg.DEFAULTS[name]
|
conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model]
|
||||||
|
|
||||||
conf["cleanup_enabled"] = self.cleanup_enabled.isChecked()
|
conf["cleanup_enabled"] = self.cleanup_enabled.isChecked()
|
||||||
conf["cleanup_model"] = self.cleanup_model.currentText().strip()
|
conf["cleanup_model"] = self.cleanup_model.currentText().strip()
|
||||||
@@ -1107,15 +1116,14 @@ class SettingsWindow(QDialog):
|
|||||||
provider = self.transcribe_provider.currentData() or "openai"
|
provider = self.transcribe_provider.currentData() or "openai"
|
||||||
self.refresh_transcribe_models.setEnabled(False)
|
self.refresh_transcribe_models.setEnabled(False)
|
||||||
self.transcribe_status.setText(t("Fetching model list…"))
|
self.transcribe_status.setText(t("Fetching model list…"))
|
||||||
openai_key = self.openai_key.text().strip() or self.conf.openai_key()
|
key, base = self._typed_key(provider)
|
||||||
openrouter_key = self.openrouter_key.text().strip() or self.conf.openrouter_key()
|
service = cfg.TRANSCRIBERS[provider].service
|
||||||
base = self.conf["openai_base_url"]
|
|
||||||
|
|
||||||
def work():
|
def work():
|
||||||
try:
|
try:
|
||||||
models = (api.openrouter_models(openrouter_key, transcription=True)
|
models = (api.openrouter_models(key, transcription=True)
|
||||||
if provider == "openrouter"
|
if provider == "openrouter"
|
||||||
else api.openai_models(openai_key, base))
|
else api.openai_models(key, base, service))
|
||||||
self._transcribe_models_loaded.emit(models, "")
|
self._transcribe_models_loaded.emit(models, "")
|
||||||
except api.ApiError as exc:
|
except api.ApiError as exc:
|
||||||
self._transcribe_models_loaded.emit([], str(exc))
|
self._transcribe_models_loaded.emit([], str(exc))
|
||||||
@@ -1159,42 +1167,51 @@ class SettingsWindow(QDialog):
|
|||||||
self.models_label.setText(t("{count} models loaded.", count=len(models)))
|
self.models_label.setText(t("{count} models loaded.", count=len(models)))
|
||||||
|
|
||||||
def _test_openai(self):
|
def _test_openai(self):
|
||||||
self.test_button.setEnabled(False)
|
key, base = self._typed_key("openai")
|
||||||
self.test_label.setText(t("Trying…"))
|
self._test_key("openai", lambda: t(
|
||||||
key = self.openai_key.text().strip() or self.conf.openai_key()
|
"Connection works. {count} audio models visible.",
|
||||||
base = self.conf["openai_base_url"]
|
count=len(api.openai_models(key, base)),
|
||||||
|
))
|
||||||
|
|
||||||
def work():
|
def _test_groq(self):
|
||||||
try:
|
key, base = self._typed_key("groq")
|
||||||
models = api.openai_models(key, base)
|
self._test_key("groq", lambda: t(
|
||||||
self._test_done.emit(
|
"Connection works. {count} audio models visible.",
|
||||||
True, t("Connection works. {count} audio models visible.", count=len(models))
|
count=len(api.openai_models(key, base, cfg.TRANSCRIBERS["groq"].service)),
|
||||||
)
|
))
|
||||||
except api.ApiError as exc:
|
|
||||||
self._test_done.emit(False, str(exc))
|
|
||||||
|
|
||||||
threading.Thread(target=work, daemon=True).start()
|
|
||||||
|
|
||||||
def _test_openrouter(self):
|
def _test_openrouter(self):
|
||||||
self.or_test_button.setEnabled(False)
|
key, _ = self._typed_key("openrouter")
|
||||||
self.or_test_label.setText(t("Trying…"))
|
self._test_key("openrouter", lambda: api.openrouter_key_status(key))
|
||||||
key = self.openrouter_key.text().strip() or self.conf.openrouter_key()
|
|
||||||
|
def _typed_key(self, provider):
|
||||||
|
"""(key, base URL) for a provider, preferring what is in the field now."""
|
||||||
|
who = cfg.TRANSCRIBERS[provider]
|
||||||
|
typed = self._key_fields[provider].text().strip()
|
||||||
|
return typed or self.conf.api_key(who.key), self.conf[who.url]
|
||||||
|
|
||||||
|
def _test_key(self, provider, ask):
|
||||||
|
"""Run `ask` off the interface thread and write its answer under the key.
|
||||||
|
|
||||||
|
`ask` returns the line to show, or raises ApiError with the line to show
|
||||||
|
instead; either way it is read from a field before the thread starts.
|
||||||
|
"""
|
||||||
|
button, answer = self._testers[provider]
|
||||||
|
button.setEnabled(False)
|
||||||
|
answer.setText(t("Trying…"))
|
||||||
|
|
||||||
def work():
|
def work():
|
||||||
try:
|
try:
|
||||||
self._or_test_done.emit(True, api.openrouter_key_status(key))
|
self._test_done.emit(provider, True, ask())
|
||||||
except api.ApiError as exc:
|
except api.ApiError as exc:
|
||||||
self._or_test_done.emit(False, str(exc))
|
self._test_done.emit(provider, False, str(exc))
|
||||||
|
|
||||||
threading.Thread(target=work, daemon=True).start()
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
def _on_or_test_done(self, ok, message):
|
def _on_test_done(self, provider, ok, message):
|
||||||
self.or_test_button.setEnabled(True)
|
button, answer = self._testers[provider]
|
||||||
self.or_test_label.setText(("✓ " if ok else "✗ ") + message)
|
button.setEnabled(True)
|
||||||
|
answer.setText(("✓ " if ok else "✗ ") + message)
|
||||||
def _on_test_done(self, ok, message):
|
|
||||||
self.test_button.setEnabled(True)
|
|
||||||
self.test_label.setText(("✓ " if ok else "✗ ") + message)
|
|
||||||
|
|
||||||
# ---- audio file ------------------------------------------------------
|
# ---- audio file ------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ atexit.register(shutil.rmtree, _SANDBOX, True)
|
|||||||
# A key sitting in the environment would otherwise reach the code that falls
|
# A key sitting in the environment would otherwise reach the code that falls
|
||||||
# back to it, and the tests for "there is no key" would pass only on a machine
|
# back to it, and the tests for "there is no key" would pass only on a machine
|
||||||
# without one.
|
# without one.
|
||||||
for _var in ("OPENAI_API_KEY", "OPENROUTER_API_KEY"):
|
for _var in ("OPENAI_API_KEY", "GROQ_API_KEY", "OPENROUTER_API_KEY"):
|
||||||
os.environ.pop(_var, None)
|
os.environ.pop(_var, None)
|
||||||
|
|
||||||
# The interface language leaks through module-level state, so the tests fix it
|
# The interface language leaks through module-level state, so the tests fix it
|
||||||
|
|||||||
+48
-2
@@ -21,6 +21,7 @@ from tests.support import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
OPENAI = api.Target("openai", "OpenAI", "sk-test", api.OPENAI_URL, "gpt-4o-transcribe")
|
OPENAI = api.Target("openai", "OpenAI", "sk-test", api.OPENAI_URL, "gpt-4o-transcribe")
|
||||||
|
GROQ = api.Target("groq", "Groq", "gsk-test", api.GROQ_URL, "whisper-large-v3-turbo")
|
||||||
OPENROUTER = api.Target("openrouter", "OpenRouter", "sk-or-test",
|
OPENROUTER = api.Target("openrouter", "OpenRouter", "sk-or-test",
|
||||||
api.OPENROUTER_URL, "openai/gpt-4o-transcribe")
|
api.OPENROUTER_URL, "openai/gpt-4o-transcribe")
|
||||||
|
|
||||||
@@ -32,6 +33,18 @@ class TimestampModel(unittest.TestCase):
|
|||||||
def test_openrouter_namespaces_the_id(self):
|
def test_openrouter_namespaces_the_id(self):
|
||||||
self.assertEqual(api.timestamp_model("openrouter"), "openai/whisper-1")
|
self.assertEqual(api.timestamp_model("openrouter"), "openai/whisper-1")
|
||||||
|
|
||||||
|
def test_groq_keeps_the_model_that_was_chosen(self):
|
||||||
|
"""Every model it transcribes with is a whisper, so all of them do times."""
|
||||||
|
self.assertEqual(api.timestamp_model("groq", "whisper-large-v3"),
|
||||||
|
"whisper-large-v3")
|
||||||
|
|
||||||
|
def test_groq_with_nothing_chosen_falls_back(self):
|
||||||
|
self.assertEqual(api.timestamp_model("groq"), "whisper-large-v3-turbo")
|
||||||
|
|
||||||
|
def test_the_others_ignore_what_was_chosen(self):
|
||||||
|
self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"),
|
||||||
|
"whisper-1")
|
||||||
|
|
||||||
|
|
||||||
class Explain(DikteTest):
|
class Explain(DikteTest):
|
||||||
def error(self, status):
|
def error(self, status):
|
||||||
@@ -176,13 +189,28 @@ class Transcribe(DikteTest):
|
|||||||
self.assertEqual(multipart_fields(calls[0])["language"], "tr")
|
self.assertEqual(multipart_fields(calls[0])["language"], "tr")
|
||||||
self.assertNotIn("language", multipart_fields(calls[1]))
|
self.assertNotIn("language", multipart_fields(calls[1]))
|
||||||
|
|
||||||
def test_the_glossary_goes_to_openai_only(self):
|
def test_the_glossary_goes_everywhere_but_openrouter(self):
|
||||||
"""OpenRouter takes the field and throws it away, so spare it the bytes."""
|
"""OpenRouter takes the field and throws it away, so spare it the bytes."""
|
||||||
with fake_urlopen({"text": "hi"}) as calls:
|
with fake_urlopen({"text": "hi"}) as calls:
|
||||||
api.transcribe(OPENAI, self.wav, prompt="Paraşüt, OpenFrame")
|
api.transcribe(OPENAI, self.wav, prompt="Paraşüt, OpenFrame")
|
||||||
|
api.transcribe(GROQ, self.wav, prompt="Paraşüt, OpenFrame")
|
||||||
api.transcribe(OPENROUTER, self.wav, prompt="Paraşüt, OpenFrame")
|
api.transcribe(OPENROUTER, self.wav, prompt="Paraşüt, OpenFrame")
|
||||||
self.assertIn("prompt", multipart_fields(calls[0]))
|
self.assertIn("prompt", multipart_fields(calls[0]))
|
||||||
self.assertNotIn("prompt", multipart_fields(calls[1]))
|
self.assertIn("prompt", multipart_fields(calls[1]))
|
||||||
|
self.assertNotIn("prompt", multipart_fields(calls[2]))
|
||||||
|
|
||||||
|
def test_groq_goes_to_groq(self):
|
||||||
|
with fake_urlopen({"text": "hi"}) as calls:
|
||||||
|
api.transcribe(GROQ, self.wav)
|
||||||
|
self.assertEqual(calls[0].full_url,
|
||||||
|
"https://api.groq.com/openai/v1/audio/transcriptions")
|
||||||
|
self.assertEqual(multipart_fields(calls[0])["model"], "whisper-large-v3-turbo")
|
||||||
|
|
||||||
|
def test_a_refused_groq_key_is_explained_in_groq_s_name(self):
|
||||||
|
with fake_urlopen(http_error(401, '{"error": {"message": "bad key"}}')), \
|
||||||
|
self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.transcribe(GROQ, self.wav)
|
||||||
|
self.assertIn("Groq", str(caught.exception))
|
||||||
|
|
||||||
def test_openrouter_is_attributed(self):
|
def test_openrouter_is_attributed(self):
|
||||||
with fake_urlopen({"text": "hi"}) as calls:
|
with fake_urlopen({"text": "hi"}) as calls:
|
||||||
@@ -242,6 +270,12 @@ class TranscribeSegments(DikteTest):
|
|||||||
api.transcribe_segments(OPENROUTER, self.wav)
|
api.transcribe_segments(OPENROUTER, self.wav)
|
||||||
self.assertEqual(multipart_fields(calls[0])["model"], "openai/whisper-1")
|
self.assertEqual(multipart_fields(calls[0])["model"], "openai/whisper-1")
|
||||||
|
|
||||||
|
def test_groq_stays_on_the_model_it_was_given(self):
|
||||||
|
target = GROQ._replace(model="whisper-large-v3")
|
||||||
|
with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls:
|
||||||
|
api.transcribe_segments(target, self.wav)
|
||||||
|
self.assertEqual(multipart_fields(calls[0])["model"], "whisper-large-v3")
|
||||||
|
|
||||||
def test_the_segments_come_back_as_numbers(self):
|
def test_the_segments_come_back_as_numbers(self):
|
||||||
with fake_urlopen(self.reply([
|
with fake_urlopen(self.reply([
|
||||||
{"start": "0.5", "end": "2.25", "text": " hello "},
|
{"start": "0.5", "end": "2.25", "text": " hello "},
|
||||||
@@ -432,6 +466,18 @@ class ModelLists(DikteTest):
|
|||||||
with self.assertRaises(api.ApiError):
|
with self.assertRaises(api.ApiError):
|
||||||
api.openai_models("")
|
api.openai_models("")
|
||||||
|
|
||||||
|
def test_the_same_list_read_from_groq(self):
|
||||||
|
with fake_urlopen({"data": [{"id": "llama-3.3-70b"},
|
||||||
|
{"id": "whisper-large-v3"}]}) as calls:
|
||||||
|
models = api.openai_models("gsk-test", api.GROQ_URL, "Groq")
|
||||||
|
self.assertEqual(calls[0].full_url, "https://api.groq.com/openai/v1/models")
|
||||||
|
self.assertEqual(models, ["whisper-large-v3"])
|
||||||
|
|
||||||
|
def test_a_missing_groq_key_says_groq(self):
|
||||||
|
with self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.openai_models("", api.GROQ_URL, "Groq")
|
||||||
|
self.assertIn("Groq", str(caught.exception))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+34
-1
@@ -15,7 +15,7 @@ from unittest import mock
|
|||||||
import cli
|
import cli
|
||||||
import config as cfg
|
import config as cfg
|
||||||
import ipc
|
import ipc
|
||||||
from tests.support import DikteTest
|
from tests.support import DikteTest, fake_urlopen
|
||||||
|
|
||||||
|
|
||||||
class Options:
|
class Options:
|
||||||
@@ -316,6 +316,39 @@ class ConfigCommands(DikteTest):
|
|||||||
{"cleanup", "subtitles", "meeting", "agent"})
|
{"cleanup", "subtitles", "meeting", "agent"})
|
||||||
|
|
||||||
|
|
||||||
|
class Providers(DikteTest):
|
||||||
|
"""The terminal reaches every provider the settings window does."""
|
||||||
|
|
||||||
|
def run_cmd(self, func, **values):
|
||||||
|
with captured() as (out, err):
|
||||||
|
code = func(Options(**values))
|
||||||
|
return code, out.getvalue(), err.getvalue()
|
||||||
|
|
||||||
|
def test_a_provider_the_settings_window_offers_is_a_choice_here_too(self):
|
||||||
|
parser = cli.build_parser()
|
||||||
|
for provider in cfg.TRANSCRIBERS:
|
||||||
|
with self.subTest(provider=provider):
|
||||||
|
opts = parser.parse_args(["models", "--provider", provider])
|
||||||
|
self.assertEqual(opts.provider, provider)
|
||||||
|
self.assertEqual(parser.parse_args(["test-key", provider]).which,
|
||||||
|
provider)
|
||||||
|
|
||||||
|
def test_the_model_list_is_read_from_the_chosen_provider(self):
|
||||||
|
self.write_config({"groq_api_key": "gsk-test"})
|
||||||
|
with fake_urlopen({"data": [{"id": "whisper-large-v3"}]}) as calls:
|
||||||
|
code, out, _ = self.run_cmd(cli.cmd_models, provider="groq",
|
||||||
|
transcription=False)
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertEqual(calls[0].full_url, "https://api.groq.com/openai/v1/models")
|
||||||
|
self.assertEqual(out.strip(), "whisper-large-v3")
|
||||||
|
|
||||||
|
def test_a_key_that_is_not_there_is_reported_under_its_own_name(self):
|
||||||
|
code, out, _ = self.run_cmd(cli.cmd_test_key, which="groq")
|
||||||
|
self.assertEqual(code, 1)
|
||||||
|
self.assertIn("groq", out)
|
||||||
|
self.assertIn("Groq", out)
|
||||||
|
|
||||||
|
|
||||||
class Finding(DikteTest):
|
class Finding(DikteTest):
|
||||||
def test_no_history_at_all(self):
|
def test_no_history_at_all(self):
|
||||||
self.assertIsNone(cli._find_history("last"))
|
self.assertIsNone(cli._find_history("last"))
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ class Keys(DikteTest):
|
|||||||
def test_no_key_anywhere(self):
|
def test_no_key_anywhere(self):
|
||||||
self.assertEqual(cfg.Config().openai_key(), "")
|
self.assertEqual(cfg.Config().openai_key(), "")
|
||||||
|
|
||||||
|
def test_every_provider_falls_back_to_the_variable_of_its_own_name(self):
|
||||||
|
with mock.patch.dict(os.environ, {"GROQ_API_KEY": "gsk-env"}):
|
||||||
|
self.assertEqual(cfg.Config().groq_key(), "gsk-env")
|
||||||
|
|
||||||
|
|
||||||
class TranscribeTarget(DikteTest):
|
class TranscribeTarget(DikteTest):
|
||||||
def test_openai_by_default(self):
|
def test_openai_by_default(self):
|
||||||
@@ -145,6 +149,21 @@ class TranscribeTarget(DikteTest):
|
|||||||
self.assertEqual(target.api_key, "sk-or-test")
|
self.assertEqual(target.api_key, "sk-or-test")
|
||||||
self.assertEqual(target.model, "openai/whisper-1")
|
self.assertEqual(target.model, "openai/whisper-1")
|
||||||
|
|
||||||
|
def test_groq_when_it_is_picked(self):
|
||||||
|
conf = self.config(transcribe_provider="groq", groq_api_key="gsk-test",
|
||||||
|
groq_transcribe_model="whisper-large-v3")
|
||||||
|
target = conf.transcribe_target()
|
||||||
|
self.assertEqual(target.provider, "groq")
|
||||||
|
self.assertEqual(target.service, "Groq")
|
||||||
|
self.assertEqual(target.api_key, "gsk-test")
|
||||||
|
self.assertEqual(target.base_url, api.GROQ_URL)
|
||||||
|
self.assertEqual(target.model, "whisper-large-v3")
|
||||||
|
|
||||||
|
def test_a_provider_this_version_has_never_heard_of(self):
|
||||||
|
"""A config written by a fork, or by a version that dropped one."""
|
||||||
|
target = self.config(transcribe_provider="deepgram").transcribe_target()
|
||||||
|
self.assertEqual(target.provider, "openai")
|
||||||
|
|
||||||
def test_a_self_hosted_endpoint(self):
|
def test_a_self_hosted_endpoint(self):
|
||||||
conf = self.config(openai_base_url="http://localhost:8080/v1")
|
conf = self.config(openai_base_url="http://localhost:8080/v1")
|
||||||
self.assertEqual(conf.transcribe_target().base_url, "http://localhost:8080/v1")
|
self.assertEqual(conf.transcribe_target().base_url, "http://localhost:8080/v1")
|
||||||
|
|||||||
+28
-2
@@ -36,9 +36,11 @@ CHANGED = {
|
|||||||
"filter_hallucinations": False,
|
"filter_hallucinations": False,
|
||||||
"keep_audio": True,
|
"keep_audio": True,
|
||||||
"openai_api_key": "sk-test-key",
|
"openai_api_key": "sk-test-key",
|
||||||
|
"groq_api_key": "gsk-test-key",
|
||||||
"openrouter_api_key": "sk-or-test-key",
|
"openrouter_api_key": "sk-or-test-key",
|
||||||
"transcribe_provider": "openrouter",
|
"transcribe_provider": "openrouter",
|
||||||
"transcribe_model": "whisper-1",
|
"transcribe_model": "whisper-1",
|
||||||
|
"groq_transcribe_model": "whisper-large-v3",
|
||||||
"openrouter_transcribe_model": "openai/whisper-1",
|
"openrouter_transcribe_model": "openai/whisper-1",
|
||||||
"cleanup_enabled": False,
|
"cleanup_enabled": False,
|
||||||
"cleanup_model": "some/other-model",
|
"cleanup_model": "some/other-model",
|
||||||
@@ -145,14 +147,38 @@ class Settings(DikteTest):
|
|||||||
def test_each_provider_keeps_its_own_transcription_model(self):
|
def test_each_provider_keeps_its_own_transcription_model(self):
|
||||||
self.write_config({"transcribe_provider": "openai",
|
self.write_config({"transcribe_provider": "openai",
|
||||||
"transcribe_model": "gpt-4o-transcribe",
|
"transcribe_model": "gpt-4o-transcribe",
|
||||||
|
"groq_transcribe_model": "whisper-large-v3",
|
||||||
"openrouter_transcribe_model": "openai/whisper-1"})
|
"openrouter_transcribe_model": "openai/whisper-1"})
|
||||||
conf = cfg.Config()
|
conf = cfg.Config()
|
||||||
window = self.window(conf)
|
window = self.window(conf)
|
||||||
window.transcribe_provider.setCurrentIndex(
|
for provider in ("groq", "openrouter"):
|
||||||
window.transcribe_provider.findData("openrouter"))
|
window.transcribe_provider.setCurrentIndex(
|
||||||
|
window.transcribe_provider.findData(provider))
|
||||||
window._save()
|
window._save()
|
||||||
self.assertEqual(conf["transcribe_provider"], "openrouter")
|
self.assertEqual(conf["transcribe_provider"], "openrouter")
|
||||||
self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe")
|
self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe")
|
||||||
|
self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3")
|
||||||
|
|
||||||
|
def test_the_provider_box_offers_every_provider_config_knows(self):
|
||||||
|
window = self.window(cfg.Config())
|
||||||
|
offered = [window.transcribe_provider.itemData(i)
|
||||||
|
for i in range(window.transcribe_provider.count())]
|
||||||
|
self.assertEqual(offered, list(cfg.TRANSCRIBERS))
|
||||||
|
|
||||||
|
def test_the_answer_to_a_test_lands_under_the_key_it_was_asked_about(self):
|
||||||
|
"""One signal serves all three buttons, so it carries which one asked."""
|
||||||
|
window = self.window(cfg.Config())
|
||||||
|
window._on_test_done("groq", True, "it works")
|
||||||
|
button, answer = window._testers["groq"]
|
||||||
|
self.assertEqual(answer.text(), "✓ it works")
|
||||||
|
self.assertTrue(button.isEnabled())
|
||||||
|
self.assertEqual(window._testers["openai"][1].text(), "")
|
||||||
|
|
||||||
|
def test_a_key_lands_in_the_field_of_its_own_provider(self):
|
||||||
|
self.write_config({"groq_api_key": "gsk-mine"})
|
||||||
|
window = self.window(cfg.Config())
|
||||||
|
self.assertEqual(window.groq_key.text(), "gsk-mine")
|
||||||
|
self.assertEqual(window.openai_key.text(), "")
|
||||||
|
|
||||||
def test_saving_applies_the_lowered_history_limit_at_once(self):
|
def test_saving_applies_the_lowered_history_limit_at_once(self):
|
||||||
for index in range(10):
|
for index in range(10):
|
||||||
|
|||||||
Reference in New Issue
Block a user