diff --git a/gui/config_tab.py b/gui/config_tab.py index efd7402..f0dd146 100644 --- a/gui/config_tab.py +++ b/gui/config_tab.py @@ -66,7 +66,9 @@ class ModelFetchThread(QThread): def _fetch_openai(self): import openai - kwargs = {"api_key": self.api_key} + # The SDK requires a non-empty value even when a local server ignores + # authentication entirely. + kwargs = {"api_key": self.api_key or "not-needed"} if self.api_url: kwargs["base_url"] = self.api_url client = openai.OpenAI(**kwargs) @@ -128,6 +130,7 @@ class ApiKeyEditDialog(QDialog): initial_endpoint: str = "", *, allow_blank_secret: bool = False, + initial_keyless: bool = False, ): super().__init__(parent) self.setWindowTitle("API Key") @@ -153,6 +156,14 @@ class ApiKeyEditDialog(QDialog): self.secret_edit.setPlaceholderText("Paste API key secret") form.addRow("Secret:", self.secret_edit) + self.keyless_cb = QCheckBox("No API key required (local model)") + self.keyless_cb.setChecked(initial_keyless) + self.keyless_cb.setToolTip( + "Use this for a local OpenAI-compatible server that does not require authentication." + ) + self.keyless_cb.toggled.connect(self._on_keyless_toggled) + form.addRow("", self.keyless_cb) + endpoint_wrap = QWidget() endpoint_row = QHBoxLayout(endpoint_wrap) endpoint_row.setContentsMargins(0, 0, 0, 0) @@ -202,27 +213,47 @@ class ApiKeyEditDialog(QDialog): self._name = "" self._secret = "" self._endpoint = "" + self._keyless = False + self._on_keyless_toggled(initial_keyless) self.secret_edit.setFocus() + def _on_keyless_toggled(self, checked: bool): + self.secret_edit.setEnabled(not checked) + if checked: + self.secret_edit.clear() + self.secret_edit.setPlaceholderText("Not used for this local endpoint") + elif self._allow_blank_secret: + self.secret_edit.setPlaceholderText("Leave blank to keep the existing secret") + else: + self.secret_edit.setPlaceholderText("Paste API key secret") + def _accept_if_valid(self): name = self.name_edit.text().strip() secret = self.secret_edit.text().strip() endpoint = self.endpoint_edit.text().strip() + keyless = self.keyless_cb.isChecked() if not name: QMessageBox.warning(self, "API Key", "Enter a name for this key.") self.name_edit.setFocus() return - if not secret and not self._allow_blank_secret: + if not secret and not keyless and not self._allow_blank_secret: QMessageBox.warning(self, "API Key", "Enter the API key secret.") self.secret_edit.setFocus() return + if keyless and not endpoint: + QMessageBox.warning( + self, "API Key", "Enter the local model server's API endpoint." + ) + self.endpoint_edit.setFocus() + return self._name = name self._secret = secret self._endpoint = endpoint + self._keyless = keyless self.accept() - def result_values(self) -> tuple[str, str, str]: - return self._name, self._secret, self._endpoint + def result_values(self) -> tuple[str, str, str, bool]: + return self._name, self._secret, self._endpoint, self._keyless class ConfigTab(QWidget): @@ -792,6 +823,11 @@ class ConfigTab(QWidget): secret = api_key_vault.get_secret(name) return secret if secret is not None else "" + def _active_api_key_is_optional(self) -> bool: + """Return whether the selected vault entry is explicitly keyless.""" + name = self.api_key_combo.currentText().strip() + return api_key_vault.is_keyless(name) if name else False + def _apply_key_endpoint(self, name: str | None = None): """If the named key has an endpoint, apply it to the API URL field.""" key_name = (name or self.api_key_combo.currentText()).strip() @@ -846,10 +882,11 @@ class ConfigTab(QWidget): initial_name=current, initial_endpoint=existing_endpoint or self.api_url_edit.text().strip(), allow_blank_secret=bool(current and api_key_vault.get_secret(current)), + initial_keyless=bool(current and api_key_vault.is_keyless(current)), ) if dialog.exec_() != QDialog.Accepted: return - name, secret, endpoint = dialog.result_values() + name, secret, endpoint, keyless = dialog.result_values() try: api_key_vault.upsert_key( name, @@ -857,6 +894,7 @@ class ConfigTab(QWidget): endpoint=endpoint, make_active=True, keep_secret_if_blank=True, + keyless=keyless, ) self._apply_key_endpoint(name) api_key_vault.sync_active_to_env(env_path=self.env_file_path) @@ -890,7 +928,7 @@ class ConfigTab(QWidget): api_key = self._active_api_key_secret() api_url = self.api_url_edit.text().strip() - if not api_key: + if not api_key and not self._active_api_key_is_optional(): if not silent: QMessageBox.warning( self, "No API Key", @@ -1107,6 +1145,9 @@ class ConfigTab(QWidget): config = { "api": self.api_url_edit.text().strip(), "key": self._active_api_key_secret(), + "API_KEY_OPTIONAL": ( + "true" if self._active_api_key_is_optional() else "false" + ), "model": self.model_combo.currentText(), "language": self.language_combo.currentText(), "timeout": str(self.timeout_spin.value()), @@ -1241,7 +1282,7 @@ class ConfigTab(QWidget): errors = [] # Check required fields - if not self._active_api_key_secret(): + if not self._active_api_key_secret() and not self._active_api_key_is_optional(): errors.append("API Key is required") if not self.model_combo.currentText().strip(): diff --git a/tests/test_api_keys.py b/tests/test_api_keys.py index 78dd248..e0140fa 100644 --- a/tests/test_api_keys.py +++ b/tests/test_api_keys.py @@ -91,6 +91,7 @@ class ApiKeyVaultTests(unittest.TestCase): ) self.assertEqual(secret, "sk-work") self.assertEqual(os.environ.get("key"), "sk-work") + self.assertEqual(os.environ.get("API_KEY_OPTIONAL"), "false") self.assertEqual(os.environ.get("api"), "https://api.work.test/v1") text = self.env_path.read_text(encoding="utf-8") self.assertIn("sk-work", text) @@ -162,6 +163,36 @@ class ApiKeyVaultTests(unittest.TestCase): with self.assertRaises(ValueError): api_keys.upsert_key("Name", " ", path=self.vault_path) + def test_keyless_local_endpoint_roundtrip_and_sync(self): + api_keys.upsert_key( + "Local", + "", + endpoint="http://127.0.0.1:1234/v1", + keyless=True, + path=self.vault_path, + ) + + self.assertEqual(api_keys.list_names(self.vault_path), ["Local"]) + self.assertEqual(api_keys.get_active_secret(self.vault_path), "") + self.assertTrue(api_keys.is_keyless("Local", self.vault_path)) + self.assertTrue(api_keys.is_active_keyless(self.vault_path)) + + with patch.dict(os.environ, {}, clear=False): + api_keys.sync_active_to_env( + vault_path=self.vault_path, + env_path=self.env_path, + ) + self.assertEqual(os.environ.get("key"), "") + self.assertEqual(os.environ.get("API_KEY_OPTIONAL"), "true") + text = self.env_path.read_text(encoding="utf-8") + self.assertIn("API_KEY_OPTIONAL='true'", text) + + def test_keyless_entry_requires_endpoint(self): + with self.assertRaises(ValueError): + api_keys.upsert_key( + "Local", "", keyless=True, path=self.vault_path + ) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/util/api_keys.py b/util/api_keys.py index 610e94d..5dbb932 100644 --- a/util/api_keys.py +++ b/util/api_keys.py @@ -3,8 +3,10 @@ Secrets live in ``data/api_keys.json`` (user-local). The active secret is also mirrored to ``.env`` ``key=`` so translation and subprocesses keep working. -Each named entry may optionally include an ``endpoint`` (API base URL). When the -active key has one, it is mirrored to ``.env`` ``api=`` as well. +Each named entry may optionally include an ``endpoint`` (API base URL), and may +be marked ``keyless`` for a local server that does not authenticate requests. +When the active entry has an endpoint, it is mirrored to ``.env`` ``api=`` as +well. """ from __future__ import annotations @@ -25,18 +27,19 @@ def _empty_vault() -> dict[str, Any]: return {"active": "", "keys": {}} -def _normalize_entry(raw: Any) -> dict[str, str]: +def _normalize_entry(raw: Any) -> dict[str, Any]: """Accept legacy plain-string secrets or ``{secret, endpoint}`` objects.""" if isinstance(raw, str): - return {"secret": raw, "endpoint": ""} + return {"secret": raw, "endpoint": "", "keyless": False} if isinstance(raw, dict): secret = raw.get("secret", raw.get("key", "")) endpoint = raw.get("endpoint", raw.get("api", "")) return { "secret": "" if secret is None else str(secret), "endpoint": ("" if endpoint is None else str(endpoint)).strip(), + "keyless": bool(raw.get("keyless", False)), } - return {"secret": "", "endpoint": ""} + return {"secret": "", "endpoint": "", "keyless": False} def load_vault(path: Path | None = None) -> dict[str, Any]: @@ -53,12 +56,12 @@ def load_vault(path: Path | None = None) -> dict[str, Any]: keys = data.get("keys") if not isinstance(keys, dict): keys = {} - cleaned: dict[str, dict[str, str]] = {} + cleaned: dict[str, dict[str, Any]] = {} for name, raw in keys.items(): if not isinstance(name, str) or not name.strip(): continue entry = _normalize_entry(raw) - if not entry["secret"]: + if not entry["secret"] and not entry["keyless"]: continue cleaned[name.strip()] = entry active = data.get("active") or "" @@ -76,14 +79,15 @@ def save_vault(vault: dict[str, Any], path: Path | None = None) -> None: """Write vault JSON with restrictive permissions when the OS allows.""" vault_path = path or API_KEYS_PATH vault_path.parent.mkdir(parents=True, exist_ok=True) - keys_out: dict[str, dict[str, str]] = {} + keys_out: dict[str, dict[str, Any]] = {} for name, raw in dict(vault.get("keys") or {}).items(): entry = _normalize_entry(raw) - if not entry["secret"]: + if not entry["secret"] and not entry["keyless"]: continue keys_out[str(name)] = { "secret": entry["secret"], "endpoint": entry["endpoint"], + "keyless": entry["keyless"], } payload = { "active": str(vault.get("active") or ""), @@ -112,7 +116,7 @@ def get_active_name(path: Path | None = None) -> str: return str(load_vault(path).get("active") or "") -def get_entry(name: str, path: Path | None = None) -> dict[str, str] | None: +def get_entry(name: str, path: Path | None = None) -> dict[str, Any] | None: keys = load_vault(path).get("keys") or {} entry = keys.get(name) if entry is None: @@ -144,6 +148,18 @@ def get_endpoint(name: str, path: Path | None = None) -> str | None: return entry["endpoint"] +def is_keyless(name: str, path: Path | None = None) -> bool: + """Return whether *name* explicitly represents a keyless local endpoint.""" + entry = get_entry(name, path) + return bool(entry and entry["keyless"]) + + +def is_active_keyless(path: Path | None = None) -> bool: + vault = load_vault(path) + active = str(vault.get("active") or "") + return is_keyless(active, path) if active else False + + def get_active_endpoint(path: Path | None = None) -> str: vault = load_vault(path) active = vault.get("active") or "" @@ -160,11 +176,13 @@ def upsert_key( make_active: bool = True, path: Path | None = None, keep_secret_if_blank: bool = False, + keyless: bool = False, ) -> dict[str, Any]: """Create or replace a named key. Optionally make it active. When *keep_secret_if_blank* is true and *secret* is empty, an existing - entry's secret is preserved (useful for editing endpoint only). + entry's secret is preserved (useful for editing endpoint only). A keyless + entry deliberately stores no secret and must provide a local endpoint. """ name = (name or "").strip() if not name: @@ -173,12 +191,21 @@ def upsert_key( endpoint = (endpoint or "").strip() vault = load_vault(path) existing = vault["keys"].get(name) - if not secret: + keyless = bool(keyless) + if keyless: + secret = "" + if not endpoint: + raise ValueError("A local API endpoint is required when no API key is used") + elif not secret: if keep_secret_if_blank and existing: secret = _normalize_entry(existing)["secret"] else: raise ValueError("API key secret is required") - vault["keys"][name] = {"secret": secret, "endpoint": endpoint} + vault["keys"][name] = { + "secret": secret, + "endpoint": endpoint, + "keyless": keyless, + } if make_active or not vault.get("active"): vault["active"] = name save_vault(vault, path) @@ -214,7 +241,9 @@ def sync_active_to_env( ) -> str: """Write the active vault secret to ``.env`` ``key=`` and ``os.environ``. - When the active key has an endpoint, also write ``api=``. + When the active key has an endpoint, also write ``api=``. The explicit + ``API_KEY_OPTIONAL`` flag lets OpenAI-compatible clients use a harmless + placeholder internally for local servers whose HTTP API needs no key. Returns the secret that was written (may be empty). """ from dotenv import set_key @@ -226,6 +255,9 @@ def sync_active_to_env( target.touch() set_key(target, "key", secret) os.environ["key"] = secret + key_optional = "true" if is_active_keyless(vault_path) else "false" + set_key(target, "API_KEY_OPTIONAL", key_optional) + os.environ["API_KEY_OPTIONAL"] = key_optional if endpoint: set_key(target, "api", endpoint) os.environ["api"] = endpoint diff --git a/util/translation.py b/util/translation.py index 3b2db5a..c139d1a 100644 --- a/util/translation.py +++ b/util/translation.py @@ -392,8 +392,12 @@ else: if org: openai.organization = org.strip() -# Always set API key from 'key' env var (trim whitespace) -openai.api_key = os.getenv("key", "").strip() +# The SDK itself requires a non-empty value even when a local compatible server +# does not authenticate requests. Only use the placeholder for an explicitly +# keyless vault entry so missing cloud credentials still fail normally. +_env_key = os.getenv("key", "").strip() +_key_optional = os.getenv("API_KEY_OPTIONAL", "").strip().lower() in ("1", "true", "yes") +openai.api_key = _env_key or ("not-needed" if _key_optional else "") # Translation cache management CACHE_FILE = Path("log/translation_cache.json") @@ -2166,8 +2170,8 @@ def translateText(system, user, history, penalty, formatType, model, numLines=No openai.base_url = "https://api.mistral.ai/v1/" elif _live_api: openai.base_url = _normalize_openai_base_url(_live_api) - if _live_key: - openai.api_key = _live_key + _live_key_optional = os.getenv("API_KEY_OPTIONAL", "").strip().lower() in ("1", "true", "yes") + openai.api_key = _live_key or ("not-needed" if _live_key_optional else "") api_provider = _live_provider @@ -3278,4 +3282,4 @@ def translateAI(text, history, config, filename=None, pbar=None, lock=None, mism if isinstance(text, list): return [tList, totalTokens] else: - return [tList[0], totalTokens] \ No newline at end of file + return [tList[0], totalTokens]