diff --git a/.gitignore b/.gitignore index 2940e5d..7934cb7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ .env .env.* secrets.env +.venv/ diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index c1212bb..026eb18 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -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) @@ -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) @@ -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) + 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. @@ -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() @@ -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) @@ -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 + 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 = { @@ -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 diff --git a/app/config.py b/app/config.py index 0f2c0d2..57dfc1f 100644 --- a/app/config.py +++ b/app/config.py @@ -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"), @@ -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", @@ -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"] @@ -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"] = {} @@ -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 [] diff --git a/app/llm_service.py b/app/llm_service.py index f60fe76..c0edfae 100644 --- a/app/llm_service.py +++ b/app/llm_service.py @@ -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 @@ -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 @@ -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()}, @@ -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()}, @@ -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()}, diff --git a/tests/test_config.py b/tests/test_config.py index 743a765..303f9d3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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" diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index 0e4e3fd..7ddab6d 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -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) diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 4b823db..00ead8e 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -107,6 +107,7 @@ def currentData(self): class _Edit: def __init__(self, text=""): self._text = text + self.enabled = True def text(self): return self._text @@ -114,6 +115,12 @@ def text(self): def toPlainText(self): return self._text + def setText(self, value): + self._text = value + + def setEnabled(self, value): + self.enabled = bool(value) + class _Check: def __init__(self, checked=True): @@ -134,6 +141,9 @@ def _fake_save_self(config_dir, preset_key): combo_hotkey_mode=_Combo("hold"), combo_transcription_key=_Combo("KEY_LEFTALT"), edit_api_key_env=_Edit("OPENAI_API_KEY"), + combo_llm_provider=_Combo(text="OpenRouter", data="openrouter"), + edit_base_url=_Edit("https://openrouter.ai/api/v1"), + edit_llm_model=_Edit("openai/gpt-4o"), combo_tone=_Combo("neutral"), combo_writing_preset=_Combo(text="E-Mail – formell", data=preset_key), combo_emoji=_Combo("mittel"), @@ -157,6 +167,82 @@ def test_save_settings_persists_writing_preset(tmp_path): assert reloaded.writing_preset == "email_formal" +def test_save_settings_persists_llm_provider_fields(tmp_path): + config_dir = tmp_path / ".config" / "blitztext-linux" + fake = _fake_save_self(config_dir, "standard") + + SettingsDialog.save_settings(fake) + + reloaded = BlitztextConfig(config_dir=config_dir) + assert reloaded.llm_provider == "openrouter" + assert reloaded.llm_base_url == "https://openrouter.ai/api/v1" + assert reloaded.llm_model == "openai/gpt-4o" + + +def test_build_llm_service_includes_base_url_and_model(tmp_path): + # Regression: beide Konstruktionsorte (Init + Settings-Save) gehen ueber + # _build_llm_service, damit base_url/model nicht an einer Stelle fehlen. + from app.blitztext_linux import BlitztextApp + + config_dir = tmp_path / ".config" / "blitztext-linux" + config = BlitztextConfig(config_dir=config_dir) + config.llm_provider = "openrouter" + config.llm_base_url = "https://openrouter.ai/api/v1" + config.llm_model = "openai/gpt-4o" + fake = SimpleNamespace(config=config) + + service = BlitztextApp._build_llm_service(fake) + + assert service.base_url == "https://openrouter.ai/api/v1" + assert service.model == "openai/gpt-4o" + + +def test_build_llm_service_ignores_base_url_when_provider_is_openai(tmp_path): + # Provider ist autoritativ: bei "openai" darf eine (z. B. manuell in config.json + # gesetzte) base_url NICHT verwendet werden -> OpenAI-Standardendpunkt. + from app.blitztext_linux import BlitztextApp + + config_dir = tmp_path / ".config" / "blitztext-linux" + config = BlitztextConfig(config_dir=config_dir) + config.llm_provider = "openai" + config.llm_base_url = "https://openrouter.ai/api/v1" + config.llm_model = "gpt-4o" + fake = SimpleNamespace(config=config) + + service = BlitztextApp._build_llm_service(fake) + + assert service.base_url == "" + assert service.model == "gpt-4o" + + +def test_provider_change_prefills_openrouter_base_url(): + fake = SimpleNamespace( + combo_llm_provider=_Combo(text="OpenRouter", data="openrouter"), + edit_base_url=_Edit(""), + ) + SettingsDialog._on_llm_provider_changed(fake) + assert fake.edit_base_url.text() == "https://openrouter.ai/api/v1" + + +def test_provider_change_to_openai_clears_and_disables_base_url(): + fake = SimpleNamespace( + combo_llm_provider=_Combo(text="OpenAI", data="openai"), + edit_base_url=_Edit("https://openrouter.ai/api/v1"), + ) + SettingsDialog._on_llm_provider_changed(fake) + assert fake.edit_base_url.text() == "" + assert fake.edit_base_url.enabled is False + + +def test_provider_change_does_not_overwrite_existing_base_url(): + fake = SimpleNamespace( + combo_llm_provider=_Combo(text="OpenRouter", data="openrouter"), + edit_base_url=_Edit("https://my-proxy/api/v1"), + ) + SettingsDialog._on_llm_provider_changed(fake) + assert fake.edit_base_url.text() == "https://my-proxy/api/v1" + + def test_save_settings_keeps_standard_preset(tmp_path): config_dir = tmp_path / ".config" / "blitztext-linux" fake = _fake_save_self(config_dir, "standard")