LΓͺ Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
7.46 kB
"""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"]],
}
@router.post("/hf-token")
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()
@router.delete("/hf-token")
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()
@router.get("/hf-token/state")
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}
@router.get("/perf/torch-compile-disabled")
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()
@router.put("/perf/torch-compile-disabled")
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")
@router.post("/license")
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)}
@router.get("/license/{engine_id}")
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)}