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:
parent
df04f44db4
commit
e504c5aae3
4 changed files with 134 additions and 26 deletions
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
return [tList[0], totalTokens]
|
||||
|
|
|
|||
Loading…
Reference in a new issue