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 thread
TimInTech marked this conversation as resolved.
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
Comment thread
TimInTech marked this conversation as resolved.
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