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):
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():

View file

@ -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)

View file

@ -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

View file

@ -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