From 7a0da6bbac798e46ff70b3b271e4acc495a494e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=3D=3DTIM=2E=C2=A9=2EB=20=20=3D=3D?= <129355283+TimInTech@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:33:43 +0100 Subject: [PATCH] fix: preserve provider models and clear stop errors --- app/blitztext_linux.py | 37 +++++++++++++++++++++++++++++++++- app/config.py | 27 +++++++++++++++++++++++++ tests/test_state_machine.py | 23 +++++++++++++++++++++ tests/test_tray_preset_menu.py | 16 +++++++++++++++ 4 files changed, 102 insertions(+), 1 deletion(-) diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index 3e312e6..65fd354 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -33,6 +33,7 @@ from app.config import ( Config, DEFAULTS, + DEFAULT_LLM_PROVIDER_MODELS, VALID_HOTKEY_KEYS, normalize_llm_api_key_env, validate_llm_provider_settings, @@ -287,6 +288,12 @@ def init_ui(self) -> None: self._env_by_provider["openai"] = self.config.openai_provider_key_env if self._env_provider != "openrouter" and self.config.openrouter_provider_key_env: self._env_by_provider["openrouter"] = self.config.openrouter_provider_key_env + self._model_provider = self.config.llm_provider + self._model_by_provider = {self._model_provider: self.config.llm_model} + if self._model_provider != "openai" and self.config.openai_provider_model: + self._model_by_provider["openai"] = self.config.openai_provider_model + if self._model_provider != "openrouter" and self.config.openrouter_provider_model: + self._model_by_provider["openrouter"] = self.config.openrouter_provider_model self.lbl_api_key_status = QLabel() self.lbl_api_key_status.setWordWrap(True) @@ -564,6 +571,17 @@ def _refresh_api_key_status(self) -> None: def _on_llm_provider_changed(self) -> None: provider = self.combo_llm_provider.currentData() + model_field = getattr(self, "edit_llm_model", None) + if model_field is not None: + remembered_models = getattr(self, "_model_by_provider", {}) + previous_model_provider = getattr(self, "_model_provider", None) + if previous_model_provider in {"openai", "openrouter"}: + remembered_models[previous_model_provider] = model_field.text().strip() + self._model_provider = provider + if provider in {"openai", "openrouter"}: + model_field.setText( + remembered_models.get(provider) or DEFAULT_LLM_PROVIDER_MODELS[provider] + ) api_key_field = getattr(self, "edit_api_key_env", None) if api_key_field is not None: remembered = getattr(self, "_env_by_provider", {}) @@ -660,6 +678,13 @@ def save_settings(self) -> None: self.config.openai_provider_key_env = _parked_env(self.config, remembered, "openai", provider) self.config.openrouter_provider_key_env = _parked_env( self.config, remembered, "openrouter", provider) + remembered_models = getattr(self, "_model_by_provider", {}) + self.config.openai_provider_model = ( + "" if provider == "openai" else remembered_models.get("openai", "") + ) + self.config.openrouter_provider_model = ( + "" if provider == "openrouter" else remembered_models.get("openrouter", "") + ) self.config.openai_api_key_env = api_key_env self.config.llm_provider = provider self.config.llm_base_url = base_url @@ -1130,11 +1155,14 @@ def main_window_provider_changed(self, provider: str) -> None: previous = ( cfg.llm_provider, cfg.llm_base_url, cfg.openai_api_key_env, cfg.openai_provider_key_env, cfg.openrouter_provider_key_env, + cfg.llm_model, cfg.openai_provider_model, cfg.openrouter_provider_model, ) problem = None if provider == "custom" or cfg.llm_provider == "custom": problem = (t("settings.save_error.title"), t("mainwindow.provider.custom_in_settings.message")) else: + if cfg.llm_provider in {"openai", "openrouter"}: + setattr(cfg, f"{cfg.llm_provider}_provider_model", cfg.llm_model) if cfg.llm_provider == "openai": # Park a personal OpenAI key variable (its name only) in the config so the # way back restores it, also after a restart. @@ -1154,6 +1182,9 @@ def main_window_provider_changed(self, provider: str) -> None: cfg.openai_provider_key_env or DEFAULTS["openai_api_key_env"] ) cfg.openai_provider_key_env = "" + cfg.llm_model = ( + getattr(cfg, f"{provider}_provider_model") or DEFAULT_LLM_PROVIDER_MODELS[provider] + ) if not cfg.resolve_llm_api_key(): problem = ( t("mainwindow.provider.no_key.title"), @@ -1170,7 +1201,8 @@ def main_window_provider_changed(self, provider: str) -> None: ) if problem is not None: (cfg.llm_provider, cfg.llm_base_url, cfg.openai_api_key_env, - cfg.openai_provider_key_env, cfg.openrouter_provider_key_env) = previous + cfg.openai_provider_key_env, cfg.openrouter_provider_key_env, + cfg.llm_model, cfg.openai_provider_model, cfg.openrouter_provider_model) = previous if self._main_window is not None: self._main_window.set_llm_provider(cfg.llm_provider) self.show_tray_warning(*problem) @@ -1398,6 +1430,9 @@ def _stop_recording_and_process(self) -> None: skip_llm = self._recording_routes_to_compose route_to_compose = skip_llm and self._compose_voice_routing_enabled() wav_path = self.audio_recorder.stop() + # A previous stop attempt may have failed while leaving the owned recorder + # alive for retry. Once stop() succeeds, that stale error no longer applies. + self._tray_error_message = None self._recording_routes_to_compose = False if not wav_path: logger.warning("No audio was recorded") diff --git a/app/config.py b/app/config.py index d54f39d..ef47295 100644 --- a/app/config.py +++ b/app/config.py @@ -39,6 +39,9 @@ "openai_provider_key_env": "", # Same for a personal OpenRouter variable while OpenAI is active. "openrouter_provider_key_env": "", + # Last model used with each provider while another provider is active. + "openai_provider_model": "", + "openrouter_provider_model": "", "llm_provider": "openai", "llm_base_url": "", "llm_model": "gpt-4o-mini", @@ -68,6 +71,10 @@ "compose_signature_auto_append": False, "compose_custom_preset_text": "", } +DEFAULT_LLM_PROVIDER_MODELS = { + "openai": DEFAULTS["llm_model"], + "openrouter": "openai/gpt-4o-mini", +} VALID_MODELS = {"tiny", "base", "small", "medium", "large", "large-v2", "large-v3", "large-v3-turbo"} VALID_BACKENDS = {"openai-whisper", "faster-whisper", "openrouter", "openai-cloud"} @@ -223,6 +230,24 @@ def openrouter_provider_key_env(self) -> str: def openrouter_provider_key_env(self, value: str) -> None: self._data["openrouter_provider_key_env"] = _normalize_env_var_name(value, "") + @property + def openai_provider_model(self) -> str: + value = self._data.get("openai_provider_model", "") + return value.strip() if isinstance(value, str) else "" + + @openai_provider_model.setter + def openai_provider_model(self, value: str) -> None: + self._data["openai_provider_model"] = value.strip() if isinstance(value, str) else "" + + @property + def openrouter_provider_model(self) -> str: + value = self._data.get("openrouter_provider_model", "") + return value.strip() if isinstance(value, str) else "" + + @openrouter_provider_model.setter + def openrouter_provider_model(self, value: str) -> None: + self._data["openrouter_provider_model"] = value.strip() if isinstance(value, str) else "" + def resolve_openai_api_key(self) -> str: env_name = self.openai_api_key_env env_value = os.environ.get(env_name, "").strip() @@ -632,6 +657,8 @@ def _validate_and_sanitize(self) -> None: ) self._data["openai_provider_key_env"] = self.openai_provider_key_env self._data["openrouter_provider_key_env"] = self.openrouter_provider_key_env + self._data["openai_provider_model"] = self.openai_provider_model + self._data["openrouter_provider_model"] = self.openrouter_provider_model self._data.pop("openai_api_key", None) if self._data.get("llm_provider") not in VALID_LLM_PROVIDERS: diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py index d6b8377..a57a04f 100644 --- a/tests/test_state_machine.py +++ b/tests/test_state_machine.py @@ -582,6 +582,29 @@ def test_state_returns_to_idle_after_error(self, gui_app): assert gui_app.state == "IDLE" assert gui_app.current_workflow is None + def test_successful_retry_clears_prior_recorder_stop_error(self, gui_app, tmp_path): + from app.audio_recorder import AudioRecorderError + + wav_path = tmp_path / "retry.wav" + pool = MagicMock() + gui_app.state = "RECORDING" + gui_app.current_workflow = WorkflowType.TRANSCRIPTION + with patch.object( + gui_app, "show_tray_error", + side_effect=lambda _title, message: setattr(gui_app, "_tray_error_message", message), + ), patch.object(gui_app.audio_recorder, "is_recording", return_value=True), \ + patch.object(gui_app.audio_recorder, "stop", side_effect=[ + AudioRecorderError("transient stop failure"), wav_path, + ]), patch("app.blitztext_linux.QThreadPool.globalInstance", return_value=pool): + gui_app._stop_recording_and_process() + assert gui_app._tray_error_message is not None + + gui_app._stop_recording_and_process() + + assert gui_app._tray_error_message is None + assert gui_app.state == "TRANSCRIBING" + pool.start.assert_called_once() + def test_orange_clears_after_result(self, gui_app): """Nach result darf der State nicht mehr auf einem Orange-Zustand stehen.""" gui_app.state = "TRANSCRIBING" diff --git a/tests/test_tray_preset_menu.py b/tests/test_tray_preset_menu.py index 17285f5..84c0765 100644 --- a/tests/test_tray_preset_menu.py +++ b/tests/test_tray_preset_menu.py @@ -838,6 +838,22 @@ def test_quick_switch_round_trip_keeps_both_custom_variables(self, tray_app, mon assert cfg.openai_provider_key_env == "WORK_OPENAI_KEY" assert cfg.openrouter_provider_key_env == "" + def test_quick_switch_remembers_provider_models_across_restart(self, tray_app, monkeypatch): + from app.config import BlitztextConfig + + cfg = self._setup(tray_app, monkeypatch) + cfg.llm_model = "anthropic/claude-3.5-sonnet" + tray_app.main_window_provider_changed("openai") + assert cfg.llm_model == "gpt-4o-mini" + cfg.llm_model = "gpt-4.1" + cfg.save() + + tray_app.config = BlitztextConfig(config_dir=cfg.config_dir) + tray_app.main_window_provider_changed("openrouter") + assert tray_app.config.llm_model == "anthropic/claude-3.5-sonnet" + tray_app.main_window_provider_changed("openai") + assert tray_app.config.llm_model == "gpt-4.1" + def test_round_trip_survives_a_restart(self, tray_app, monkeypatch): from app.config import BlitztextConfig