feat(wolf): show Hermes GPU fallback state

This commit is contained in:
codex 2026-08-01 22:31:46 -03:00
parent 89ea032142
commit e9655ab61f
7 changed files with 86 additions and 24 deletions

View File

@ -43,6 +43,7 @@ def request_raw(
*, *,
payload: Any | None = None, payload: Any | None = None,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
timeout_sec: float | None = None,
) -> httpx.Response: ) -> httpx.Response:
"""Send one authenticated request to Ariadne and return the raw response. """Send one authenticated request to Ariadne and return the raw response.
@ -55,9 +56,10 @@ def request_raw(
url = _url(path) url = _url(path)
attempts = max(1, settings.ARIADNE_RETRY_COUNT) attempts = max(1, settings.ARIADNE_RETRY_COUNT)
request_timeout = timeout_sec if timeout_sec is not None else settings.ARIADNE_TIMEOUT_SEC
for attempt in range(1, attempts + 1): for attempt in range(1, attempts + 1):
try: try:
with httpx.Client(timeout=settings.ARIADNE_TIMEOUT_SEC) as client: with httpx.Client(timeout=request_timeout) as client:
resp = client.request( resp = client.request(
method, method,
url, url,
@ -78,7 +80,7 @@ def request_raw(
"method": method, "method": method,
"path": path, "path": path,
"attempt": attempt, "attempt": attempt,
"timeout_sec": settings.ARIADNE_TIMEOUT_SEC, "timeout_sec": request_timeout,
"error": str(exc), "error": str(exc),
}, },
) )
@ -93,6 +95,7 @@ def proxy(
*, *,
payload: Any | None = None, payload: Any | None = None,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
timeout_sec: float | None = None,
) -> tuple[Any, int]: ) -> tuple[Any, int]:
"""Proxy an Ariadne response through Flask as JSON plus status code. """Proxy an Ariadne response through Flask as JSON plus status code.
@ -101,7 +104,7 @@ def proxy(
""" """
try: try:
resp = request_raw(method, path, payload=payload, params=params) resp = request_raw(method, path, payload=payload, params=params, timeout_sec=timeout_sec)
except AriadneError as exc: except AriadneError as exc:
return jsonify({"error": str(exc)}), exc.status_code return jsonify({"error": str(exc)}), exc.status_code

View File

@ -5,7 +5,7 @@ from typing import Any
from flask import jsonify, request from flask import jsonify, request
from .. import ariadne_client from .. import ariadne_client, settings
from ..keycloak import require_auth, require_account_access from ..keycloak import require_auth, require_account_access
@ -142,7 +142,12 @@ def register_account_wolf(app) -> None:
ok, resp = _require_account() ok, resp = _require_account()
if not ok: if not ok:
return resp return resp
return ariadne_client.proxy("POST", "/api/admin/game-mode/start", payload=_json_payload()) return ariadne_client.proxy(
"POST",
"/api/admin/game-mode/start",
payload=_json_payload(),
timeout_sec=settings.ARIADNE_GAME_MODE_TIMEOUT_SEC,
)
@app.route("/api/account/wolf/game-mode/stop", methods=["POST"]) @app.route("/api/account/wolf/game-mode/stop", methods=["POST"])
@require_auth @require_auth
@ -150,7 +155,12 @@ def register_account_wolf(app) -> None:
ok, resp = _require_account() ok, resp = _require_account()
if not ok: if not ok:
return resp return resp
return ariadne_client.proxy("POST", "/api/admin/game-mode/stop", payload=_json_payload()) return ariadne_client.proxy(
"POST",
"/api/admin/game-mode/stop",
payload=_json_payload(),
timeout_sec=settings.ARIADNE_GAME_MODE_TIMEOUT_SEC,
)
@app.route("/api/account/wolf/admin/firewall/unlock", methods=["POST"]) @app.route("/api/account/wolf/admin/firewall/unlock", methods=["POST"])
@require_auth @require_auth

View File

@ -80,6 +80,7 @@ KEYCLOAK_ADMIN_REALM = os.getenv("KEYCLOAK_ADMIN_REALM", KEYCLOAK_REALM)
ARIADNE_URL = os.getenv("ARIADNE_URL", "").strip() ARIADNE_URL = os.getenv("ARIADNE_URL", "").strip()
ARIADNE_TIMEOUT_SEC = float(os.getenv("ARIADNE_TIMEOUT_SEC", "10")) ARIADNE_TIMEOUT_SEC = float(os.getenv("ARIADNE_TIMEOUT_SEC", "10"))
ARIADNE_GAME_MODE_TIMEOUT_SEC = float(os.getenv("ARIADNE_GAME_MODE_TIMEOUT_SEC", "900"))
ARIADNE_RETRY_COUNT = int(os.getenv("ARIADNE_RETRY_COUNT", "2")) ARIADNE_RETRY_COUNT = int(os.getenv("ARIADNE_RETRY_COUNT", "2"))
ARIADNE_RETRY_BACKOFF_SEC = float(os.getenv("ARIADNE_RETRY_BACKOFF_SEC", "0.2")) ARIADNE_RETRY_BACKOFF_SEC = float(os.getenv("ARIADNE_RETRY_BACKOFF_SEC", "0.2"))

View File

@ -8,13 +8,20 @@ from atlas_portal.routes import account_wolf
class DummyAriadne: class DummyAriadne:
def __init__(self, enabled: bool = True) -> None: def __init__(self, enabled: bool = True) -> None:
self._enabled = enabled self._enabled = enabled
self.calls: list[tuple[str, str, object | None, dict | None]] = [] self.calls: list[tuple[str, str, object | None, dict | None, float | None]] = []
def enabled(self) -> bool: def enabled(self) -> bool:
return self._enabled return self._enabled
def proxy(self, method: str, path: str, payload: object | None = None, params: dict | None = None): def proxy(
self.calls.append((method, path, payload, params)) self,
method: str,
path: str,
payload: object | None = None,
params: dict | None = None,
timeout_sec: float | None = None,
):
self.calls.append((method, path, payload, params, timeout_sec))
return jsonify({"path": path, "payload": payload, "params": params}) return jsonify({"path": path, "payload": payload, "params": params})
@ -42,7 +49,7 @@ def test_wolf_status_proxies_source_ip(monkeypatch) -> None:
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert ariadne.calls == [("GET", "/api/game-stream/status", None, {"source_ip": "1.2.3.4"})] assert ariadne.calls == [("GET", "/api/game-stream/status", None, {"source_ip": "1.2.3.4"}, None)]
def test_wolf_status_prefers_public_query_ip(monkeypatch) -> None: def test_wolf_status_prefers_public_query_ip(monkeypatch) -> None:
@ -54,7 +61,7 @@ def test_wolf_status_prefers_public_query_ip(monkeypatch) -> None:
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert ariadne.calls == [("GET", "/api/game-stream/status", None, {"source_ip": "181.1.87.186"})] assert ariadne.calls == [("GET", "/api/game-stream/status", None, {"source_ip": "181.1.87.186"}, None)]
def test_wolf_unlock_uses_current_source_ip(monkeypatch) -> None: def test_wolf_unlock_uses_current_source_ip(monkeypatch) -> None:
@ -68,7 +75,7 @@ def test_wolf_unlock_uses_current_source_ip(monkeypatch) -> None:
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ttl_seconds": 120, "ip": "5.6.7.8"}, None)] assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ttl_seconds": 120, "ip": "5.6.7.8"}, None, None)]
def test_wolf_unlock_prefers_public_payload_ip(monkeypatch) -> None: def test_wolf_unlock_prefers_public_payload_ip(monkeypatch) -> None:
@ -81,7 +88,7 @@ def test_wolf_unlock_prefers_public_payload_ip(monkeypatch) -> None:
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "181.1.87.186"}, None)] assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "181.1.87.186"}, None, None)]
def test_wolf_unlock_ignores_private_payload_ip(monkeypatch) -> None: def test_wolf_unlock_ignores_private_payload_ip(monkeypatch) -> None:
@ -94,7 +101,7 @@ def test_wolf_unlock_ignores_private_payload_ip(monkeypatch) -> None:
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "5.6.7.8"}, None)] assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "5.6.7.8"}, None, None)]
def test_wolf_source_ip_prefers_nearest_public_proxy_value(monkeypatch) -> None: def test_wolf_source_ip_prefers_nearest_public_proxy_value(monkeypatch) -> None:
@ -108,7 +115,7 @@ def test_wolf_source_ip_prefers_nearest_public_proxy_value(monkeypatch) -> None:
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "181.1.87.186"}, None)] assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "181.1.87.186"}, None, None)]
def test_wolf_pairing_and_admin_actions_proxy(monkeypatch) -> None: def test_wolf_pairing_and_admin_actions_proxy(monkeypatch) -> None:
@ -125,11 +132,11 @@ def test_wolf_pairing_and_admin_actions_proxy(monkeypatch) -> None:
client.post("/api/account/wolf/admin/firewall/unlock", json={"ip": "8.8.8.8", "target_user": "olya"}) client.post("/api/account/wolf/admin/firewall/unlock", json={"ip": "8.8.8.8", "target_user": "olya"})
assert ariadne.calls == [ assert ariadne.calls == [
("GET", "/api/game-stream/pairing/status", None, {"source_ip": "181.1.87.186"}), ("GET", "/api/game-stream/pairing/status", None, {"source_ip": "181.1.87.186"}, None),
("POST", "/api/game-stream/pairing/submit-pin", {"pair_secret": "secret", "pin": "1234", "source_ip": "181.1.87.186"}, None), ("POST", "/api/game-stream/pairing/submit-pin", {"pair_secret": "secret", "pin": "1234", "source_ip": "181.1.87.186"}, None, None),
("POST", "/api/admin/game-mode/start", {"game": "steam"}, None), ("POST", "/api/admin/game-mode/start", {"game": "steam"}, None, account_wolf.settings.ARIADNE_GAME_MODE_TIMEOUT_SEC),
("POST", "/api/admin/game-mode/stop", {"game": "steam"}, None), ("POST", "/api/admin/game-mode/stop", {"game": "steam"}, None, account_wolf.settings.ARIADNE_GAME_MODE_TIMEOUT_SEC),
("POST", "/api/admin/game-stream/firewall/unlock", {"ip": "8.8.8.8", "target_user": "olya"}, None), ("POST", "/api/admin/game-stream/firewall/unlock", {"ip": "8.8.8.8", "target_user": "olya"}, None, None),
] ]
@ -143,7 +150,7 @@ def test_wolf_user_revoke_accepts_public_payload_ip(monkeypatch) -> None:
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert ariadne.calls == [("POST", "/api/game-stream/firewall/revoke", {"ip": "9.9.9.9"}, None)] assert ariadne.calls == [("POST", "/api/game-stream/firewall/revoke", {"ip": "9.9.9.9"}, None, None)]
def test_wolf_routes_require_account_and_ariadne(monkeypatch) -> None: def test_wolf_routes_require_account_and_ariadne(monkeypatch) -> None:

View File

@ -47,8 +47,13 @@ export function useWolfDashboard() {
sourceIp: "", sourceIp: "",
unlockTtlSeconds: 28800, unlockTtlSeconds: 28800,
gpuPriority: "unknown", gpuPriority: "unknown",
gpuOwner: "unknown",
gameModeStatus: "unknown", gameModeStatus: "unknown",
gameModeActive: false, gameModeActive: false,
localModel: "unknown",
localModelLoaded: false,
localInferenceReady: false,
inferencePath: "fallback",
selectedGame: "steam", selectedGame: "steam",
note: "", note: "",
manualIp: "", manualIp: "",
@ -82,8 +87,14 @@ export function useWolfDashboard() {
wolf.sourceIp = data.moonlight?.source_ip || ""; wolf.sourceIp = data.moonlight?.source_ip || "";
wolf.unlockTtlSeconds = Number(data.moonlight?.unlock_ttl_seconds || 28800); wolf.unlockTtlSeconds = Number(data.moonlight?.unlock_ttl_seconds || 28800);
wolf.gpuPriority = data.gpu?.priority || "unknown"; wolf.gpuPriority = data.gpu?.priority || "unknown";
wolf.gameModeStatus = data.gpu?.game_mode?.status || "unknown"; const gameMode = data.gpu?.game_mode || {};
wolf.gameModeActive = Boolean(data.gpu?.game_mode?.active); wolf.gpuOwner = gameMode.gpu_owner || "unknown";
wolf.gameModeStatus = gameMode.status || "unknown";
wolf.gameModeActive = Boolean(gameMode.active);
wolf.localModel = gameMode.model || "unknown";
wolf.localModelLoaded = Boolean(gameMode.model_loaded);
wolf.localInferenceReady = Boolean(gameMode.local_inference_ready);
wolf.inferencePath = gameMode.inference_path || "fallback";
wolf.clients = Array.isArray(data.wolf?.clients) ? data.wolf.clients : []; wolf.clients = Array.isArray(data.wolf?.clients) ? data.wolf.clients : [];
wolf.pendingPairRequests = Array.isArray(data.wolf?.pending_pair_requests) ? data.wolf.pending_pair_requests : []; wolf.pendingPairRequests = Array.isArray(data.wolf?.pending_pair_requests) ? data.wolf.pending_pair_requests : [];
wolf.sessions = Array.isArray(data.wolf?.sessions) ? data.wolf.sessions : []; wolf.sessions = Array.isArray(data.wolf?.sessions) ? data.wolf.sessions : [];

View File

@ -35,6 +35,12 @@
<span class="k mono">GPU</span> <span class="k mono">GPU</span>
<strong class="mono">{{ wolf.gpuPriority }}</strong> <strong class="mono">{{ wolf.gpuPriority }}</strong>
</div> </div>
<div class="wolf-summary-item">
<span class="k mono">Hermes</span>
<strong class="mono" :class="wolf.localInferenceReady ? 'ok-text' : 'warn-text'">
{{ wolf.inferencePath }}
</strong>
</div>
<div class="wolf-summary-item"> <div class="wolf-summary-item">
<span class="k mono">Firewall</span> <span class="k mono">Firewall</span>
<strong class="mono" :class="currentIpUnlocked ? 'ok-text' : 'warn-text'"> <strong class="mono" :class="currentIpUnlocked ? 'ok-text' : 'warn-text'">
@ -60,6 +66,14 @@
<span class="k mono">Paired devices</span> <span class="k mono">Paired devices</span>
<span class="v mono">{{ pairedDeviceNames }}</span> <span class="v mono">{{ pairedDeviceNames }}</span>
</div> </div>
<div class="row">
<span class="k mono">GPU owner</span>
<span class="v mono">{{ wolf.gpuOwner }}</span>
</div>
<div class="row">
<span class="k mono">Local model</span>
<span class="v mono">{{ wolf.localModel }} · {{ wolf.localModelLoaded ? "loaded" : "unloaded" }}</span>
</div>
</div> </div>
<div class="actions"> <div class="actions">

View File

@ -215,7 +215,18 @@ describe("account dashboard", () => {
return jsonResponse({ return jsonResponse({
can_control_gpu: true, can_control_gpu: true,
moonlight: { host: "moonlight.bstein.dev", source_ip: "181.1.87.186", unlock_ttl_seconds: 28800 }, moonlight: { host: "moonlight.bstein.dev", source_ip: "181.1.87.186", unlock_ttl_seconds: 28800 },
gpu: { priority: "ai", game_mode: { status: "idle", active: false } }, gpu: {
priority: "ai",
game_mode: {
status: "idle",
active: false,
gpu_owner: "hermes",
model: "gpt-oss:20b",
model_loaded: true,
local_inference_ready: true,
inference_path: "local",
},
},
wolf: { wolf: {
api_enabled: true, api_enabled: true,
clients: [{ name: "Desktop" }], clients: [{ name: "Desktop" }],
@ -236,6 +247,11 @@ describe("account dashboard", () => {
expect(dashboard.wolf.status).toBe("ready"); expect(dashboard.wolf.status).toBe("ready");
expect(dashboard.wolf.canControlGpu).toBe(true); expect(dashboard.wolf.canControlGpu).toBe(true);
expect(dashboard.wolf.gpuOwner).toBe("hermes");
expect(dashboard.wolf.localModel).toBe("gpt-oss:20b");
expect(dashboard.wolf.localModelLoaded).toBe(true);
expect(dashboard.wolf.localInferenceReady).toBe(true);
expect(dashboard.wolf.inferencePath).toBe("local");
expect(dashboard.wolf.clients[0].name).toBe("Desktop"); expect(dashboard.wolf.clients[0].name).toBe("Desktop");
expect(dashboard.wolf.pendingPairRequests[0].pair_secret).toBe("secret-1"); expect(dashboard.wolf.pendingPairRequests[0].pair_secret).toBe("secret-1");