feat(config): support keyless local model endpoints

- Add an explicit no-key option for local model configurations
- Persist keyless profiles while retaining cloud credential validation
- Use an internal SDK placeholder only for keyless endpoints
- Cover keyless vault storage and environment synchronization
This commit is contained in:
DazedAnon 2026-07-22 16:49:19 -05:00
parent df04f44db4
commit e504c5aae3
4 changed files with 134 additions and 26 deletions

View file

@ -66,7 +66,9 @@ class ModelFetchThread(QThread):
def _fetch_openai(self): def _fetch_openai(self):
import openai 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: if self.api_url:
kwargs["base_url"] = self.api_url kwargs["base_url"] = self.api_url
client = openai.OpenAI(**kwargs) client = openai.OpenAI(**kwargs)
@ -128,6 +130,7 @@ class ApiKeyEditDialog(QDialog):
initial_endpoint: str = "", initial_endpoint: str = "",
*, *,
allow_blank_secret: bool = False, allow_blank_secret: bool = False,
initial_keyless: bool = False,
): ):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("API Key") self.setWindowTitle("API Key")
@ -153,6 +156,14 @@ class ApiKeyEditDialog(QDialog):
self.secret_edit.setPlaceholderText("Paste API key secret") self.secret_edit.setPlaceholderText("Paste API key secret")
form.addRow("Secret:", self.secret_edit) 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_wrap = QWidget()
endpoint_row = QHBoxLayout(endpoint_wrap) endpoint_row = QHBoxLayout(endpoint_wrap)
endpoint_row.setContentsMargins(0, 0, 0, 0) endpoint_row.setContentsMargins(0, 0, 0, 0)
@ -202,27 +213,47 @@ class ApiKeyEditDialog(QDialog):
self._name = "" self._name = ""
self._secret = "" self._secret = ""
self._endpoint = "" self._endpoint = ""
self._keyless = False
self._on_keyless_toggled(initial_keyless)
self.secret_edit.setFocus() 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): def _accept_if_valid(self):
name = self.name_edit.text().strip() name = self.name_edit.text().strip()
secret = self.secret_edit.text().strip() secret = self.secret_edit.text().strip()
endpoint = self.endpoint_edit.text().strip() endpoint = self.endpoint_edit.text().strip()
keyless = self.keyless_cb.isChecked()
if not name: if not name:
QMessageBox.warning(self, "API Key", "Enter a name for this key.") QMessageBox.warning(self, "API Key", "Enter a name for this key.")
self.name_edit.setFocus() self.name_edit.setFocus()
return 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.") QMessageBox.warning(self, "API Key", "Enter the API key secret.")
self.secret_edit.setFocus() self.secret_edit.setFocus()
return 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._name = name
self._secret = secret self._secret = secret
self._endpoint = endpoint self._endpoint = endpoint
self._keyless = keyless
self.accept() self.accept()
def result_values(self) -> tuple[str, str, str]: def result_values(self) -> tuple[str, str, str, bool]:
return self._name, self._secret, self._endpoint return self._name, self._secret, self._endpoint, self._keyless
class ConfigTab(QWidget): class ConfigTab(QWidget):
@ -792,6 +823,11 @@ class ConfigTab(QWidget):
secret = api_key_vault.get_secret(name) secret = api_key_vault.get_secret(name)
return secret if secret is not None else "" 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): def _apply_key_endpoint(self, name: str | None = None):
"""If the named key has an endpoint, apply it to the API URL field.""" """If the named key has an endpoint, apply it to the API URL field."""
key_name = (name or self.api_key_combo.currentText()).strip() key_name = (name or self.api_key_combo.currentText()).strip()
@ -846,10 +882,11 @@ class ConfigTab(QWidget):
initial_name=current, initial_name=current,
initial_endpoint=existing_endpoint or self.api_url_edit.text().strip(), initial_endpoint=existing_endpoint or self.api_url_edit.text().strip(),
allow_blank_secret=bool(current and api_key_vault.get_secret(current)), 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: if dialog.exec_() != QDialog.Accepted:
return return
name, secret, endpoint = dialog.result_values() name, secret, endpoint, keyless = dialog.result_values()
try: try:
api_key_vault.upsert_key( api_key_vault.upsert_key(
name, name,
@ -857,6 +894,7 @@ class ConfigTab(QWidget):
endpoint=endpoint, endpoint=endpoint,
make_active=True, make_active=True,
keep_secret_if_blank=True, keep_secret_if_blank=True,
keyless=keyless,
) )
self._apply_key_endpoint(name) self._apply_key_endpoint(name)
api_key_vault.sync_active_to_env(env_path=self.env_file_path) 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_key = self._active_api_key_secret()
api_url = self.api_url_edit.text().strip() 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: if not silent:
QMessageBox.warning( QMessageBox.warning(
self, "No API Key", self, "No API Key",
@ -1107,6 +1145,9 @@ class ConfigTab(QWidget):
config = { config = {
"api": self.api_url_edit.text().strip(), "api": self.api_url_edit.text().strip(),
"key": self._active_api_key_secret(), "key": self._active_api_key_secret(),
"API_KEY_OPTIONAL": (
"true" if self._active_api_key_is_optional() else "false"
),
"model": self.model_combo.currentText(), "model": self.model_combo.currentText(),
"language": self.language_combo.currentText(), "language": self.language_combo.currentText(),
"timeout": str(self.timeout_spin.value()), "timeout": str(self.timeout_spin.value()),
@ -1241,7 +1282,7 @@ class ConfigTab(QWidget):
errors = [] errors = []
# Check required fields # 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") errors.append("API Key is required")
if not self.model_combo.currentText().strip(): if not self.model_combo.currentText().strip():

View file

@ -91,6 +91,7 @@ class ApiKeyVaultTests(unittest.TestCase):
) )
self.assertEqual(secret, "sk-work") self.assertEqual(secret, "sk-work")
self.assertEqual(os.environ.get("key"), "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") self.assertEqual(os.environ.get("api"), "https://api.work.test/v1")
text = self.env_path.read_text(encoding="utf-8") text = self.env_path.read_text(encoding="utf-8")
self.assertIn("sk-work", text) self.assertIn("sk-work", text)
@ -162,6 +163,36 @@ class ApiKeyVaultTests(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
api_keys.upsert_key("Name", " ", path=self.vault_path) 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__": if __name__ == "__main__":
unittest.main(verbosity=2) unittest.main(verbosity=2)

View file

@ -3,8 +3,10 @@
Secrets live in ``data/api_keys.json`` (user-local). The active secret is also Secrets live in ``data/api_keys.json`` (user-local). The active secret is also
mirrored to ``.env`` ``key=`` so translation and subprocesses keep working. mirrored to ``.env`` ``key=`` so translation and subprocesses keep working.
Each named entry may optionally include an ``endpoint`` (API base URL). When the Each named entry may optionally include an ``endpoint`` (API base URL), and may
active key has one, it is mirrored to ``.env`` ``api=`` as well. 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 from __future__ import annotations
@ -25,18 +27,19 @@ def _empty_vault() -> dict[str, Any]:
return {"active": "", "keys": {}} 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.""" """Accept legacy plain-string secrets or ``{secret, endpoint}`` objects."""
if isinstance(raw, str): if isinstance(raw, str):
return {"secret": raw, "endpoint": ""} return {"secret": raw, "endpoint": "", "keyless": False}
if isinstance(raw, dict): if isinstance(raw, dict):
secret = raw.get("secret", raw.get("key", "")) secret = raw.get("secret", raw.get("key", ""))
endpoint = raw.get("endpoint", raw.get("api", "")) endpoint = raw.get("endpoint", raw.get("api", ""))
return { return {
"secret": "" if secret is None else str(secret), "secret": "" if secret is None else str(secret),
"endpoint": ("" if endpoint is None else str(endpoint)).strip(), "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]: 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") keys = data.get("keys")
if not isinstance(keys, dict): if not isinstance(keys, dict):
keys = {} keys = {}
cleaned: dict[str, dict[str, str]] = {} cleaned: dict[str, dict[str, Any]] = {}
for name, raw in keys.items(): for name, raw in keys.items():
if not isinstance(name, str) or not name.strip(): if not isinstance(name, str) or not name.strip():
continue continue
entry = _normalize_entry(raw) entry = _normalize_entry(raw)
if not entry["secret"]: if not entry["secret"] and not entry["keyless"]:
continue continue
cleaned[name.strip()] = entry cleaned[name.strip()] = entry
active = data.get("active") or "" 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.""" """Write vault JSON with restrictive permissions when the OS allows."""
vault_path = path or API_KEYS_PATH vault_path = path or API_KEYS_PATH
vault_path.parent.mkdir(parents=True, exist_ok=True) 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(): for name, raw in dict(vault.get("keys") or {}).items():
entry = _normalize_entry(raw) entry = _normalize_entry(raw)
if not entry["secret"]: if not entry["secret"] and not entry["keyless"]:
continue continue
keys_out[str(name)] = { keys_out[str(name)] = {
"secret": entry["secret"], "secret": entry["secret"],
"endpoint": entry["endpoint"], "endpoint": entry["endpoint"],
"keyless": entry["keyless"],
} }
payload = { payload = {
"active": str(vault.get("active") or ""), "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 "") 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 {} keys = load_vault(path).get("keys") or {}
entry = keys.get(name) entry = keys.get(name)
if entry is None: if entry is None:
@ -144,6 +148,18 @@ def get_endpoint(name: str, path: Path | None = None) -> str | None:
return entry["endpoint"] 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: def get_active_endpoint(path: Path | None = None) -> str:
vault = load_vault(path) vault = load_vault(path)
active = vault.get("active") or "" active = vault.get("active") or ""
@ -160,11 +176,13 @@ def upsert_key(
make_active: bool = True, make_active: bool = True,
path: Path | None = None, path: Path | None = None,
keep_secret_if_blank: bool = False, keep_secret_if_blank: bool = False,
keyless: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Create or replace a named key. Optionally make it active. """Create or replace a named key. Optionally make it active.
When *keep_secret_if_blank* is true and *secret* is empty, an existing 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() name = (name or "").strip()
if not name: if not name:
@ -173,12 +191,21 @@ def upsert_key(
endpoint = (endpoint or "").strip() endpoint = (endpoint or "").strip()
vault = load_vault(path) vault = load_vault(path)
existing = vault["keys"].get(name) 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: if keep_secret_if_blank and existing:
secret = _normalize_entry(existing)["secret"] secret = _normalize_entry(existing)["secret"]
else: else:
raise ValueError("API key secret is required") 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"): if make_active or not vault.get("active"):
vault["active"] = name vault["active"] = name
save_vault(vault, path) save_vault(vault, path)
@ -214,7 +241,9 @@ def sync_active_to_env(
) -> str: ) -> str:
"""Write the active vault secret to ``.env`` ``key=`` and ``os.environ``. """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). Returns the secret that was written (may be empty).
""" """
from dotenv import set_key from dotenv import set_key
@ -226,6 +255,9 @@ def sync_active_to_env(
target.touch() target.touch()
set_key(target, "key", secret) set_key(target, "key", secret)
os.environ["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: if endpoint:
set_key(target, "api", endpoint) set_key(target, "api", endpoint)
os.environ["api"] = endpoint os.environ["api"] = endpoint

View file

@ -392,8 +392,12 @@ else:
if org: if org:
openai.organization = org.strip() openai.organization = org.strip()
# Always set API key from 'key' env var (trim whitespace) # The SDK itself requires a non-empty value even when a local compatible server
openai.api_key = os.getenv("key", "").strip() # 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 # Translation cache management
CACHE_FILE = Path("log/translation_cache.json") 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/" openai.base_url = "https://api.mistral.ai/v1/"
elif _live_api: elif _live_api:
openai.base_url = _normalize_openai_base_url(_live_api) openai.base_url = _normalize_openai_base_url(_live_api)
if _live_key: _live_key_optional = os.getenv("API_KEY_OPTIONAL", "").strip().lower() in ("1", "true", "yes")
openai.api_key = _live_key openai.api_key = _live_key or ("not-needed" if _live_key_optional else "")
api_provider = _live_provider api_provider = _live_provider
@ -3278,4 +3282,4 @@ def translateAI(text, history, config, filename=None, pbar=None, lock=None, mism
if isinstance(text, list): if isinstance(text, list):
return [tList, totalTokens] return [tList, totalTokens]
else: else:
return [tList[0], totalTokens] return [tList[0], totalTokens]