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
37 changes: 36 additions & 1 deletion app/blitztext_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"}:
Comment on lines +578 to +581

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 Restore custom-provider models when switching back in settings

When settings are opened with a custom endpoint, switching to OpenAI or OpenRouter and then back to Custom replaces the original model with the other provider's model. Although _model_by_provider is initialized with the custom model, this handler neither remembers edits made for Custom nor restores its saved entry. For example, a custom endpoint using llama3.2 ends up with openai/gpt-4o-mini after an OpenRouter round trip; saving persists that incompatible model and subsequent requests can fail. Include Custom in the model preservation and restoration logic while retaining provider-specific defaults for OpenAI and OpenRouter.

Useful? React with 👍 / 👎.

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", {})
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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"),
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
27 changes: 27 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
23 changes: 23 additions & 0 deletions tests/test_state_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions tests/test_tray_preset_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading