* docs(spec): network sharing + Tailscale remote access design Same-state LAN sharing via a second in-process uvicorn listener on a dedicated share port (no restart, model/jobs preserved), PIN-gated for non-loopback clients, with QR + all-LAN-addresses panel. Tailscale serve for private remote access. Supersedes the raw 0.0.0.0 default-flip in #125. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(spec): control endpoints reuse existing require_loopback gate Security review of #157 confirmed the /system router is already loopback-gated via Depends(require_loopback) (non-spoofable request.client.host). The network control endpoints inherit it and /system/set-env is auto-protected from the LAN listener — no new guard needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(network): share-listener module — LAN enumeration + PIN + lifecycle Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(network): loopback-only control endpoints + /system/info sharing fields Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(cjk): scan git-tracked files only, not untracked vendored dirs The no-hardcoded-CJK guard walked the filesystem, so local untracked vendored experiments (research/voice-pro etc. with JP issue templates) caused false local failures while CI (committed files) passed. Scan via git ls-files so local-only and CI behavior match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(network): PIN middleware — gate non-loopback API access when sharing on Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(network): inject X-OmniVoice-Pin globally + capture ?pin= from QR URL Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(network): remote PIN gate on 401 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(network): add qrcode dep for share QR Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(network): footer Local/Network toggle with LAN addresses, QR, copy/open Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tailscale): CLI status + serve enable/disable + endpoints Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(network): Settings → Sharing & Remote Access panel (LAN + Tailscale) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(network): sharing & remote access guide (LAN PIN/QR + Tailscale) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(network): enable() tears down and raises if the share listener never binds Defensive guard (spec §7): if the second uvicorn server doesn't reach 'started' (e.g. the share port was taken in the race after the free-port probe), cancel the task, reset state, and raise — so the API surfaces the failure and the UI stays Local rather than reporting a dead 'Network' state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(network): use globalThis (not Node global) in client.test.ts for tsc CI runs 'tsc --noEmit --checkJs false', which type-checks .ts files; Node's 'global' isn't typed there (TS2304). vitest (esbuild) tolerated it locally. Use globalThis (standard, typed) + cast the mock. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(network): apiFetch leaves opts untouched when no PIN set The unconditional headers merge changed the request shape for callers with no headers (e.g. FormData posts), breaking the legacy 'apiPost passes FormData without Content-Type override' node test. Only spread opts + inject X-OmniVoice-Pin when a PIN is actually present; otherwise pass opts through unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
174 lines
5.1 KiB
Python
174 lines
5.1 KiB
Python
"""Pydantic v2 schemas for request/response validation.
|
||
|
||
Shared across routers — import from here rather than defining inline.
|
||
Using ``model_config = ConfigDict(...)`` for Pydantic v2 compat.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
|
||
|
||
# ── System ────────────────────────────────────────────────────────────────
|
||
|
||
class SysinfoResponse(BaseModel):
|
||
"""GET /sysinfo"""
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
cpu: float = Field(description="CPU usage percentage (0–100)")
|
||
ram: float = Field(description="Used RAM in GiB")
|
||
total_ram: float = Field(description="Total RAM in GiB")
|
||
vram: float = Field(0.0, description="Used VRAM in GiB")
|
||
gpu_active: bool = Field(False, description="Whether a GPU is actively used")
|
||
|
||
|
||
class SystemInfoResponse(BaseModel):
|
||
"""GET /system/info"""
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
data_dir: str
|
||
outputs_dir: str
|
||
crash_log_path: str
|
||
idle_timeout_seconds: int
|
||
model_checkpoint: str = "unknown"
|
||
asr_model: str = "unknown"
|
||
translate_provider: str = "unknown"
|
||
has_hf_token: bool = False
|
||
device: str = "cpu"
|
||
python: str = ""
|
||
platform: str = ""
|
||
error: str | None = None
|
||
ffmpeg_ok: bool = False
|
||
ffmpeg_path: str = ""
|
||
proxy_url: str = ""
|
||
share_enabled: bool = False
|
||
share_port: int | None = None
|
||
lan_addresses: list[str] = []
|
||
pin_required: bool = False
|
||
|
||
|
||
class ModelStatusResponse(BaseModel):
|
||
"""GET /model/status"""
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
status: str = Field(description="idle | loading | ready")
|
||
checkpoint: str | None = None
|
||
loaded_at: str | None = None
|
||
sub_stage: str | None = Field(None, description="Current loading sub-stage: importing | loading_weights | loading_asr | compiling | ready | error")
|
||
detail: str | None = Field(None, description="Human-readable detail of current loading phase")
|
||
error: str | None = Field(None, description="Error message if loading failed")
|
||
|
||
|
||
class LogsResponse(BaseModel):
|
||
"""GET /system/logs"""
|
||
lines: list[str] = Field(default_factory=list)
|
||
path: str = ""
|
||
exists: bool = False
|
||
total_lines: int = 0
|
||
error: str | None = None
|
||
candidates: list[str] | None = None
|
||
|
||
|
||
class FlushMemoryResponse(BaseModel):
|
||
"""POST /system/flush-memory"""
|
||
flushed: bool = True
|
||
unloaded_model: bool = False
|
||
ram_after: float = 0.0
|
||
vram_after: float = 0.0
|
||
|
||
|
||
# ── Setup ─────────────────────────────────────────────────────────────────
|
||
|
||
class MissingModel(BaseModel):
|
||
repo_id: str
|
||
label: str
|
||
|
||
|
||
class SetupStatusResponse(BaseModel):
|
||
"""GET /setup/status"""
|
||
models_ready: bool
|
||
missing: list[MissingModel] = Field(default_factory=list)
|
||
hf_cache_dir: str
|
||
disk_free_gb: float
|
||
min_free_gb: int = 10
|
||
enough_disk: bool = True
|
||
|
||
|
||
class PreflightCheck(BaseModel):
|
||
"""One check in the preflight report."""
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
id: str
|
||
label: str
|
||
status: str = Field(description="pass | warn | fail")
|
||
detail: str = ""
|
||
fix: str | None = None
|
||
|
||
|
||
class DeviceInfo(BaseModel):
|
||
"""GPU/system device info from preflight."""
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
os: str
|
||
arch: str
|
||
gpu_vendor: str = "none"
|
||
gpu_backend: str = "cpu"
|
||
gpu_available: bool = False
|
||
gpu_driver: str | None = None
|
||
gpu_device_name: str | None = None
|
||
ram_gb: float = 0.0
|
||
disk_free_gb: float = 0.0
|
||
|
||
|
||
class PreflightResponse(BaseModel):
|
||
"""GET /setup/preflight"""
|
||
ok: bool
|
||
has_warnings: bool = False
|
||
checks: list[PreflightCheck] = Field(default_factory=list)
|
||
device: DeviceInfo
|
||
|
||
|
||
class InstallModelRequest(BaseModel):
|
||
"""POST /models/install"""
|
||
repo_id: str
|
||
|
||
|
||
class DeleteModelResponse(BaseModel):
|
||
"""DELETE /models/{repo_id}"""
|
||
deleted: bool = True
|
||
repo_id: str
|
||
freed_bytes: int = 0
|
||
|
||
|
||
# ── Models list ───────────────────────────────────────────────────────────
|
||
|
||
class ModelEntry(BaseModel):
|
||
"""One model in the GET /models response."""
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
repo_id: str
|
||
label: str
|
||
role: str
|
||
size: str = ""
|
||
required: bool = False
|
||
installed: bool = False
|
||
supported: bool = True
|
||
size_on_disk: int | None = None
|
||
nb_files: int | None = None
|
||
|
||
|
||
# ── Effect presets ─────────────────────────────────────────────────────
|
||
|
||
class EffectPresetEntry(BaseModel):
|
||
"""One DSP effect preset."""
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
id: str
|
||
label: str
|
||
icon: str
|
||
description: str
|
||
|
||
|
||
class EffectPresetsResponse(BaseModel):
|
||
"""GET /engines/effects/presets"""
|
||
presets: list[EffectPresetEntry] = Field(default_factory=list)
|