feat(config): align settings UI and bind optional API endpoints to keys

- Rebuild General Settings on a shared label/field grid with Fusion controls
- Store optional per-key endpoints and apply them on key select
- Keep legacy string vault entries working and cover endpoint sync in tests
This commit is contained in:
DazedAnon 2026-07-22 14:57:33 -05:00
parent 1502050577
commit df04f44db4
4 changed files with 1443 additions and 1290 deletions

File diff suppressed because it is too large Load diff

View file

@ -804,6 +804,15 @@ class DazedMTLGUI(QMainWindow):
font.setPointSize(max(6, int(9 * scale_factor)))
app.setFont(font)
# Keep line edits / combos tall enough for the scaled font.
from PyQt5.QtGui import QFontMetrics
from PyQt5.QtWidgets import QComboBox, QDoubleSpinBox, QLineEdit, QSpinBox
control_h = max(30, QFontMetrics(font).height() + 14)
for widget in app.allWidgets():
if isinstance(widget, (QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox)):
widget.setMinimumHeight(control_h)
# Scale inline font-size values in every widget's individual stylesheet.
# We store the *original* stylesheet on each widget (using a Qt property)
# so that re-scaling always starts from the unmodified values.
@ -1309,6 +1318,7 @@ def main():
migrate_app_settings()
app = QApplication(sys.argv)
app.setStyle("Fusion")
# Windows taskbar groups by executable (python.exe) unless we set an explicit
# AppUserModelID; combine with QApplication window icon so the pinned icon
@ -1437,7 +1447,8 @@ def main():
background-color: #404040;
color: #ffffff;
border: 1px solid #555555;
padding: 5px 8px;
padding: 1px 10px;
min-height: 30px;
border-radius: 2px;
selection-background-color: #007acc;
}
@ -1448,7 +1459,8 @@ def main():
background-color: #404040;
color: #ffffff;
border: 1px solid #555555;
padding: 5px 8px;
padding: 1px 10px;
min-height: 30px;
border-radius: 2px;
}
QSpinBox:focus, QDoubleSpinBox:focus {
@ -1468,30 +1480,24 @@ def main():
background-color: #404040;
color: #ffffff;
border: 1px solid #555555;
padding: 5px;
padding-right: 30px;
padding: 1px 8px;
min-height: 30px;
border-radius: 2px;
}
QComboBox:focus {
border: 1px solid #007acc;
}
QComboBox::drop-down {
subcontrol-origin: padding;
subcontrol-position: center right;
width: 25px;
subcontrol-origin: border;
subcontrol-position: top right;
width: 20px;
border: none;
border-left: 1px solid #555555;
background-color: #555555;
background-color: #4a4a4a;
}
QComboBox::drop-down:hover {
background-color: #007acc;
}
QComboBox::down-arrow {
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid #ffffff;
}
QComboBox QAbstractItemView {
background-color: #404040;
color: #ffffff;

View file

@ -47,13 +47,18 @@ class ApiKeyVaultTests(unittest.TestCase):
self.assertEqual(api_keys.get_active_secret(self.vault_path), "")
def test_migrate_from_env_when_vault_empty(self):
self.env_path.write_text('key="sk-from-env"\n', encoding="utf-8")
self.env_path.write_text(
'key="sk-from-env"\napi="https://api.example.com/v1"\n',
encoding="utf-8",
)
vault = api_keys.migrate_from_env_if_empty(
vault_path=self.vault_path,
env_path=self.env_path,
)
self.assertEqual(vault["active"], api_keys.DEFAULT_KEY_NAME)
self.assertEqual(vault["keys"][api_keys.DEFAULT_KEY_NAME], "sk-from-env")
entry = vault["keys"][api_keys.DEFAULT_KEY_NAME]
self.assertEqual(entry["secret"], "sk-from-env")
self.assertEqual(entry["endpoint"], "https://api.example.com/v1")
# Second call must not overwrite existing vault entries.
self.env_path.write_text('key="sk-other"\n', encoding="utf-8")
@ -61,7 +66,9 @@ class ApiKeyVaultTests(unittest.TestCase):
vault_path=self.vault_path,
env_path=self.env_path,
)
self.assertEqual(vault2["keys"][api_keys.DEFAULT_KEY_NAME], "sk-from-env")
self.assertEqual(
vault2["keys"][api_keys.DEFAULT_KEY_NAME]["secret"], "sk-from-env"
)
def test_migrate_skips_placeholder_env_key(self):
vault = api_keys.migrate_from_env_if_empty(
@ -71,7 +78,12 @@ class ApiKeyVaultTests(unittest.TestCase):
self.assertEqual(vault["keys"], {})
def test_sync_active_to_env(self):
api_keys.upsert_key("Work", "sk-work", path=self.vault_path)
api_keys.upsert_key(
"Work",
"sk-work",
endpoint="https://api.work.test/v1",
path=self.vault_path,
)
with patch.dict(os.environ, {}, clear=False):
secret = api_keys.sync_active_to_env(
vault_path=self.vault_path,
@ -79,14 +91,67 @@ class ApiKeyVaultTests(unittest.TestCase):
)
self.assertEqual(secret, "sk-work")
self.assertEqual(os.environ.get("key"), "sk-work")
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)
self.assertIn("https://api.work.test/v1", text)
def test_sync_without_endpoint_leaves_api_alone(self):
self.env_path.write_text('api="https://keep.me/v1"\nkey="old"\n', encoding="utf-8")
api_keys.upsert_key("Local", "sk-local", path=self.vault_path)
with patch.dict(os.environ, {"api": "https://keep.me/v1"}, clear=False):
api_keys.sync_active_to_env(
vault_path=self.vault_path,
env_path=self.env_path,
)
text = self.env_path.read_text(encoding="utf-8")
self.assertIn("https://keep.me/v1", text)
self.assertEqual(os.environ.get("api"), "https://keep.me/v1")
def test_endpoint_roundtrip_and_legacy_string_entries(self):
self.vault_path.write_text(
json.dumps({"active": "Legacy", "keys": {"Legacy": "sk-legacy"}}),
encoding="utf-8",
)
self.assertEqual(api_keys.get_secret("Legacy", self.vault_path), "sk-legacy")
self.assertEqual(api_keys.get_endpoint("Legacy", self.vault_path), "")
api_keys.upsert_key(
"Claude",
"sk-claude",
endpoint="https://api.anthropic.com/v1",
path=self.vault_path,
)
self.assertEqual(
api_keys.get_endpoint("Claude", self.vault_path),
"https://api.anthropic.com/v1",
)
data = json.loads(self.vault_path.read_text(encoding="utf-8"))
self.assertEqual(data["keys"]["Claude"]["endpoint"], "https://api.anthropic.com/v1")
# Legacy string entry is normalized on next save.
api_keys.set_active("Legacy", path=self.vault_path)
data2 = json.loads(self.vault_path.read_text(encoding="utf-8"))
self.assertEqual(data2["keys"]["Legacy"]["secret"], "sk-legacy")
def test_keep_secret_if_blank_updates_endpoint(self):
api_keys.upsert_key("A", "sk-a", path=self.vault_path)
api_keys.upsert_key(
"A",
"",
endpoint="https://api.a.test/v1",
path=self.vault_path,
keep_secret_if_blank=True,
)
self.assertEqual(api_keys.get_secret("A", self.vault_path), "sk-a")
self.assertEqual(
api_keys.get_endpoint("A", self.vault_path), "https://api.a.test/v1"
)
def test_save_writes_json_and_restrictive_mode(self):
api_keys.upsert_key("X", "sk-x", path=self.vault_path)
data = json.loads(self.vault_path.read_text(encoding="utf-8"))
self.assertEqual(data["active"], "X")
self.assertEqual(data["keys"]["X"], "sk-x")
self.assertEqual(data["keys"]["X"]["secret"], "sk-x")
mode = self.vault_path.stat().st_mode & 0o777
# Best-effort: owner read/write only when chmod is honored.
self.assertEqual(mode & 0o077, 0)

View file

@ -2,6 +2,9 @@
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.
"""
from __future__ import annotations
@ -22,6 +25,20 @@ def _empty_vault() -> dict[str, Any]:
return {"active": "", "keys": {}}
def _normalize_entry(raw: Any) -> dict[str, str]:
"""Accept legacy plain-string secrets or ``{secret, endpoint}`` objects."""
if isinstance(raw, str):
return {"secret": raw, "endpoint": ""}
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(),
}
return {"secret": "", "endpoint": ""}
def load_vault(path: Path | None = None) -> dict[str, Any]:
"""Load vault JSON. Returns ``{"active": "", "keys": {}}`` when missing/invalid."""
vault_path = path or API_KEYS_PATH
@ -36,13 +53,14 @@ def load_vault(path: Path | None = None) -> dict[str, Any]:
keys = data.get("keys")
if not isinstance(keys, dict):
keys = {}
cleaned: dict[str, str] = {}
for name, secret in keys.items():
cleaned: dict[str, dict[str, str]] = {}
for name, raw in keys.items():
if not isinstance(name, str) or not name.strip():
continue
if secret is None:
entry = _normalize_entry(raw)
if not entry["secret"]:
continue
cleaned[name.strip()] = str(secret)
cleaned[name.strip()] = entry
active = data.get("active") or ""
if not isinstance(active, str):
active = ""
@ -58,9 +76,18 @@ 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]] = {}
for name, raw in dict(vault.get("keys") or {}).items():
entry = _normalize_entry(raw)
if not entry["secret"]:
continue
keys_out[str(name)] = {
"secret": entry["secret"],
"endpoint": entry["endpoint"],
}
payload = {
"active": str(vault.get("active") or ""),
"keys": dict(vault.get("keys") or {}),
"keys": keys_out,
}
tmp = vault_path.with_suffix(vault_path.suffix + ".tmp")
tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
@ -85,38 +112,73 @@ 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:
keys = load_vault(path).get("keys") or {}
entry = keys.get(name)
if entry is None:
return None
return _normalize_entry(entry)
def get_active_secret(path: Path | None = None) -> str:
vault = load_vault(path)
active = vault.get("active") or ""
keys = vault.get("keys") or {}
if active and active in keys:
return str(keys[active])
return _normalize_entry(keys[active])["secret"]
return ""
def get_secret(name: str, path: Path | None = None) -> str | None:
keys = load_vault(path).get("keys") or {}
if name not in keys:
entry = get_entry(name, path)
if entry is None:
return None
return str(keys[name])
return entry["secret"]
def get_endpoint(name: str, path: Path | None = None) -> str | None:
"""Return the optional endpoint for *name*, or None if the key is unknown."""
entry = get_entry(name, path)
if entry is None:
return None
return entry["endpoint"]
def get_active_endpoint(path: Path | None = None) -> str:
vault = load_vault(path)
active = vault.get("active") or ""
if not active:
return ""
return get_endpoint(active, path) or ""
def upsert_key(
name: str,
secret: str,
*,
endpoint: str = "",
make_active: bool = True,
path: Path | None = None,
keep_secret_if_blank: bool = False,
) -> 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
entry's secret is preserved (useful for editing endpoint only).
"""
name = (name or "").strip()
if not name:
raise ValueError("Key name is required")
secret = (secret or "").strip()
if not secret:
raise ValueError("API key secret is required")
endpoint = (endpoint or "").strip()
vault = load_vault(path)
vault["keys"][name] = secret
existing = vault["keys"].get(name)
if 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}
if make_active or not vault.get("active"):
vault["active"] = name
save_vault(vault, path)
@ -152,16 +214,21 @@ 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=``.
Returns the secret that was written (may be empty).
"""
from dotenv import set_key
secret = get_active_secret(vault_path)
endpoint = get_active_endpoint(vault_path)
target = env_path or ENV_PATH
if not target.exists():
target.touch()
set_key(target, "key", secret)
os.environ["key"] = secret
if endpoint:
set_key(target, "api", endpoint)
os.environ["api"] = endpoint
return secret
@ -170,24 +237,30 @@ def migrate_from_env_if_empty(
vault_path: Path | None = None,
env_path: Path | None = None,
env_key: str | None = None,
env_api: str | None = None,
) -> dict[str, Any]:
"""If the vault has no keys, import ``key`` from ``.env`` as ``Default``.
"""If the vault has no keys, import ``key`` / ``api`` from ``.env`` as ``Default``.
``env_key`` overrides reading from disk (useful in tests).
``env_key`` / ``env_api`` override reading from disk (useful in tests).
"""
vault = load_vault(vault_path)
if vault["keys"]:
return vault
secret = env_key
if secret is None:
endpoint = env_api
if secret is None or endpoint is None:
from dotenv import dotenv_values
target = env_path or ENV_PATH
values = dotenv_values(target) if target.is_file() else {}
secret = (values.get("key") or os.getenv("key") or "").strip()
if secret is None:
secret = (values.get("key") or os.getenv("key") or "").strip()
if endpoint is None:
endpoint = (values.get("api") or os.getenv("api") or "").strip()
else:
secret = str(secret).strip()
endpoint = str(endpoint or "").strip()
if not secret or secret.startswith("<"):
return vault
@ -195,6 +268,7 @@ def migrate_from_env_if_empty(
return upsert_key(
DEFAULT_KEY_NAME,
secret,
endpoint=endpoint or "",
make_active=True,
path=vault_path,
)