Spaces:
Sleeping
Sleeping
| """Settings API β HF token save/clear/state endpoints (Phase 1 AUTH-03 backend half). | |
| These endpoints are the backend half of the Wave 2 Settings β API Keys | |
| panel. Threat T-01-03 mitigation: every write endpoint is gated by the | |
| router-level `require_loopback` dep, so non-loopback origins get 403 | |
| before the handler runs. Reads are loopback-gated too β the masked | |
| token preview is useful telemetry that we still don't want exposed on | |
| the LAN. | |
| The state endpoint duplicates `/system/hf-token/state` (which lives on | |
| `system.py` for legacy-router compatibility); both return the same shape. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from dataclasses import asdict | |
| from typing import Optional | |
| from fastapi import APIRouter, Depends, HTTPException, Query | |
| from pydantic import BaseModel, Field | |
| from api.dependencies import require_loopback | |
| logger = logging.getLogger("omnivoice.api.settings") | |
| router = APIRouter( | |
| prefix="/api/settings", | |
| tags=["settings"], | |
| dependencies=[Depends(require_loopback)], | |
| ) | |
| class _HFTokenBody(BaseModel): | |
| token: str = Field(..., min_length=1, description="HuggingFace access token") | |
| def _state_response() -> dict: | |
| """Return the same shape the React panel renders. Never includes raw token.""" | |
| from services import token_resolver | |
| s = token_resolver.state() | |
| return { | |
| "active": s["active"], | |
| "sources": [asdict(row) for row in s["sources"]], | |
| } | |
| def save_hf_token(body: _HFTokenBody): | |
| """Persist a new HF token to the encrypted settings store + the HF | |
| canonical file (via huggingface_hub.login). Returns the updated | |
| cascade state.""" | |
| token = body.token.strip() | |
| if not token: | |
| raise HTTPException(status_code=400, detail="token must be non-empty") | |
| from services import token_resolver | |
| try: | |
| token_resolver.save_app_token(token) | |
| except Exception: | |
| logger.exception("save_app_token failed") | |
| raise HTTPException(status_code=500, detail="Failed to save HF token") | |
| return _state_response() | |
| def clear_hf_token(also_clear_hf_cli: bool = Query(False)): | |
| """Clear the App-source token. Optionally also call huggingface_hub.logout | |
| to clear the canonical HF file. Returns the updated cascade state.""" | |
| from services import token_resolver | |
| try: | |
| token_resolver.clear_app_token(also_clear_hf_cli=also_clear_hf_cli) | |
| except Exception: | |
| logger.exception("clear_app_token failed") | |
| raise HTTPException(status_code=500, detail="Failed to clear HF token") | |
| return _state_response() | |
| def get_hf_token_state(): | |
| """3-source HF token cascade state for the Settings UI.""" | |
| return _state_response() | |
| # ββ Performance settings (INST-12) ββββββββββββββββββββββββββββββββββββββββ | |
| # Threat T-02-04: same loopback guard as the hf-token endpoints via the | |
| # router-level `require_loopback` dep. | |
| _TORCH_COMPILE_KEY = "perf.torch_compile_disabled" | |
| class _TorchCompileBody(BaseModel): | |
| enabled: bool = Field(..., description="True to set TORCH_COMPILE_DISABLE=1 on engine subprocesses") | |
| def _torch_compile_state() -> dict: | |
| import sys | |
| from services import settings_store | |
| raw = settings_store.get_text(_TORCH_COMPILE_KEY, "0") | |
| return {"enabled": raw == "1", "platform": sys.platform} | |
| def get_torch_compile_disabled(): | |
| """Return the current torch.compile-disabled toggle + the runtime platform. | |
| UI uses the platform to render the toggle disabled (with an explainer) | |
| on non-Windows hosts, since the OOM is Windows-specific (issue #65).""" | |
| return _torch_compile_state() | |
| def set_torch_compile_disabled(body: _TorchCompileBody): | |
| """Persist the toggle. Honoured by `services.engine_env.build_engine_env()` | |
| which injects TORCH_COMPILE_DISABLE=1 on Windows when enabled.""" | |
| from services import settings_store | |
| try: | |
| settings_store.set_text(_TORCH_COMPILE_KEY, "1" if body.enabled else "0") | |
| except Exception: | |
| logger.exception("set_torch_compile_disabled failed") | |
| raise HTTPException(status_code=500, detail="Failed to persist setting") | |
| return _torch_compile_state() | |
| # ββ License acceptance (Phase 3 Plan 03-01 / TTS-05) ββββββββββββββββββββββ | |
| # Frontend ``SupertonicLicenseDialog`` flips the engine-license bit via this | |
| # endpoint. The handler is loopback-gated (router-level dep) and the | |
| # engine_id is allow-listed so an arbitrary string cannot be persisted. | |
| # Threat T-03-04 in the plan frontmatter: this is an honest-acknowledgment | |
| # gate, not a security boundary; the loopback + allow-list keeps the | |
| # attack surface tight regardless. | |
| #: Engines that have an in-tree acceptance dialog. Adding a new engine | |
| #: here means adding a corresponding frontend dialog + a license URLs | |
| #: dict in its constants module. Until that, the API refuses the write. | |
| _LICENSE_ALLOWED_ENGINES: frozenset[str] = frozenset({"supertonic3"}) | |
| class _LicenseAcceptBody(BaseModel): | |
| engine_id: str = Field(..., min_length=1, max_length=64) | |
| accepted: bool = Field(..., description="True to accept the license terms") | |
| def post_license_acceptance(body: _LicenseAcceptBody) -> dict: | |
| """Persist a per-engine license-acceptance boolean. | |
| Returns ``{"ok": True, "engine_id": ..., "accepted": ...}`` so the | |
| caller can update its UI without a second round-trip. Validation: | |
| ``engine_id`` must be in the in-tree allow-list ββ refuses arbitrary | |
| keys so the settings table can't be polluted via this route. | |
| """ | |
| eid = body.engine_id.strip().lower() | |
| if eid not in _LICENSE_ALLOWED_ENGINES: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=( | |
| f"engine_id {eid!r} is not in the license allow-list " | |
| f"{sorted(_LICENSE_ALLOWED_ENGINES)}" | |
| ), | |
| ) | |
| from services import settings_store | |
| try: | |
| settings_store.set_license_accepted(eid, body.accepted) | |
| except Exception: | |
| logger.exception("set_license_accepted failed for %s", eid) | |
| raise HTTPException(status_code=500, detail="Failed to persist license acceptance") | |
| return {"ok": True, "engine_id": eid, "accepted": bool(body.accepted)} | |
| def get_license_acceptance(engine_id: str) -> dict: | |
| """Return ``{"engine_id": ..., "accepted": bool}``. | |
| Same allow-list as the POST handler so an unknown engine id is a | |
| 400 rather than a silent ``accepted=false`` for a non-existent | |
| engine. | |
| """ | |
| eid = engine_id.strip().lower() | |
| if eid not in _LICENSE_ALLOWED_ENGINES: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=( | |
| f"engine_id {eid!r} is not in the license allow-list " | |
| f"{sorted(_LICENSE_ALLOWED_ENGINES)}" | |
| ), | |
| ) | |
| from services import settings_store | |
| try: | |
| accepted = settings_store.get_license_accepted(eid) | |
| except Exception: | |
| logger.exception("get_license_accepted failed for %s", eid) | |
| raise HTTPException(status_code=500, detail="Failed to read license acceptance") | |
| return {"engine_id": eid, "accepted": bool(accepted)} | |