docs: add API reference and enrich OpenAPI metadata

- docs/API.md: full REST + WebSocket reference with auth usage
  (cookie/Bearer login flow, curl examples), conventions, endpoint
  tables, and WS protocol (WS routes are not in the OpenAPI schema)
- server.py: app-level description, openapi_tags grouping, and
  tags/responses metadata on all REST routes documenting the error
  codes each route raises; hide the SPA catch-all from the schema
- README: link the API reference in the docs index
This commit is contained in:
ARIA committed 2026-10-03 14:51:58 +02:00
1 parent db0e676501
commit b75b9d43e9
3 files changed
+473 -38

No files matched your search

+1
View File
@@ -205,6 +205,7 @@ The GPU key can be a UUID, `pci:XXXX`, or `idx:N` fallback. Find your GPU key wi
- **[Overview](docs/Overview.md)** — What it does, capabilities, architecture
- **[Installation](docs/Installation.md)** — Prerequisites, source build, troubleshooting
- **[Usage Guide](docs/Usage-Guide.md)** — Web UI, CLI reference, systemd service
- **[API Reference](docs/API.md)** — REST + WebSocket API, authentication, endpoint summary (interactive docs at `/docs`)
- **[Tips and Tricks](docs/Tips-and-Tricks.md)** — Workflows, curve flattening, safety
- **[WireView Pro II Setup](docs/WireView.md)** — One-time host setup (udev rule + port access) for the 12VHPWR connector monitor
+243
View File
@@ -0,0 +1,243 @@
# NVCurve API Reference
The web server (`nvcurve serve`) exposes a **REST + WebSocket API** on
`http://127.0.0.1:8042` by default. The same server also serves the web UI
SPA, so the API and UI share one port.
**Interactive docs** (served by the running server, no login required):
| URL | Description |
| --- | --- |
| `/docs` | Swagger UI (interactive, per-endpoint request/response metadata) |
| `/redoc` | ReDoc (read-friendly reference) |
| `/openapi.json` | Machine-readable OpenAPI 3.1 schema |
> WebSocket endpoints are **not** part of the OpenAPI schema (OpenAPI has no
> WebSocket operation type) — they are documented in the
> [WebSockets](#websockets) section below.
---
## Authentication
The server has two modes, decided automatically:
- **No users configured** → the API is fully open (no auth).
- **One or more users configured** → every `/api/*` and `/ws/*` endpoint
requires a valid session, except the public endpoints listed below.
Sessions last **24 hours**. Passwords are stored as bcrypt hashes.
### Public endpoints (always reachable)
| Endpoint | Purpose |
| --- | --- |
| `GET /api/ping` | Liveness probe (CLI uses it to detect a running server) |
| `GET /api/auth/status` | Whether auth is required and whether *this* request is authenticated |
| `POST /api/auth/login` | Authenticate, obtain session |
| `POST /api/auth/logout` | End the current session |
### Obtaining a session
```bash
curl -s -X POST http://127.0.0.1:8042/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username": "alice", "password": "secret"}'
```
Response:
```json
{
"ok": true,
"username": "alice",
"expires_at": 1750000000.0,
"token": "9f2c1a…"
}
```
On success the server also sets an `HttpOnly` cookie `nvcurve_session`
(`Secure` when TLS is enabled). You can use either credential:
- **Cookie** — what browsers use automatically.
- **Bearer token** — what scripts/CLI use:
```bash
TOKEN=$(curl -s -X POST http://127.0.0.1:8042/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username": "alice", "password": "secret"}' | jq -r .token)
curl -s http://127.0.0.1:8042/api/curve -H "Authorization: Bearer $TOKEN"
```
Notes:
- For WebSockets, the token is read from the `Authorization` header or the
cookie on the handshake. It is **deliberately not** accepted via query
string (the access log records full paths).
- Failed logins are rate-limited per client IP; lockouts return `429`.
Behind a reverse proxy, set `trusted_proxies` in the config so the real
client IP is used.
- `GET /api/auth/status` reports your current state:
```json
{"auth_required": true, "authenticated": true, "username": "alice", "expires_at": 1750000000.0}
```
---
## Conventions
- **`gpu_index`** — GPU-selecting routes take a 0-based `gpu_index` query
parameter (default `0`). Unknown indexes → `404`; an uninitialized GPU →
`503`.
- **Errors** — JSON `{"detail": "..."}`. Validation errors on write endpoints
return `400` with `detail.errors[]` listing the offending points.
- **Safety cap** — curve writes are capped by the server-side
`max_delta_khz` (default ±3000 MHz, set in `/etc/nvcurve/config.json`).
Clients cannot raise it per request.
- **Units** — curve deltas are in **kHz**; clock offsets and memory offsets
are in **MHz**; voltages in µV/mV; power in watts.
- **Side effects** — write endpoints (`/api/curve/*`, `/api/limits`,
`/api/profiles/*/apply`, `/api/snapshot/restore`) change hardware state.
With `auto_snapshot: true` (default) a ClockBoostTable snapshot is saved
before every curve write.
---
## REST endpoints
### Auth
| Method & path | Description |
| --- | --- |
| `GET /api/ping` | Liveness probe → `{"ok": true}` |
| `GET /api/auth/status` | Auth mode + current session state |
| `POST /api/auth/login` | Body `{"username", "password"}` → session token + cookie. Errors: `401` bad credentials, `404` auth disabled, `429` locked out |
| `POST /api/auth/logout` | End session (idempotent) |
| `GET /api/auth/users` | List configured usernames (requires session) |
### GPU
| Method & path | Description |
| --- | --- |
| `GET /api/gpus` | List discovered GPUs → `[{index, name, uuid, pci_bus_id}]` |
| `GET /api/gpu?gpu_index=0` | Name, driver version, VRAM |
| `GET /api/dashboard?gpu_index=0` | Static dashboard info (VBIOS, CUDA cores, PCIe, BAR1, …). Live values come from the monitor WebSocket |
| `GET /api/monitor?gpu_index=0` | One-shot monitoring snapshot: voltage, clocks, temp, power, fans, p-state, VRAM, utilization, throttle reasons |
### Curve
| Method & path | Description |
| --- | --- |
| `GET /api/curve?gpu_index=0` | Full curve: `{gpu_name, timestamp, points: [{index, freq_khz, volt_mv, delta_khz, effective_freq_khz, domain, …}]}` |
| `GET /api/curve/{point}?gpu_index=0` | Single V/F point detail (`400` if index out of range) |
| `GET /api/ranges?gpu_index=0` | Clock boost domain ranges (min/max offset per domain) |
| `GET /api/voltage?gpu_index=0` | Current core voltage → `{voltage_uv, voltage_mv}` |
| `POST /api/curve/write?gpu_index=0` | Body `{"deltas": {"<point>": <delta_khz>}}` — write per-point frequency offsets. Response includes `ok`, `return_code`, optional `warning` (external change detected) and `freq_warnings` (negative-frequency risk) |
| `POST /api/curve/write/global?gpu_index=0` | Body `{"delta_khz": N}` — uniform offset on all GPU-domain points |
| `POST /api/curve/reset?gpu_index=0` | Reset all frequency offsets to zero |
| `POST /api/curve/verify?gpu_index=0` | Body `{"deltas": {...}}` — write-verify-read cycle; returns per-point `match` results and `collateral_changes` on other points |
### Profiles
Profiles capture the current curve deltas, power limit, memory offset and
active fan curve as a named JSON file.
| Method & path | Description |
| --- | --- |
| `GET /api/profiles?gpu_index=0` | `{profiles: [...], active, auto_load}` |
| `POST /api/profiles?gpu_index=0` | Body `{"name": "..."}` — save current GPU state as a profile |
| `POST /api/profiles/{name}/apply?gpu_index=0` | Apply a saved profile to hardware. `404` if missing; `500` with per-part errors if any part fails |
| `DELETE /api/profiles/{name}` | Delete a profile (also clears auto-load references) |
| `POST /api/profiles/{name}/rename` | Body `{"new_name": "..."}` |
### Config
| Method & path | Description |
| --- | --- |
| `GET /api/config?gpu_index=0` | `{auto_load_profile}` for the GPU |
| `POST /api/config` | Body `{"auto_load_profile": "name" \| null, "gpu_index": 0}` — set/clear the profile auto-applied at server start. Persists to `/etc/nvcurve/config.json` if present |
### Limits
| Method & path | Description |
| --- | --- |
| `GET /api/limits?gpu_index=0` | Power limit (current/default/min/max), `gpc_offset_mhz`, `mem_offset_mhz`, memory offset range |
| `POST /api/limits?gpu_index=0` | Body `{power_limit_w?, mem_offset_mhz?, power_cap_mode?}` — any subset. `power_cap_mode` is `"nvml"` (default) or `"ioctl"` (experimental RM power control; `409` if unsupported). Changing the memory offset may reset the curve table — the server re-applies the last known curve offsets |
| `POST /api/limits/reset?gpu_index=0` | Power limit → hardware default, memory offset → 0 |
### Fans
| Method & path | Description |
| --- | --- |
| `GET /api/fans?gpu_index=0` | Per-fan state: `{fan_pct, fans: [{index, fan_pct}], num_fans, min_fan_pct, max_fan_pct, fan_mode: "auto" \| "curve", curve, curve_active, fan_targets}` |
| `POST /api/fans?gpu_index=0` | Body `{"curve": [{"temp_c": 60, "fan_pct": 40}, …], "fans": [0] \| null}` — set the fan curve and start the control poller. `fans: null` drives all fans. `400` for invalid curves (e.g. non-monotonic temps) |
| `POST /api/fans/reset?gpu_index=0` | Deactivate curve control, restore automatic fan mode |
| `POST /api/fans/speed?gpu_index=0` | Body `{"fan_pct": 50, "fan": 0 \| null}` — one-shot exact speed (bypasses curve) |
Active fan curves persist across server restarts.
### WireView Pro II
| Method & path | Description |
| --- | --- |
| `GET /api/wireview` | `{available, connected, info, sample}` — `available` is true when a Thermal Grizzly WireView Pro II is connected; `sample` is the most recent reading (`null` until the first) |
### Snapshots
| Method & path | Description |
| --- | --- |
| `GET /api/snapshots` | List saved ClockBoostTable snapshots → `[{filepath, timestamp, gpu, nonzero_offsets, size}]` |
| `POST /api/snapshot/save?gpu_index=0` | Save the current ClockBoostTable → `{ok, filepath}` |
| `POST /api/snapshot/restore?gpu_index=0` | Body `{"filepath": "…" \| null}` — restore a snapshot (most recent if omitted) |
### Server
| Method & path | Description |
| --- | --- |
| `POST /api/shutdown` | Gracefully stop the server process. `403` when `allow_api_shutdown: false` (recommended on shared systems — use systemd instead) |
---
## WebSockets
All three endpoints use the same handshake: connect, then send a subscribe
message. The server sends an initial state payload, then keeps streaming.
| Endpoint | Subscribe message | Stream |
| --- | --- | --- |
| `/ws/monitor` | `{"action": "subscribe", "gpu_index": 0}` | Monitoring sample every `poll_interval_s` (default 1 s): voltage, clocks, temp, power, fans, p-state, VRAM, utilization, throttle reasons |
| `/ws/curve` | `{"action": "subscribe", "gpu_index": 0}` | Full curve state, pushed whenever the curve changes (after any write) |
| `/ws/wireview` | `{"action": "subscribe"}` | `{"type": "unavailable"}` or `{"type": "sample", "info": {...}, "sample": {...}}` per poll tick |
Authentication (when enabled): the session token is taken from the
`Authorization: Bearer <token>` header or the `nvcurve_session` cookie on the
handshake. Unauthenticated connections are closed with code `1008`.
Example (Python `websockets`):
```python
import asyncio, json, websockets
async def main():
async with websockets.connect(
"ws://127.0.0.1:8042/ws/monitor",
extra_headers={"Authorization": f"Bearer {token}"},
) as ws:
await ws.send(json.dumps({"action": "subscribe", "gpu_index": 0}))
async for msg in ws:
sample = json.loads(msg)
print(sample["temp_c"], sample["clock_mhz"])
asyncio.run(main())
```
---
## CLI
The `nvcurve` CLI talks to this same API (default base
`http://127.0.0.1:8042`, override with `--base-url`), so any scripted use
covered by the CLI works against the API as well. See
[Usage-Guide.md](Usage-Guide.md) for CLI details.
+229 -38
View File
@@ -638,7 +638,88 @@ async def lifespan(app: FastAPI):
# ── App ───────────────────────────────────────────────────────────────────────
app = FastAPI(title="nvcurve", version="0.5.0", lifespan=lifespan)
# OpenAPI tag groups, rendered as sections in /docs.
OPENAPI_TAGS = [
{
"name": "Auth",
"description": (
"Session management. ping, status, login and logout are public; "
"all other /api/* and /ws/* endpoints require a session once at "
"least one user is configured."
),
},
{
"name": "GPU",
"description": (
"GPU discovery, static info and live monitoring. Routes accept a "
"0-based gpu_index query parameter (default 0)."
),
},
{
"name": "Curve",
"description": (
"V/F curve read and write (per-point deltas, global offset, reset, "
"write-verify). Writes are capped by the server-side safety limit "
"(max_delta_khz in /etc/nvcurve/config.json)."
),
},
{
"name": "Profiles",
"description": (
"Named profiles: save, apply, rename, delete. A profile captures "
"curve deltas, limits and the active fan curve."
),
},
{
"name": "Config",
"description": "Mutable per-GPU server configuration (auto-load profile).",
},
{
"name": "Limits",
"description": "Power limit and memory clock offset (read/set/reset).",
},
{
"name": "Fans",
"description": (
"Fan curve control: set a curve, one-shot speed, reset to automatic "
"fan mode."
),
},
{
"name": "WireView",
"description": "Thermal Grizzly WireView Pro II sensor access.",
},
{
"name": "Snapshots",
"description": "ClockBoostTable snapshots: list, save, restore.",
},
{
"name": "Server",
"description": "Server lifecycle.",
},
]
app = FastAPI(
title="nvcurve",
version="0.5.0",
lifespan=lifespan,
description=(
"REST + WebSocket API for the nvcurve GPU V/F curve editor.\n\n"
"Base URL defaults to http://127.0.0.1:8042 (see `nvcurve serve --help`).\n\n"
"**Authentication** — with no users configured the API is open. Once at "
"least one user exists, every /api/* and /ws/* endpoint requires a "
"session: either the `nvcurve_session` cookie set by "
"`POST /api/auth/login` (browsers) or the returned token as "
"`Authorization: Bearer <token>` (scripts/CLI). Sessions last 24 hours.\n\n"
"**gpu_index** — GPU-selecting routes take a 0-based `gpu_index` query "
"parameter (default `0`). Unknown indexes return 404; an uninitialized "
"GPU returns 503.\n\n"
"**Errors** — JSON `{'detail': '...'}`. Write endpoints return 400 with "
"`detail.errors[]` when a delta exceeds the server-side safety cap "
"(`max_delta_khz` in /etc/nvcurve/config.json)."
),
openapi_tags=OPENAPI_TAGS,
)
app.add_middleware(
CORSMiddleware,
@@ -764,16 +845,29 @@ async def _run(fn, *args):
return await loop.run_in_executor(None, fn, *args)
# ── Shared OpenAPI response metadata ──────────────────────────────────────────
# Reused `responses=` entries so every route documents the same error shapes.
R_UNAUTHORIZED = {"description": "No valid session (auth is enabled)."}
R_GPU_NOT_FOUND = {"description": "Unknown gpu_index."}
R_GPU_NOT_INIT = {"description": "GPU not initialized."}
R_HW_FAILED = {"description": "Hardware operation failed (NvAPI/NVML error)."}
# Convenience bundles for routes that take a gpu_index. Annotated to match
# FastAPI's `responses` parameter type (keys may be int or str).
R_AUTH: dict[int | str, dict[str, Any]] = {401: R_UNAUTHORIZED}
R_GPU: dict[int | str, dict[str, Any]] = {404: R_GPU_NOT_FOUND, 503: R_GPU_NOT_INIT}
# ── Auth endpoints ────────────────────────────────────────────────────────────
@app.get("/api/ping")
@app.get("/api/ping", tags=["Auth"])
async def api_ping():
"""Public liveness probe (no auth). Used by the CLI to detect a running server."""
return {"ok": True}
@app.get("/api/auth/status")
@app.get("/api/auth/status", tags=["Auth"])
async def api_auth_status(request: Request):
"""Report whether auth is required and whether this request is authenticated."""
cfg: Config = _state["config"]
@@ -789,7 +883,15 @@ async def api_auth_status(request: Request):
}
@app.post("/api/auth/login")
@app.post(
"/api/auth/login",
tags=["Auth"],
responses={
401: {"description": "Invalid username or password."},
404: {"description": "Authentication is not enabled (no users configured)."},
429: {"description": "Too many failed login attempts — client is temporarily locked out."},
},
)
async def api_auth_login(req: LoginRequest, request: Request):
"""Authenticate with username+password. Sets a 24-hour session cookie.
@@ -834,7 +936,7 @@ async def api_auth_login(req: LoginRequest, request: Request):
raise HTTPException(status_code=401, detail="Invalid username or password")
@app.post("/api/auth/logout")
@app.post("/api/auth/logout", tags=["Auth"])
async def api_auth_logout(request: Request):
"""End the current session (idempotent)."""
token = auth.extract_token(request)
@@ -844,7 +946,7 @@ async def api_auth_logout(request: Request):
return response
@app.get("/api/auth/users")
@app.get("/api/auth/users", tags=["Auth"], responses=R_AUTH)
async def api_auth_users():
"""List configured usernames (requires auth)."""
cfg: Config = _state["config"]
@@ -892,7 +994,7 @@ def _client_ip(request: _ClientIpSource, trusted_proxies: list[str]) -> str:
# ── REST endpoints ────────────────────────────────────────────────────────────
@app.get("/api/gpus")
@app.get("/api/gpus", tags=["GPU"])
async def api_gpus():
"""List all discovered GPUs."""
from .hal.gpu import discover_gpus
@@ -909,7 +1011,7 @@ async def api_gpus():
]
@app.get("/api/gpu")
@app.get("/api/gpu", tags=["GPU"], responses={**R_AUTH, **R_GPU})
async def api_gpu(gpu_index: int = 0):
"""GPU info: name, driver version, VRAM."""
gpu, g_state = _require_gpu(gpu_index)
@@ -924,7 +1026,7 @@ async def api_gpu(gpu_index: int = 0):
}
@app.get("/api/dashboard")
@app.get("/api/dashboard", tags=["GPU"], responses={**R_AUTH, **R_GPU})
async def api_dashboard(gpu_index: int = 0):
"""Static GPU info for the Dashboard tab (VBIOS, CUDA cores, PCIe, BAR1, etc.).
@@ -935,7 +1037,11 @@ async def api_dashboard(gpu_index: int = 0):
return info
@app.get("/api/curve")
@app.get(
"/api/curve",
tags=["Curve"],
responses={**R_AUTH, **R_GPU, 500: R_HW_FAILED},
)
async def api_curve(gpu_index: int = 0):
"""Full CurveState: all V/F points with base freq, voltage, delta, effective freq."""
gpu, g_state = _require_gpu(gpu_index)
@@ -948,7 +1054,16 @@ async def api_curve(gpu_index: int = 0):
return _curve_state_dict(state)
@app.get("/api/curve/{point}")
@app.get(
"/api/curve/{point}",
tags=["Curve"],
responses={
**R_AUTH,
**R_GPU,
400: {"description": "Point index out of range."},
500: R_HW_FAILED,
},
)
async def api_curve_point(point: int, gpu_index: int = 0):
"""Single V/F point detail."""
gpu, g_state = _require_gpu(gpu_index)
@@ -962,7 +1077,7 @@ async def api_curve_point(point: int, gpu_index: int = 0):
return _vfpoint_dict(state.points[point])
@app.get("/api/ranges")
@app.get("/api/ranges", tags=["Curve"], responses={**R_AUTH, **R_GPU, 500: R_HW_FAILED})
async def api_ranges(gpu_index: int = 0):
"""Clock boost domain ranges (min/max offset per domain)."""
gpu, g_state = _require_gpu(gpu_index)
@@ -972,7 +1087,7 @@ async def api_ranges(gpu_index: int = 0):
return ranges
@app.get("/api/voltage")
@app.get("/api/voltage", tags=["Curve"], responses={**R_AUTH, **R_GPU, 500: R_HW_FAILED})
async def api_voltage(gpu_index: int = 0):
"""Current GPU core voltage."""
from .hal.monitoring import read_voltage
@@ -984,7 +1099,7 @@ async def api_voltage(gpu_index: int = 0):
return {"voltage_uv": voltage_uv, "voltage_mv": voltage_uv / 1000.0}
@app.get("/api/monitor")
@app.get("/api/monitor", tags=["GPU"], responses={**R_AUTH, **R_GPU})
async def api_monitor(gpu_index: int = 0):
"""One-shot monitoring snapshot: voltage, clock, temp, power, fan, p-state, VRAM, utilization."""
gpu, g_state = _require_gpu(gpu_index)
@@ -992,7 +1107,7 @@ async def api_monitor(gpu_index: int = 0):
return _sample_dict(sample)
@app.get("/api/snapshots")
@app.get("/api/snapshots", tags=["Snapshots"], responses=R_AUTH)
async def api_snapshots():
"""List saved ClockBoostTable snapshots."""
cfg: Config = _state["config"]
@@ -1075,7 +1190,7 @@ def _power_cap_mode(cfg: Config, gpu_index: int) -> str:
return mode if mode in ("nvml", "ioctl") else "nvml"
@app.get("/api/profiles")
@app.get("/api/profiles", tags=["Profiles"], responses=R_AUTH)
async def api_profiles(gpu_index: int = 0):
"""List saved native profiles, the active profile name, and the auto-load profile name."""
cfg: Config = _state["config"]
@@ -1089,7 +1204,7 @@ async def api_profiles(gpu_index: int = 0):
}
@app.post("/api/profiles")
@app.post("/api/profiles", tags=["Profiles"], responses={**R_AUTH, **R_GPU, 500: R_HW_FAILED})
async def api_profile_save(req: ProfileSaveRequest, gpu_index: int = 0):
"""Save current GPU state (curve deltas + limits) as a named profile."""
gpu, g_state = _require_gpu(gpu_index)
@@ -1284,7 +1399,16 @@ async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]:
return errs
@app.post("/api/profiles/{name}/apply")
@app.post(
"/api/profiles/{name}/apply",
tags=["Profiles"],
responses={
**R_AUTH,
**R_GPU,
404: {"description": "Profile not found."},
500: {"description": "Profile failed to apply (per-part errors in detail)."},
},
)
async def api_profile_apply(name: str, gpu_index: int = 0):
"""Apply a saved profile to hardware (curve deltas + limits)."""
_require_gpu(gpu_index)
@@ -1303,7 +1427,11 @@ async def api_profile_apply(name: str, gpu_index: int = 0):
return {"ok": True}
@app.delete("/api/profiles/{name}")
@app.delete(
"/api/profiles/{name}",
tags=["Profiles"],
responses={**R_AUTH, 404: {"description": "Profile not found."}},
)
async def api_profile_delete(name: str):
"""Delete a saved profile by name."""
cfg: Config = _state["config"]
@@ -1322,7 +1450,15 @@ async def api_profile_delete(name: str):
return {"ok": True}
@app.post("/api/profiles/{name}/rename")
@app.post(
"/api/profiles/{name}/rename",
tags=["Profiles"],
responses={
**R_AUTH,
400: {"description": "New name is empty."},
404: {"description": "Profile not found."},
},
)
async def api_profile_rename(name: str, req: ProfileRenameRequest):
"""Rename a profile."""
cfg: Config = _state["config"]
@@ -1344,14 +1480,14 @@ async def api_profile_rename(name: str, req: ProfileRenameRequest):
return {"ok": True}
@app.get("/api/config")
@app.get("/api/config", tags=["Config"], responses=R_AUTH)
async def api_config_get(gpu_index: int = 0):
"""Get mutable server configuration for a specific GPU."""
cfg: Config = _state["config"]
return {"auto_load_profile": cfg.auto_load_profiles.get(_gpu_stable_key(gpu_index))}
@app.post("/api/config")
@app.post("/api/config", tags=["Config"], responses={**R_AUTH, 404: R_GPU_NOT_FOUND})
async def api_config_update(req: ConfigUpdateRequest):
"""Update mutable server configuration. Changes persist to /etc/nvcurve/config.json if present."""
if req.gpu_index not in _state["gpus"]:
@@ -1366,7 +1502,7 @@ async def api_config_update(req: ConfigUpdateRequest):
return {"ok": True, "auto_load_profile": cfg.auto_load_profiles.get(key)}
@app.get("/api/limits")
@app.get("/api/limits", tags=["Limits"], responses={**R_AUTH, 404: R_GPU_NOT_FOUND})
async def api_limits(gpu_index: int = 0):
"""Current performance limits: power and clock offsets."""
cfg: Config = _state["config"]
@@ -1381,7 +1517,19 @@ async def api_limits(gpu_index: int = 0):
}
@app.post("/api/limits")
@app.post(
"/api/limits",
tags=["Limits"],
responses={
**R_AUTH,
404: R_GPU_NOT_FOUND,
400: {"description": "Invalid power_cap_mode (must be 'nvml' or 'ioctl')."},
409: {
"description": "ioctl power mode requested but not supported on this GPU/driver."
},
500: R_HW_FAILED,
},
)
async def api_limits_update(req: LimitsRequest, gpu_index: int = 0):
"""Update performance limits."""
g_state = _get_gpu_state(gpu_index)
@@ -1473,7 +1621,7 @@ async def _update_offsets_and_broadcast(gpu_index: int) -> None:
g_state["last_offsets"] = offsets
@app.post("/api/limits/reset")
@app.post("/api/limits/reset", tags=["Limits"], responses={**R_AUTH, 404: R_GPU_NOT_FOUND, 500: R_HW_FAILED})
async def api_limits_reset(gpu_index: int = 0):
"""Reset power limit to hardware default and memory clock offset to 0."""
g_state = _get_gpu_state(gpu_index)
@@ -1508,7 +1656,7 @@ async def api_limits_reset(gpu_index: int = 0):
# ── Fan endpoints ──────────────────────────────────────────────────────────────
@app.get("/api/fans")
@app.get("/api/fans", tags=["Fans"], responses={**R_AUTH, 404: R_GPU_NOT_FOUND})
async def api_fans(gpu_index: int = 0):
"""Current fan state: per-fan %, curve, and whether curve control is active."""
_get_gpu_state(gpu_index)
@@ -1524,7 +1672,16 @@ async def api_fans(gpu_index: int = 0):
}
@app.post("/api/fans")
@app.post(
"/api/fans",
tags=["Fans"],
responses={
**R_AUTH,
404: R_GPU_NOT_FOUND,
400: {"description": "Invalid fan curve (e.g. non-monotonic temperatures)."},
500: {"description": "Fan control not available on this GPU."},
},
)
async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
"""Set or update the fan curve. Starts the fan control poller.
@@ -1563,7 +1720,7 @@ async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
return {"ok": True}
@app.post("/api/fans/reset")
@app.post("/api/fans/reset", tags=["Fans"], responses={**R_AUTH, 404: R_GPU_NOT_FOUND})
async def api_fans_reset(gpu_index: int = 0):
"""Deactivate fan curve control and restore automatic fan mode."""
_get_gpu_state(gpu_index)
@@ -1575,7 +1732,7 @@ async def api_fans_reset(gpu_index: int = 0):
return {"ok": True}
@app.post("/api/fans/speed")
@app.post("/api/fans/speed", tags=["Fans"], responses={**R_AUTH, 404: R_GPU_NOT_FOUND, 500: R_HW_FAILED})
async def api_fans_speed(req: FanSpeedRequest, gpu_index: int = 0):
"""One-shot set fan(s) to an exact percentage (bypasses curve).
@@ -1593,7 +1750,7 @@ async def api_fans_speed(req: FanSpeedRequest, gpu_index: int = 0):
# ── WireView Pro II (Thermal Grizzly) ─────────────────────────────────────────
@app.get("/api/wireview")
@app.get("/api/wireview", tags=["WireView"], responses=R_AUTH)
async def api_wireview():
"""WireView Pro II availability and the most recent sensor sample.
@@ -1645,7 +1802,16 @@ async def _reconcile_check(gpu_index: int) -> dict | None:
}
@app.post("/api/curve/write")
@app.post(
"/api/curve/write",
tags=["Curve"],
responses={
**R_AUTH,
**R_GPU,
400: {"description": "Delta exceeds the server-side safety cap (max_delta_khz); detail.errors[] lists the offending points."},
500: R_HW_FAILED,
},
)
async def api_curve_write(req: WriteRequest, gpu_index: int = 0):
"""Write per-point frequency offsets. {deltas: {point_index: delta_kHz}}"""
gpu, g_state = _require_gpu(gpu_index)
@@ -1696,7 +1862,16 @@ async def api_curve_write(req: WriteRequest, gpu_index: int = 0):
return result
@app.post("/api/curve/write/global")
@app.post(
"/api/curve/write/global",
tags=["Curve"],
responses={
**R_AUTH,
**R_GPU,
400: {"description": "Offset exceeds the server-side safety cap (max_delta_khz); detail.errors[] lists the offending points."},
500: R_HW_FAILED,
},
)
async def api_curve_write_global(req: GlobalOffsetRequest, gpu_index: int = 0):
"""Apply a uniform frequency offset to all curve points."""
gpu, g_state = _require_gpu(gpu_index)
@@ -1746,7 +1921,7 @@ async def api_curve_write_global(req: GlobalOffsetRequest, gpu_index: int = 0):
return result
@app.post("/api/curve/reset")
@app.post("/api/curve/reset", tags=["Curve"], responses={**R_AUTH, **R_GPU, 500: R_HW_FAILED})
async def api_curve_reset(gpu_index: int = 0):
"""Reset all frequency offsets to zero."""
gpu, g_state = _require_gpu(gpu_index)
@@ -1777,7 +1952,16 @@ async def api_curve_reset(gpu_index: int = 0):
return result
@app.post("/api/curve/verify")
@app.post(
"/api/curve/verify",
tags=["Curve"],
responses={
**R_AUTH,
**R_GPU,
400: {"description": "Delta exceeds the server-side safety cap (max_delta_khz); detail.errors[] lists the offending points."},
500: R_HW_FAILED,
},
)
async def api_curve_verify(req: VerifyRequest, gpu_index: int = 0):
"""Write-verify-read cycle. Returns per-point match results and collateral changes."""
gpu, g_state = _require_gpu(gpu_index)
@@ -1847,7 +2031,14 @@ async def api_curve_verify(req: VerifyRequest, gpu_index: int = 0):
}
@app.post("/api/shutdown")
@app.post(
"/api/shutdown",
tags=["Server"],
responses={
**R_AUTH,
403: {"description": "API shutdown disabled (allow_api_shutdown: false in config)."},
},
)
async def api_shutdown():
"""Gracefully shut down the server process.
@@ -1869,7 +2060,7 @@ async def api_shutdown():
return {"ok": True}
@app.post("/api/snapshot/save")
@app.post("/api/snapshot/save", tags=["Snapshots"], responses={**R_AUTH, **R_GPU, 500: R_HW_FAILED})
async def api_snapshot_save(gpu_index: int = 0):
"""Save a ClockBoostTable snapshot."""
gpu, g_state = _require_gpu(gpu_index)
@@ -1882,7 +2073,7 @@ async def api_snapshot_save(gpu_index: int = 0):
return {"ok": True, "filepath": path}
@app.post("/api/snapshot/restore")
@app.post("/api/snapshot/restore", tags=["Snapshots"], responses={**R_AUTH, **R_GPU, 500: R_HW_FAILED})
async def api_snapshot_restore(req: SnapshotRestoreRequest, gpu_index: int = 0):
"""Restore a ClockBoostTable snapshot. Uses most recent if filepath not specified."""
gpu, g_state = _require_gpu(gpu_index)
@@ -2087,7 +2278,7 @@ if os.path.isdir(os.path.join(_dist_dir, "assets")):
)
@app.get("/{catchall:path}")
@app.get("/{catchall:path}", include_in_schema=False)
async def serve_spa(catchall: str):
if catchall.startswith(("api/", "ws/")):
raise HTTPException(status_code=404, detail="Not Found")