Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ __pycache__/
.env
.env.*
secrets.env
.venv/
84 changes: 64 additions & 20 deletions app/blitztext_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,23 @@ def init_ui(self) -> None:

self._refresh_api_key_status()

self.combo_llm_provider = QComboBox()
self.combo_llm_provider.addItem("OpenAI", "openai")
self.combo_llm_provider.addItem("OpenRouter", "openrouter")
self.combo_llm_provider.addItem("Eigener Endpunkt", "custom")
provider_index = self.combo_llm_provider.findData(self.config.llm_provider)
self.combo_llm_provider.setCurrentIndex(provider_index if provider_index >= 0 else 0)
self.combo_llm_provider.currentIndexChanged.connect(lambda *_: self._on_llm_provider_changed())

self.edit_base_url = QLineEdit()
self.edit_base_url.setText(self.config.llm_base_url)
self.edit_base_url.setPlaceholderText("https://openrouter.ai/api/v1")
self.edit_base_url.setEnabled(self.config.llm_provider != "openai")

self.edit_llm_model = QLineEdit()
self.edit_llm_model.setText(self.config.llm_model)
self.edit_llm_model.setPlaceholderText("gpt-4o-mini")

self.combo_tone = QComboBox()
self.combo_tone.addItems(["formal", "neutral", "locker"])
self.combo_tone.setCurrentText(self.config.text_improver_tone)
Expand Down Expand Up @@ -260,8 +277,15 @@ def init_ui(self) -> None:
custom_terms_widget = QWidget()
custom_terms_widget.setLayout(custom_terms_layout)

form_llm.addRow("OpenAI API-Key-Umgebung:", api_key_layout)
form_llm.addRow("", create_help_label("Nur der Name der Umgebungsvariable wird gespeichert. Der Schlüssel selbst wird aus os.environ gelesen."))
form_llm.addRow("API-Key-Umgebung:", api_key_layout)
form_llm.addRow("", create_help_label("Nur der Name der Umgebungsvariable wird gespeichert. Der Schlüssel selbst wird aus os.environ gelesen (secrets.env). Für OpenRouter z. B. OPENROUTER_API_KEY."))

form_llm.addRow("LLM-Anbieter:", self.combo_llm_provider)
form_llm.addRow("", create_help_label("OpenAI = Standard. OpenRouter und 'Eigener Endpunkt' nutzen das OpenAI-kompatible API über eine eigene Basis-URL und ein eigenes Modell."))
form_llm.addRow("Basis-URL (base_url):", self.edit_base_url)
form_llm.addRow("", create_help_label("Leer = OpenAI-Standard. Für OpenRouter: https://openrouter.ai/api/v1. Muss mit http:// oder https:// beginnen."))
form_llm.addRow("LLM-Modell:", self.edit_llm_model)
form_llm.addRow("", create_help_label("Modellname beim Anbieter, z. B. 'gpt-4o-mini' (OpenAI) oder 'openai/gpt-4o' (OpenRouter)."))

form_llm.addRow("Text-Verbesserer Tonfall:", self.combo_tone)
form_llm.addRow("Schreibstil-Vorlage:", self.combo_writing_preset)
Expand Down Expand Up @@ -326,6 +350,18 @@ def _refresh_api_key_status(self) -> None:
status = "gesetzt" if env_value else "nicht gesetzt"
self.lbl_api_key_status.setText(f"Status: {status} ({env_name})")

def _on_llm_provider_changed(self) -> None:
provider = self.combo_llm_provider.currentData()
if provider == "openrouter":
if not self.edit_base_url.text().strip():
self.edit_base_url.setText("https://openrouter.ai/api/v1")
self.edit_base_url.setEnabled(True)
Comment on lines +355 to +358

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid reusing the OpenAI key for OpenRouter

When a user with an existing OPENAI_API_KEY switches to OpenRouter, this branch only fills base_url; edit_api_key_env remains at the default unless the user notices and changes it manually. _build_llm_service() then resolves that OpenAI token and the SDK sends it as the Authorization header to openrouter.ai, so the common provider-switch path both fails and discloses the user's OpenAI key to the new provider. Please either switch/clear the env field for OpenRouter or block saving until a provider-appropriate key env is selected.

Useful? React with 👍 / 👎.

elif provider == "openai":
self.edit_base_url.setText("")
self.edit_base_url.setEnabled(False)
else: # custom / eigener Endpunkt
self.edit_base_url.setEnabled(True)

def _open_config_file(self) -> None:
"""Open the config.json in the desktop's default editor.

Expand Down Expand Up @@ -390,6 +426,9 @@ def save_settings(self) -> None:
self.config.transcription_hotkey = self.combo_transcription_key.currentText()

self.config.openai_api_key_env = self.edit_api_key_env.text().strip()
self.config.llm_provider = self.combo_llm_provider.currentData()
self.config.llm_base_url = self.edit_base_url.text().strip()
self.config.llm_model = self.edit_llm_model.text().strip()
self.config.text_improver_tone = self.combo_tone.currentText()
self.config.writing_preset = self.combo_writing_preset.currentData()
self.config.emoji_density = self.combo_emoji.currentText()
Expand Down Expand Up @@ -499,15 +538,7 @@ def __init__(self, app: QApplication) -> None:
self.app = app
self.config = Config.load()

self.llm_service = LLMService(
api_key=self.config.resolve_openai_api_key(),
tone=self.config.text_improver_tone,
emoji_density=self.config.emoji_density,
dampf_system_prompt=self.config.dampf_system_prompt,
custom_terms=self.config.custom_terms,
api_key_env=self.config.openai_api_key_env,
writing_preset=self.config.writing_preset,
)
self.llm_service = self._build_llm_service()
self.audio_recorder = AudioRecorder()
self.paste_service = PasteService(autopaste=self.config.autopaste)

Expand All @@ -531,6 +562,27 @@ def __init__(self, app: QApplication) -> None:
self.hotkey_thread: Optional[QThread] = None
self.start_hotkey_worker()

def _build_llm_service(self) -> LLMService:
"""Baut den LLMService aus der aktuellen Config.

Einziger Konstruktionsort, damit Init und Settings-Save nicht
auseinanderlaufen (z. B. base_url/model vergessen). Der Provider ist
autoritativ: bei "openai" wird eine evtl. gespeicherte base_url ignoriert,
damit der OpenAI-Standardendpunkt genutzt wird (OpenRouter nur bei Auswahl).
"""
base_url = "" if self.config.llm_provider == "openai" else self.config.llm_base_url

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject empty endpoints for non-OpenAI providers

For openrouter or custom, an empty llm_base_url can still reach this line because invalid URLs are normalized to "" and custom can be saved blank. Passing that through makes LLMService instantiate the OpenAI SDK with base_url=None, which routes the configured non-OpenAI flow (including the provider token and transcript) to the default OpenAI endpoint instead of failing fast. Please require a non-empty base URL whenever the selected provider is not openai.

Useful? React with 👍 / 👎.

return LLMService(
api_key=self.config.resolve_openai_api_key(),
tone=self.config.text_improver_tone,
emoji_density=self.config.emoji_density,
dampf_system_prompt=self.config.dampf_system_prompt,
custom_terms=self.config.custom_terms,
api_key_env=self.config.openai_api_key_env,
writing_preset=self.config.writing_preset,
base_url=base_url,
model=self.config.llm_model,
)

def setup_tray(self) -> None:
self.tray_icon = QSystemTrayIcon(self)
self._tray_icons = {
Expand Down Expand Up @@ -675,15 +727,7 @@ def show_settings_dialog(self) -> None:
dialog = SettingsDialog(self.config)
if dialog.exec() == QDialog.DialogCode.Accepted:
# Update LLM Service parameters from saved configuration
self.llm_service = LLMService(
api_key=self.config.resolve_openai_api_key(),
tone=self.config.text_improver_tone,
emoji_density=self.config.emoji_density,
dampf_system_prompt=self.config.dampf_system_prompt,
custom_terms=self.config.custom_terms,
api_key_env=self.config.openai_api_key_env,
writing_preset=self.config.writing_preset,
)
self.llm_service = self._build_llm_service()
self.update_menu_availability()

# Restart hotkey listener if mode or key changed
Expand Down
53 changes: 53 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
"hotkey_mode": "hold",
"transcription_hotkey": "KEY_LEFTALT",
"openai_api_key_env": "OPENAI_API_KEY",
"llm_provider": "openai",
"llm_base_url": "",
"llm_model": "gpt-4o-mini",
"autopaste": True,
"audio_device": "@DEFAULT_SOURCE@",
"notes_folder": str(Path.home() / "Blitztext-Notizen"),
Expand All @@ -46,6 +49,8 @@
VALID_TONES = {"formal", "neutral", "locker"}
VALID_EMOJI_DENSITIES = {"wenig", "mittel", "viel"}
VALID_WRITING_PRESETS = set(WRITING_PRESET_KEYS)
VALID_LLM_PROVIDERS = {"openai", "openrouter", "custom"}
BASE_URL_RE = re.compile(r"^https?://", re.IGNORECASE)
VALID_HOTKEY_KEYS = {
"KEY_LEFTALT", "KEY_RIGHTALT", "KEY_RIGHTCTRL", "KEY_LEFTCTRL",
"KEY_F13", "KEY_F14", "KEY_F15", "KEY_F16",
Expand Down Expand Up @@ -143,6 +148,33 @@ def resolve_openai_api_key(self) -> str:
def has_legacy_openai_api_key(self) -> bool:
return self._legacy_openai_api_key_present

@property
def llm_provider(self) -> str:
value = self._data.get("llm_provider", DEFAULTS["llm_provider"])
return value if value in VALID_LLM_PROVIDERS else DEFAULTS["llm_provider"]

@llm_provider.setter
def llm_provider(self, value: str) -> None:
if value not in VALID_LLM_PROVIDERS:
raise ValueError(f"Ungueltiger LLM-Anbieter: {value!r}. Gueltig: {sorted(VALID_LLM_PROVIDERS)}")
self._data["llm_provider"] = value

@property
def llm_base_url(self) -> str:
return _normalize_base_url(self._data.get("llm_base_url", ""))

@llm_base_url.setter
def llm_base_url(self, value: str) -> None:
self._data["llm_base_url"] = _normalize_base_url(value)

@property
def llm_model(self) -> str:
return _normalize_model(self._data.get("llm_model", DEFAULTS["llm_model"]))

@llm_model.setter
def llm_model(self, value: str) -> None:
self._data["llm_model"] = _normalize_model(value)

@property
def model(self) -> str:
return self._data["model"]
Expand Down Expand Up @@ -320,6 +352,11 @@ def _validate_and_sanitize(self) -> None:
)
self._data.pop("openai_api_key", None)

if self._data.get("llm_provider") not in VALID_LLM_PROVIDERS:
self._data["llm_provider"] = DEFAULTS["llm_provider"]
self._data["llm_base_url"] = _normalize_base_url(self._data.get("llm_base_url", ""))
self._data["llm_model"] = _normalize_model(self._data.get("llm_model", DEFAULTS["llm_model"]))

if "workflows" not in self._data or not isinstance(self._data["workflows"], dict):
self._data["workflows"] = {}

Expand Down Expand Up @@ -352,6 +389,22 @@ def _normalize_env_var_name(value: Any) -> str:
return candidate


def _normalize_base_url(value: Any) -> str:
if not isinstance(value, str):
return ""
candidate = value.strip()
if not candidate or not BASE_URL_RE.match(candidate):
return ""
return candidate


def _normalize_model(value: Any) -> str:
if not isinstance(value, str):
return DEFAULTS["llm_model"]
candidate = value.strip()
return candidate or DEFAULTS["llm_model"]


def _sanitize_terms(values: Any) -> list[str]:
if not isinstance(values, list):
return []
Expand Down
12 changes: 8 additions & 4 deletions app/llm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,13 @@ def __init__(
custom_terms: Optional[list[str]] = None,
api_key_env: str = "OPENAI_API_KEY",
writing_preset: str = DEFAULT_PRESET_KEY,
base_url: str = "",
model: str = "",
) -> None:
self.api_key = api_key or ""
self.api_key_env = api_key_env or "OPENAI_API_KEY"
self.base_url = (base_url or "").strip()
self.model = (model or "").strip() or MODEL
self.tone = tone
self.emoji_density = emoji_density
self.dampf_system_prompt = dampf_system_prompt
Expand All @@ -77,7 +81,7 @@ def __init__(
self.client = MagicMock()
else:
if self.api_key and self.api_key.strip():
self.client = openai.OpenAI(api_key=self.api_key)
self.client = openai.OpenAI(api_key=self.api_key, base_url=self.base_url or None)
else:
# Ohne API-Key keinen echten Client bauen: Neuere openai-Versionen
# werfen bereits im Konstruktor bei leerem Key. Der eigentliche
Expand Down Expand Up @@ -135,7 +139,7 @@ def dampf_ablassen(self, transcript: str, custom_system_prompt: str = "") -> str
system = (custom_system_prompt.strip() or self.dampf_system_prompt.strip() or _DAMPF_SYSTEM) + self._custom_terms_instruction()

response = self.client.chat.completions.create(
model=MODEL,
model=self.model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": transcript.strip()},
Expand All @@ -157,7 +161,7 @@ def text_improver(self, transcript: str, tone: str = "neutral", custom_prompt: s
system = (custom_prompt.strip() or _TEXT_IMPROVER_SYSTEM_TEMPLATE.format(tone=tone)) + self._custom_terms_instruction()

response = self.client.chat.completions.create(
model=MODEL,
model=self.model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": transcript.strip()},
Expand All @@ -179,7 +183,7 @@ def emoji_text(self, transcript: str, density: str = "mittel") -> str:
system = _EMOJI_SYSTEM_TEMPLATE.format(density=density) + self._custom_terms_instruction()

response = self.client.chat.completions.create(
model=MODEL,
model=self.model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": transcript.strip()},
Expand Down
65 changes: 65 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,71 @@ def test_non_string_preset_value_is_coerced_without_crash(self, config_dir, bad_
assert loaded.writing_preset == "standard"


class TestLLMProvider:
def test_defaults(self, config):
assert config.llm_provider == "openai"
assert config.llm_base_url == ""
assert config.llm_model == "gpt-4o-mini"

def test_valid_provider_is_accepted(self, config):
config.llm_provider = "openrouter"
assert config.llm_provider == "openrouter"

def test_invalid_provider_is_rejected(self, config):
with pytest.raises(ValueError):
config.llm_provider = "gibt-es-nicht"

def test_unknown_provider_in_file_is_coerced_to_openai(self, config_dir):
config_dir.mkdir(parents=True, exist_ok=True)
(config_dir / "config.json").write_text(
json.dumps({"llm_provider": "kaputt"}), encoding="utf-8"
)
loaded = BlitztextConfig(config_dir=config_dir)
assert loaded.llm_provider == "openai"

def test_base_url_is_stripped(self, config):
config.llm_base_url = " https://openrouter.ai/api/v1 "
assert config.llm_base_url == "https://openrouter.ai/api/v1"

def test_non_http_base_url_is_rejected_to_empty(self, config_dir):
config_dir.mkdir(parents=True, exist_ok=True)
(config_dir / "config.json").write_text(
json.dumps({"llm_base_url": "ftp://evil/x"}), encoding="utf-8"
)
loaded = BlitztextConfig(config_dir=config_dir)
assert loaded.llm_base_url == ""

def test_empty_model_in_file_falls_back_to_default(self, config_dir):
config_dir.mkdir(parents=True, exist_ok=True)
(config_dir / "config.json").write_text(
json.dumps({"llm_model": " "}), encoding="utf-8"
)
loaded = BlitztextConfig(config_dir=config_dir)
assert loaded.llm_model == "gpt-4o-mini"

def test_roundtrip_persists_provider_fields(self, config, config_dir):
config.llm_provider = "openrouter"
config.llm_base_url = "https://openrouter.ai/api/v1"
config.llm_model = "openai/gpt-4o"
config.save()

loaded = BlitztextConfig(config_dir=config_dir)
assert loaded.llm_provider == "openrouter"
assert loaded.llm_base_url == "https://openrouter.ai/api/v1"
assert loaded.llm_model == "openai/gpt-4o"

def test_save_never_writes_api_key_with_provider_fields(self, config_dir):
config = BlitztextConfig(config_dir=config_dir)
config.llm_provider = "openrouter"
config.openai_api_key_env = "OPENROUTER_API_KEY"
config.save()

saved = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
assert "openai_api_key" not in saved
assert saved["openai_api_key_env"] == "OPENROUTER_API_KEY"
assert saved["llm_provider"] == "openrouter"


class TestTranscriptionHotkey:
def test_valid_hotkey_is_accepted(self, config):
config.transcription_hotkey = "KEY_F13"
Expand Down
35 changes: 35 additions & 0 deletions tests/test_llm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,41 @@ def test_custom_terms_are_stored(self, mock_client):
assert service.custom_terms == CUSTOM_TERMS


class TestProviderConfig:
def test_default_model_is_gpt_4o_mini(self, service):
assert service.model == "gpt-4o-mini"

def test_custom_model_is_used_in_requests(self, mock_client):
service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, model="openai/gpt-4o")
service.text_improver("text")
kwargs = mock_client.chat.completions.create.call_args.kwargs
assert kwargs["model"] == "openai/gpt-4o"

def test_empty_model_falls_back_to_default(self, mock_client):
service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, model="")
assert service.model == "gpt-4o-mini"

def test_base_url_stored_on_service(self, mock_client):
service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, base_url="https://openrouter.ai/api/v1")
assert service.base_url == "https://openrouter.ai/api/v1"

def test_base_url_passed_to_openai_client(self):
# openai wird in LLMService.__init__ lazy importiert; via sys.modules
# injizieren wir einen Fake, damit der Test ohne echtes openai-Paket laeuft.
fake_openai = MagicMock()
with patch.dict("sys.modules", {"openai": fake_openai}):
LLMService(api_key=DUMMY_API_KEY, base_url="https://openrouter.ai/api/v1")
fake_openai.OpenAI.assert_called_once_with(
api_key=DUMMY_API_KEY, base_url="https://openrouter.ai/api/v1"
)

def test_empty_base_url_uses_sdk_default(self):
fake_openai = MagicMock()
with patch.dict("sys.modules", {"openai": fake_openai}):
LLMService(api_key=DUMMY_API_KEY, base_url="")
fake_openai.OpenAI.assert_called_once_with(api_key=DUMMY_API_KEY, base_url=None)


class TestDampfAblassen:
def test_returns_string(self, service):
result = service.dampf_ablassen(RAW_TRANSCRIPT)
Expand Down
Loading