hugpy-worker 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ """hugpy_worker — the per-box hugpy worker over the fitevict front door.
2
+
3
+ Installs on every box (ae, computron, a-brain, …) next to fitevict. Registers
4
+ with the hugpy_core router, heartbeats, and relays /infer calls to the box's
5
+ front door. Holds no model and makes no placement decision.
6
+ """
7
+ __version__ = "0.1.0"
@@ -0,0 +1,131 @@
1
+ # LIFTED VERBATIM from:
2
+ # /srv/hugpy/src/hugpy/py/fleet/hugpy_fleet/src/hugpy_fleet/worker/agent.py
3
+ # lines 607-648 (WorkerRejected, central refusal markers),
4
+ # 678-729 (CentralClient), 7403-7417 (_load_worker_id / _save_worker_id).
5
+ # DROPPED: _db_heartbeat_async (Postgres heartbeat mirror; its call removed from
6
+ # CentralClient.heartbeat) and CentralClient.evictions_ingest — fitevict's
7
+ # call_log is the record and the HTTP heartbeat is the only beat.
8
+ """Central node client (registration + heartbeat)."""
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import urllib.error
15
+ import urllib.request
16
+
17
+ logger = logging.getLogger("hugpy_worker")
18
+
19
+
20
+ class WorkerRejected(Exception):
21
+ """Central refused this worker terminally (401 token / 403 blocked).
22
+
23
+ Distinct from a transient error or a 410 (re-register): the operator has
24
+ revoked/blocked us, so the agent should stop rather than retry.
25
+ """
26
+ def __init__(self, code: int, message: str = ""):
27
+ super().__init__(message or f"rejected with HTTP {code}")
28
+ self.code = code
29
+
30
+
31
+ # CENTRAL'S OWN terminal-refusal markers. A 401/403 is only terminal (stop, don't
32
+ # respawn) when it is CENTRAL saying so — its register/heartbeat routes
33
+ # abort(401/403, description=...) with these exact strings (see
34
+ # hugpy_server/app/routes/worker_routes.py: "Worker is blocked by the operator."
35
+ # at the admission gate, "Worker enrollment token invalid or required." at the
36
+ # enrollment gate). A 401/403 whose body carries NEITHER marker is almost always
37
+ # an INTERMEDIARY between us and central — a reverse-proxy / web auth gate /
38
+ # captive login page (a-brain 2026-09-24: dev.hugpy.ai's web gate returned a 403
39
+ # HTML page while central's own record was 'approved' and central logged nothing;
40
+ # the agent read it as a block and os._exit(0)'d permanently). Those must be
41
+ # logged with the URL + body and RETRIED with backoff, never treated as a block.
42
+ _CENTRAL_BLOCK_MARKER = "Worker is blocked by the operator."
43
+ _CENTRAL_ENROLL_MARKER = "Worker enrollment token invalid or required."
44
+
45
+
46
+ def _is_central_terminal_refusal(code: int, body: str) -> bool:
47
+ """True iff an HTTP ``code``/``body`` is CENTRAL's own terminal worker refusal
48
+ (block or enrollment reject), as opposed to any other 401/403 from a proxy or
49
+ web gate sitting in front of central. Body-marker matched, case-insensitively
50
+ and whitespace-tolerant, so an HTML error page that wraps the description
51
+ still matches while a generic gate page does not."""
52
+ if code not in (401, 403):
53
+ return False
54
+ hay = " ".join((body or "").split()).lower()
55
+ if not hay:
56
+ # Central always sends a description; a truly empty body is NOT central's
57
+ # refusal (a bare proxy 403). Don't treat it as terminal.
58
+ return False
59
+ return (_CENTRAL_BLOCK_MARKER.lower() in hay
60
+ or _CENTRAL_ENROLL_MARKER.lower() in hay)
61
+
62
+
63
+
64
+ class CentralClient:
65
+ def __init__(self, central_url: str, token: str | None = None):
66
+ # Endpoints live under /api on the central Flask app.
67
+ self.base = central_url.rstrip("/") + "/api/llm/workers"
68
+ self.token = token
69
+
70
+ def _post(self, path: str, payload: dict) -> dict:
71
+ data = json.dumps(payload).encode("utf-8")
72
+ headers = {"Content-Type": "application/json"}
73
+ if self.token:
74
+ headers["Authorization"] = f"Bearer {self.token}"
75
+ req = urllib.request.Request(
76
+ self.base + path, data=data, headers=headers, method="POST",
77
+ )
78
+ try:
79
+ with urllib.request.urlopen(req, timeout=20) as resp:
80
+ return json.loads(resp.read().decode("utf-8"))
81
+ except urllib.error.HTTPError as exc:
82
+ # A 401/403 is terminal (stop, don't respawn) ONLY when CENTRAL says
83
+ # so — its route bodies carry a specific marker (see
84
+ # _is_central_terminal_refusal). Any other 401/403 is an intermediary
85
+ # (proxy / web gate / login page) between us and central: log it with
86
+ # the URL + body snippet and re-raise so the caller RETRIES with
87
+ # backoff. Other codes (e.g. 410 "re-register") propagate unchanged.
88
+ if exc.code in (401, 403):
89
+ body = ""
90
+ try:
91
+ raw = exc.read()
92
+ body = raw.decode("utf-8", "replace") if raw else ""
93
+ except Exception: # noqa: BLE001 — body is best-effort context
94
+ body = ""
95
+ if _is_central_terminal_refusal(exc.code, body):
96
+ reason = exc.reason or ""
97
+ detail = " ".join(body.split())[:300]
98
+ raise WorkerRejected(
99
+ exc.code,
100
+ (reason + (": " + detail if detail else "")).strip()) from exc
101
+ logger.warning(
102
+ "central call %s returned HTTP %s but the body is NOT central's "
103
+ "own worker refusal — treating it as an intermediary (proxy / "
104
+ "web gate), NOT a block; will retry. body: %s",
105
+ self.base + path, exc.code,
106
+ (" ".join(body.split())[:300] or "(empty)"))
107
+ raise
108
+ raise
109
+
110
+ def register(self, payload: dict) -> dict:
111
+ return self._post("/register", payload)
112
+
113
+ def heartbeat(self, worker_id: str, payload: dict) -> dict:
114
+ return self._post(f"/{worker_id}/heartbeat", payload)
115
+
116
+
117
+ def _load_worker_id(path: str) -> str | None:
118
+ try:
119
+ with open(path, "r", encoding="utf-8") as fh:
120
+ return (json.load(fh) or {}).get("worker_id")
121
+ except (OSError, ValueError):
122
+ return None
123
+
124
+
125
+ def _save_worker_id(path: str, worker_id: str) -> None:
126
+ try:
127
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
128
+ with open(path, "w", encoding="utf-8") as fh:
129
+ json.dump({"worker_id": worker_id}, fh)
130
+ except OSError:
131
+ logger.warning("could not persist worker id to %s", path)
hugpy_worker/worker.py ADDED
@@ -0,0 +1,600 @@
1
+ # LIFTED from:
2
+ # /srv/hugpy/src/hugpy/py/fleet/hugpy_fleet/src/hugpy_fleet/worker/agent.py
3
+ # — the central<->worker CONTRACT only: WorkerState (6619, trimmed to the
4
+ # fields the contract uses), _ram_total_bytes (522), _sse (3187),
5
+ # _terminal_exit (15609), build_app routes /health /infer /infer/stream
6
+ # /infer/cancel (4606-4925), _heartbeat_loop (16047), _register (16309),
7
+ # _build_parser (contract flags) and main.
8
+ # SWAPPED: everything below the routes (execute_chat_stream / execute_prompt,
9
+ # _ensure_present*, gen_gate, _prepare_load_contract, spill, job_store,
10
+ # aggregate) -> one HTTP call to the fitevict front door
11
+ # (POST {front_door}/v1/chat/completions). fitevict owns evict-to-fit, load,
12
+ # serve and call_log; this process holds no model and decides no placement.
13
+ # DROPPED from the beat/register: telemetry produced by the dropped machinery
14
+ # (slots, allocations, pid_registry, calibration, aggregate, studio, comfy,
15
+ # storage, doctrine, self-update). Central's request models default them.
16
+ """hugpy worker harness: central sees an ordinary worker, fitevict serves."""
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import logging
22
+ import os
23
+ import socket
24
+ import sys
25
+ import threading
26
+ import time
27
+ import urllib.error
28
+ import urllib.request
29
+ import uuid
30
+
31
+ from flask import Flask, Response, jsonify, request, stream_with_context
32
+
33
+ from fitevict.lifted.hardware import detect_gpus, free_ram_bytes
34
+
35
+ from . import __version__
36
+ from .central_client import (CentralClient, WorkerRejected, _load_worker_id,
37
+ _save_worker_id)
38
+
39
+ logger = logging.getLogger("hugpy_worker")
40
+
41
+ ENGINE = {"installed": True, "backend": "fitevict", "supports_gpu_offload": True}
42
+
43
+
44
+ class WorkerState:
45
+ def __init__(self, name: str, url: str | None, worker_id: str | None,
46
+ central_url: str | None = None, port: int | None = None,
47
+ front_door: str = "http://127.0.0.1:8080"):
48
+ self.name = name
49
+ self.url = url # None unless operator set --advertise/WORKER_URL
50
+ self.worker_id = worker_id
51
+ self.central_url = central_url
52
+ self.port = port
53
+ self.front_door = front_door.rstrip("/")
54
+ # Models central says we should serve (adopted from register/heartbeat).
55
+ self.assigned_models: list[str] = []
56
+ # In-flight request ids, so /infer/cancel can answer honestly.
57
+ self.inflight: set[str] = set()
58
+ # Last host constraints pushed to the wrapper (None = never pushed).
59
+ self.limits_sent: "dict | None" = None
60
+
61
+
62
+ # ── fitevict front door (the swap) ──────────────────────────────────────────
63
+ def _fd_models(state: WorkerState) -> "list[dict] | None":
64
+ """GET /v1/models from the front door: [{id, resident, ...discovery}], or
65
+ None when it could not be read (NOT an empty inventory)."""
66
+ try:
67
+ with urllib.request.urlopen(state.front_door + "/v1/models", timeout=10) as r:
68
+ return (json.loads(r.read().decode("utf-8")) or {}).get("data") or []
69
+ except Exception: # noqa: BLE001
70
+ return None
71
+
72
+
73
+ def loaded_model_keys(state: WorkerState) -> list[str]:
74
+ return sorted(m["id"] for m in (_fd_models(state) or []) if m.get("resident"))
75
+
76
+
77
+ def _models_local(state: WorkerState) -> list[str]:
78
+ return sorted(m["id"] for m in (_fd_models(state) or []))
79
+
80
+
81
+ # Idle-vs-serving window (legacy agent.py:1834 _SERVING_WINDOW_S).
82
+ _SERVING_WINDOW_S = 180.0
83
+
84
+
85
+ def _fd_residents(state: WorkerState) -> list[dict]:
86
+ """GET /residents from the front door: its running llama-server children."""
87
+ try:
88
+ with urllib.request.urlopen(state.front_door + "/residents", timeout=10) as r:
89
+ return (json.loads(r.read().decode("utf-8")) or {}).get("residents") or []
90
+ except Exception: # noqa: BLE001
91
+ return []
92
+
93
+
94
+ def _allocations(residents: list[dict]) -> list[dict]:
95
+ """One kind="slot" row per front-door child, in the legacy allocation row
96
+ shape (agent.py _allocations, slot rows ~2495-2540): measured VRAM per pid
97
+ (device_source "measured"), else the declared placement ("inferred")."""
98
+ now = time.time()
99
+ rows = []
100
+ for c in residents:
101
+ if not c.get("alive"):
102
+ continue
103
+ vram = c.get("vram_bytes")
104
+ if vram is not None:
105
+ device, source = ("cuda" if vram > 0 else "cpu"), "measured"
106
+ else:
107
+ ngl = c.get("n_gpu_layers")
108
+ device = None if ngl is None else ("cpu" if ngl == 0 else "cuda")
109
+ source = "inferred" if device else None
110
+ last_used = c.get("last_used") or 0.0
111
+ row = {
112
+ "kind": "slot", "model_key": c["key"],
113
+ "slot_id": c.get("port"), "healthy": c.get("healthy"),
114
+ "busy": c.get("busy"), "endpoint": c.get("endpoint"),
115
+ "rss_bytes": c.get("ram_bytes"),
116
+ "n_gpu_layers": c.get("n_gpu_layers"), "ctx": c.get("ctx"),
117
+ "vram_bytes": vram, "device": device,
118
+ "last_used": last_used,
119
+ "serving": bool(c.get("busy")) or (last_used > 0 and now - last_used <= _SERVING_WINDOW_S),
120
+ "materialized": bool(c.get("healthy")),
121
+ }
122
+ if c.get("n_cpu_moe") is not None:
123
+ row["n_cpu_moe"] = c["n_cpu_moe"]
124
+ if source is not None:
125
+ row["device_source"] = source
126
+ if c.get("loading"):
127
+ row["loading"] = True
128
+ row["materialized"] = False
129
+ rows.append(row)
130
+ return rows
131
+
132
+
133
+ def _models_discovered(models: list[dict]) -> dict:
134
+ """{model_key: discovery detail} — every model the box's fitevict found on
135
+ disk, with its directory, elected GGUF and all weight files (quant, bytes)."""
136
+ return {m["id"]: {k: v for k, v in m.items() if k not in ("id", "object", "owned_by")}
137
+ for m in models}
138
+
139
+
140
+ def _fd_chat(state: WorkerState, payload: dict, request_id: str) -> "tuple[int, dict]":
141
+ """Forward one central ChatRequest to the front door. Returns (status, body)."""
142
+ messages = payload.get("messages")
143
+ if not messages and payload.get("prompt"):
144
+ messages = [{"role": "user", "content": payload["prompt"]}]
145
+ body = {"model": payload.get("model_key"), "messages": messages or [],
146
+ "max_tokens": payload.get("max_new_tokens") or payload.get("max_tokens"),
147
+ "task": payload.get("task"),
148
+ "metadata": {"central_request_id": request_id, "worker": state.name}}
149
+ if isinstance(payload.get("knobs"), dict):
150
+ body["knobs"] = payload["knobs"] # central's pair knobs -> the wrapper's load contract
151
+ for k in ("temperature", "top_p", "top_k", "stop", "seed"):
152
+ if payload.get(k) is not None:
153
+ body[k] = payload[k]
154
+ body = {k: v for k, v in body.items() if v is not None}
155
+ req = urllib.request.Request(
156
+ state.front_door + "/v1/chat/completions",
157
+ data=json.dumps(body).encode("utf-8"),
158
+ headers={"Content-Type": "application/json"}, method="POST")
159
+ try:
160
+ with urllib.request.urlopen(req, timeout=900) as r:
161
+ return r.status, json.loads(r.read().decode("utf-8") or "{}")
162
+ except urllib.error.HTTPError as exc:
163
+ try:
164
+ return exc.code, json.loads(exc.read().decode("utf-8") or "{}")
165
+ except Exception: # noqa: BLE001
166
+ return exc.code, {"error": {"message": str(exc)}}
167
+
168
+
169
+ def _reply_parts(body: dict) -> "tuple[str, str, dict, dict]":
170
+ """(text, finish_reason, usage, timings) out of an OpenAI chat payload."""
171
+ ch = ((body.get("choices") or [{}])[0]) if isinstance(body, dict) else {}
172
+ text = ((ch.get("message") or {}).get("content")) or ""
173
+ return (text, ch.get("finish_reason") or "stop",
174
+ body.get("usage") or {}, body.get("timings") or {})
175
+
176
+
177
+ def _error_text(body: dict) -> str:
178
+ err = body.get("error") if isinstance(body, dict) else None
179
+ if isinstance(err, dict):
180
+ return str(err.get("message") or err)
181
+ return str(err or "front door error")
182
+
183
+
184
+ # ── lifted helpers ──────────────────────────────────────────────────────────
185
+ def _ram_total_bytes() -> int | None:
186
+ """RAW physical RAM (MemTotal) in bytes — the pool-budget denominator.
187
+
188
+ Unlike _free_ram_bytes (reserve-adjusted + RAM_MAX-capped so central plans
189
+ against budgetable RAM), this is the box's total installed memory, so the
190
+ console can render used-vs-total. Best-effort, mirroring
191
+ _platform/hardware.free_ram_bytes: psutil first, then /proc/meminfo, else
192
+ None (never fabricated)."""
193
+ try:
194
+ import psutil
195
+ return int(psutil.virtual_memory().total)
196
+ except Exception:
197
+ pass
198
+ if sys.platform.startswith("linux"):
199
+ try:
200
+ with open("/proc/meminfo", "r", encoding="utf-8") as fh:
201
+ for line in fh:
202
+ if line.startswith("MemTotal:"):
203
+ return int(line.split()[1]) * 1024
204
+ except Exception:
205
+ pass
206
+ return None
207
+
208
+
209
+ def _sse(payload: dict) -> bytes:
210
+ # werkzeug's WSGI server asserts the app yields bytes, not str — so encode
211
+ # here. (gunicorn is more lenient, but the worker runs the dev server.)
212
+ return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n".encode("utf-8")
213
+
214
+
215
+ def _terminal_exit(exc: "WorkerRejected") -> None:
216
+ """Stop the agent for good after central refused it (401/403).
217
+
218
+ Called from the daemon heartbeat thread too, so it must kill the whole
219
+ process (the main thread is blocked in the inference server) — hence
220
+ os._exit. Exit code 0 so a ``Restart=on-failure`` unit does NOT respawn a
221
+ deliberately-evicted worker (transient crashes still exit non-zero/killed and
222
+ are restarted as before).
223
+ """
224
+ if getattr(exc, "code", None) == 403:
225
+ logger.error("central BLOCKED this worker (403): %s. Stopping — have the "
226
+ "operator Admit it in the console to rejoin.", exc)
227
+ else:
228
+ logger.error("central refused enrollment (401): %s. Stopping — re-enroll "
229
+ "with a valid WORKER_ENROLL_TOKEN.", exc)
230
+ os._exit(0)
231
+
232
+
233
+ def _sync_assignment(state: WorkerState, worker: dict | None) -> None:
234
+ models = (worker or {}).get("models")
235
+ if isinstance(models, list):
236
+ state.assigned_models = [str(m) for m in models]
237
+ _apply_limits(state, (worker or {}).get("fitevict_limits"))
238
+ _self_update_if_needed(state, (worker or {}).get("required_packages"))
239
+
240
+
241
+ # ── self-update (shape lifted from legacy agent.py _self_update_if_needed):
242
+ # central names the package versions to run (the latest on PyPI); a box behind
243
+ # them pip-installs exactly those from PyPI, then re-execs. Never destructive:
244
+ # it waits until nothing is in flight or loading, and restarts the front door
245
+ # (which drops its resident models) only when hugpy-wrapper itself changed.
246
+ _UPDATE_RETRY_BACKOFF = 600.0
247
+ UPDATE_STATE_FILE = os.path.expanduser(os.environ.get("HUGPY_WORKER_UPDATE_STATE",
248
+ "~/.hugpy_worker/update.json"))
249
+ WRAPPER_PKG = "hugpy-wrapper"
250
+
251
+
252
+ def _editable_install() -> bool:
253
+ """A dev box runs the source tree (pip -e): it never self-updates."""
254
+ try:
255
+ from importlib.metadata import distribution
256
+ raw = distribution("hugpy_worker").read_text("direct_url.json") or "{}"
257
+ return bool((json.loads(raw).get("dir_info") or {}).get("editable"))
258
+ except Exception: # noqa: BLE001
259
+ return False
260
+
261
+
262
+ def _installed(pkg: str) -> "str | None":
263
+ try:
264
+ from importlib.metadata import version
265
+ return version(pkg)
266
+ except Exception: # noqa: BLE001
267
+ return None
268
+
269
+
270
+ def _load_update_state() -> dict:
271
+ try:
272
+ with open(UPDATE_STATE_FILE, "r", encoding="utf-8") as fh:
273
+ return json.load(fh) or {}
274
+ except (OSError, ValueError):
275
+ return {}
276
+
277
+
278
+ def _save_update_state(d: dict) -> None:
279
+ try:
280
+ os.makedirs(os.path.dirname(UPDATE_STATE_FILE), exist_ok=True)
281
+ with open(UPDATE_STATE_FILE, "w", encoding="utf-8") as fh:
282
+ json.dump(d, fh)
283
+ except OSError:
284
+ logger.warning("could not write %s", UPDATE_STATE_FILE)
285
+
286
+
287
+ def _box_idle(state: WorkerState) -> bool:
288
+ """Nothing in flight here and nothing busy or loading in the front door."""
289
+ if state.inflight:
290
+ return False
291
+ try:
292
+ with urllib.request.urlopen(state.front_door + "/residents", timeout=5) as r:
293
+ res = (json.loads(r.read().decode("utf-8")) or {}).get("residents") or []
294
+ except Exception: # noqa: BLE001 — front door down: nothing of its to lose
295
+ return True
296
+ return not any(x.get("busy") or x.get("loading") for x in res)
297
+
298
+
299
+ def _self_update_if_needed(state: WorkerState, required: "dict | None") -> None:
300
+ if not isinstance(required, dict) or not required or _editable_install():
301
+ return # central isn't naming versions, or this is a dev (source) install
302
+ want = {p: v for p, v in required.items() if v and _installed(p) != v}
303
+ if not want:
304
+ return
305
+ st = _load_update_state()
306
+ if st.get("target") == want and (time.time() - st.get("at", 0)) < _UPDATE_RETRY_BACKOFF:
307
+ return # already tried this exact target recently; back off
308
+ if not _box_idle(state):
309
+ return # retried on the next beat, once the box is idle
310
+ import subprocess
311
+ logger.info("self-update from PyPI: %s", ", ".join(
312
+ f"{p} {_installed(p) or '(none)'} -> {v}" for p, v in want.items()))
313
+ rc = 1
314
+ try:
315
+ # the wrapper's pre-rename dist owns the same files: remove it first
316
+ if _installed("fitevict") is not None:
317
+ subprocess.call([sys.executable, "-m", "pip", "uninstall", "-y", "-q", "fitevict"])
318
+ rc = subprocess.call([sys.executable, "-m", "pip", "install", "-q",
319
+ *[f"{p}=={v}" for p, v in want.items()]])
320
+ except Exception as exc: # noqa: BLE001
321
+ logger.warning("self-update install failed: %s", exc)
322
+ if rc != 0:
323
+ _save_update_state({"target": want, "at": time.time(), "rc": rc})
324
+ logger.warning("self-update failed (rc=%s); staying on the installed versions", rc)
325
+ return
326
+ _save_update_state({"installed": want, "at": time.time(), "rc": 0})
327
+ if WRAPPER_PKG in want:
328
+ logger.info("self-update: %s changed; restarting the front door", WRAPPER_PKG)
329
+ try:
330
+ subprocess.call(["systemctl", "--user", "restart", "fitevict-frontdoor.service"],
331
+ timeout=60)
332
+ except Exception as exc: # noqa: BLE001
333
+ logger.warning("front door restart failed: %s", exc)
334
+ logger.info("self-update installed %s; re-exec", want)
335
+ os.execv(sys.executable, [sys.executable, "-m", "hugpy_worker.worker", *sys.argv[1:]])
336
+
337
+
338
+ def _apply_limits(state: WorkerState, lim) -> None:
339
+ """Hand central's host constraints (cap + protected set) to the wrapper,
340
+ only when they changed. Constraints, not knobs: the wrapper holds them."""
341
+ if not isinstance(lim, dict):
342
+ return
343
+ want = {"gpu_limit": lim.get("gpu_limit"), "ram_limit": lim.get("ram_limit"),
344
+ "protected": sorted(lim.get("protected") or [])}
345
+ if want == state.limits_sent:
346
+ return
347
+ req = urllib.request.Request(state.front_door + "/limits",
348
+ data=json.dumps(want).encode("utf-8"),
349
+ headers={"Content-Type": "application/json"}, method="POST")
350
+ try:
351
+ with urllib.request.urlopen(req, timeout=10):
352
+ state.limits_sent = want
353
+ logger.info("wrapper limits set: %s", want)
354
+ except Exception as exc: # noqa: BLE001 — retried on the next beat
355
+ logger.warning("could not set wrapper limits: %s", exc)
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # HTTP server
360
+ # ---------------------------------------------------------------------------
361
+ def build_app(state: WorkerState) -> Flask:
362
+ app = Flask("hugpy_worker")
363
+
364
+ @app.route("/health", methods=["GET"])
365
+ def health():
366
+ return jsonify(
367
+ {
368
+ "ok": True,
369
+ "worker_id": state.worker_id,
370
+ "name": state.name,
371
+ "gpus": detect_gpus(),
372
+ "engine": ENGINE,
373
+ "front_door": state.front_door,
374
+ "assigned_models": state.assigned_models,
375
+ "loaded_models": loaded_model_keys(state),
376
+ }
377
+ )
378
+
379
+ @app.route("/infer", methods=["POST"])
380
+ def infer():
381
+ payload = request.get_json(silent=True) or {}
382
+ req_id = str(payload.pop("request_id", "") or uuid.uuid4().hex)
383
+ payload.pop("spill", None)
384
+ if not payload.get("model_key"):
385
+ return jsonify({
386
+ "ok": False, "error": "model_key is required",
387
+ "worker": {"id": state.worker_id, "name": state.name},
388
+ }), 400
389
+ state.inflight.add(req_id)
390
+ try:
391
+ status, body = _fd_chat(state, payload, req_id)
392
+ finally:
393
+ state.inflight.discard(req_id)
394
+ if status != 200:
395
+ return jsonify({
396
+ "ok": False, "error": _error_text(body),
397
+ "worker": {"id": state.worker_id, "name": state.name},
398
+ }), status
399
+ text, finish, usage, timings = _reply_parts(body)
400
+ return jsonify({"ok": True, "text": text, "finish_reason": finish,
401
+ "usage": usage, "timings": timings,
402
+ "request_id": req_id})
403
+
404
+ @app.route("/infer/stream", methods=["POST"])
405
+ def infer_stream():
406
+ payload = request.get_json(silent=True) or {}
407
+ payload.pop("spill", None)
408
+ # Caller-supplied id for cancellation; else generate one. Echo it back
409
+ # as the first SSE event so the client can cancel this exact request.
410
+ req_id = str(payload.pop("request_id", "") or uuid.uuid4().hex)
411
+ if not payload.get("model_key"):
412
+ return jsonify({
413
+ "ok": False, "error": "model_key is required",
414
+ "worker": {"id": state.worker_id, "name": state.name},
415
+ }), 400
416
+
417
+ def _generate():
418
+ state.inflight.add(req_id)
419
+ try:
420
+ yield _sse({"type": "request", "request_id": req_id})
421
+ # The front door answers whole (non-streaming): the reply rides
422
+ # as one token event, then done with the engine's usage/timings.
423
+ status, body = _fd_chat(state, payload, req_id)
424
+ if status != 200:
425
+ yield _sse({"type": "error", "message": _error_text(body),
426
+ "code": str(status)})
427
+ return
428
+ text, finish, usage, timings = _reply_parts(body)
429
+ if text:
430
+ yield _sse({"type": "token", "text": text})
431
+ done = {"type": "done", "finish_reason": finish}
432
+ if usage:
433
+ done["usage"] = usage
434
+ if timings:
435
+ done["timings"] = timings
436
+ yield _sse(done)
437
+ finally:
438
+ state.inflight.discard(req_id)
439
+
440
+ return Response(
441
+ stream_with_context(_generate()),
442
+ mimetype="text/event-stream",
443
+ headers={
444
+ "Cache-Control": "no-cache",
445
+ "X-Accel-Buffering": "no",
446
+ "Connection": "keep-alive",
447
+ },
448
+ direct_passthrough=True,
449
+ )
450
+
451
+ @app.route("/infer/cancel/<request_id>", methods=["POST"])
452
+ def infer_cancel(request_id):
453
+ # Wire contract: 404 for unknown/finished requests. An in-flight request
454
+ # cannot be stopped yet — the front door has no cancel — so say so.
455
+ if request_id not in state.inflight:
456
+ return jsonify({"cancelled": False, "reason": "unknown or finished request"}), 404
457
+ return jsonify({"cancelled": False, "request_id": request_id,
458
+ "reason": "fitevict front door has no cancel yet"}), 409
459
+
460
+ return app
461
+
462
+
463
+ # ---------------------------------------------------------------------------
464
+ # Registration + heartbeat
465
+ # ---------------------------------------------------------------------------
466
+ def _beat_fields(state: WorkerState) -> dict:
467
+ return {
468
+ "gpus": detect_gpus(),
469
+ "url": state.url, # None -> central uses the source IP
470
+ "port": state.port,
471
+ "pkg_version": __version__,
472
+ "free_ram": free_ram_bytes(),
473
+ "ram_total": _ram_total_bytes(),
474
+ "engine": ENGINE,
475
+ "pool": os.environ.get("WORKER_POOL", ""),
476
+ }
477
+
478
+
479
+ def _fd_storage(state: WorkerState) -> dict | None:
480
+ """GET /storage from the front door: the model drive survey."""
481
+ try:
482
+ with urllib.request.urlopen(state.front_door + "/storage", timeout=20) as r:
483
+ return json.loads(r.read().decode("utf-8")) or None
484
+ except Exception: # noqa: BLE001
485
+ return None
486
+
487
+
488
+ def _model_fields(state: WorkerState) -> dict:
489
+ """What this box holds: discovered models (with hugpy.json + weights),
490
+ which are loaded, and the running children as allocation rows."""
491
+ models = _fd_models(state)
492
+ if models is None:
493
+ # Inventory unreadable this beat (front door down/restarting): report
494
+ # NO inventory rather than an empty one — an empty object is
495
+ # authoritative ("the scan found nothing") and would erase this box's
496
+ # models upstream (instructions/hugpy/model_discovery, rule 2).
497
+ return {"allocations": _allocations(_fd_residents(state)),
498
+ "storage": _fd_storage(state)}
499
+ return {
500
+ "loaded_models": sorted(m["id"] for m in models if m.get("resident")),
501
+ "models_local": sorted(m["id"] for m in models),
502
+ "models_discovered": _models_discovered(models),
503
+ "allocations": _allocations(_fd_residents(state)),
504
+ "storage": _fd_storage(state),
505
+ }
506
+
507
+
508
+ def _register(client: CentralClient, state: WorkerState, args) -> None:
509
+ models = [m.strip() for m in (args.models or "").split(",") if m.strip()]
510
+ payload = {
511
+ "name": state.name,
512
+ "models": models or None,
513
+ "worker_id": state.worker_id,
514
+ **_beat_fields(state),
515
+ # Sent on register too, so a router restart (410 -> re-register) never
516
+ # leaves the box listed with no models until the next beat.
517
+ **_model_fields(state),
518
+ }
519
+ try:
520
+ worker = client.register(payload)
521
+ except WorkerRejected as exc:
522
+ _terminal_exit(exc) # does not return — blocked/revoked, don't retry
523
+ state.worker_id = worker.get("id", state.worker_id)
524
+ if state.worker_id:
525
+ _save_worker_id(args.id_file, state.worker_id)
526
+ _sync_assignment(state, worker)
527
+ logger.info("registered as worker id=%s serving models=%s", state.worker_id, worker.get("models"))
528
+
529
+
530
+ def _heartbeat_loop(client: CentralClient, state: WorkerState, args) -> None:
531
+ while True:
532
+ time.sleep(args.heartbeat)
533
+ try:
534
+ worker = client.heartbeat(
535
+ state.worker_id,
536
+ {**_beat_fields(state), **_model_fields(state)},
537
+ )
538
+ # Adopt any assignment change made in the UI.
539
+ _sync_assignment(state, worker)
540
+ except WorkerRejected as exc:
541
+ _terminal_exit(exc) # does not return
542
+ except urllib.error.HTTPError as exc:
543
+ if exc.code == 410:
544
+ # Central forgot us (restart / cleared registry) — re-register.
545
+ logger.warning("central returned 410; re-registering")
546
+ _register(client, state, args)
547
+ else:
548
+ logger.warning("heartbeat HTTP %s", exc.code)
549
+ except Exception as exc:
550
+ logger.warning("heartbeat failed: %s", exc)
551
+
552
+
553
+ def _build_parser() -> argparse.ArgumentParser:
554
+ p = argparse.ArgumentParser(prog="hugpy_worker")
555
+ p.add_argument("--central", default=os.environ.get("HUGPY_BASE_URL")
556
+ or os.environ.get("WORKER_CENTRAL_URL"),
557
+ help="Central base URL. Omitted -> serve locally, never register.")
558
+ p.add_argument("--token", default=os.environ.get("WORKER_ENROLL_TOKEN"),
559
+ help="Enrollment token issued by the console (hpw_...). Sent as a "
560
+ "Bearer credential on register/heartbeat.")
561
+ p.add_argument("--name", default=os.environ.get("WORKER_NAME", socket.gethostname()))
562
+ p.add_argument("--host", default=os.environ.get("WORKER_HOST", "0.0.0.0"),
563
+ help="Bind address for the worker's inference server")
564
+ p.add_argument("--port", type=int, default=int(os.environ.get("WORKER_PORT", "9100")))
565
+ p.add_argument("--advertise", default=os.environ.get("WORKER_URL"),
566
+ help="URL the central node should call back on "
567
+ "(defaults to http://<host>:<port>)")
568
+ p.add_argument("--models", default=os.environ.get("WORKER_MODELS", ""),
569
+ help="Comma-separated model_keys to self-assign on registration")
570
+ p.add_argument("--heartbeat", type=float, default=float(os.environ.get("WORKER_HEARTBEAT", "15")))
571
+ p.add_argument("--id-file", default=os.environ.get(
572
+ "HUGPY_WORKER_ID_FILE", os.path.expanduser("~/.hugpy_worker/worker_id.json")))
573
+ p.add_argument("--front-door", default=os.environ.get(
574
+ "HUGPY_WORKER_FRONT_DOOR", "http://127.0.0.1:8080"),
575
+ help="fitevict front door base URL")
576
+ return p
577
+
578
+
579
+ def main(argv: list[str] | None = None) -> int:
580
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
581
+ args = _build_parser().parse_args(argv)
582
+ state = WorkerState(name=args.name, url=args.advertise,
583
+ worker_id=_load_worker_id(args.id_file),
584
+ central_url=args.central, port=args.port,
585
+ front_door=args.front_door)
586
+ if args.central:
587
+ client = CentralClient(args.central, token=args.token)
588
+ _register(client, state, args)
589
+ threading.Thread(target=_heartbeat_loop, args=(client, state, args),
590
+ name="hugpy-worker-heartbeat", daemon=True).start()
591
+ else:
592
+ logger.info("no --central: serving locally only, not registered")
593
+ logger.info("hugpy_worker %s on %s:%s -> front door %s",
594
+ state.name, args.host, args.port, state.front_door)
595
+ build_app(state).run(host=args.host, port=args.port, threaded=True)
596
+ return 0
597
+
598
+
599
+ if __name__ == "__main__":
600
+ sys.exit(main())
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: hugpy-worker
3
+ Version: 0.1.0
4
+ Summary: Per-box hugpy worker over the fitevict front door: register, heartbeat, /infer, /infer/stream, cancel, health — lifted from hugpy_fleet.worker; all loading and serving delegated to fitevict.
5
+ Author-email: putkoff <support@hugpy.ai>
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://hugpy.ai
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: hugpy-wrapper
17
+ Requires-Dist: flask>=2
18
+
19
+ # hugpy-worker
20
+
21
+ pip install hugpy-worker # pulls hugpy-wrapper (the front door)
22
+
23
+ Per-box hugpy worker over the fitevict front door. Central sees an ordinary hugpy
24
+ worker (register, heartbeat, `/infer`, `/infer/stream`, `/infer/cancel`, `/health`);
25
+ every inference is forwarded to fitevict's `/v1/chat/completions`, which owns
26
+ evict-to-fit, load, serve and call_log. The contract is lifted from
27
+ `hugpy_fleet.worker.agent`; nothing below it is.
28
+
29
+ hugpy-worker --central http://127.0.0.1:7020 --name ae-worker --port 9200 \
30
+ --advertise http://192.168.1.100:9200 --front-door http://127.0.0.1:8080
31
+
32
+ Without `--central` it serves locally and never registers.
@@ -0,0 +1,8 @@
1
+ hugpy_worker/__init__.py,sha256=VMQXUKOO2uu63VdnE_QCBYhDXUpJGRsj66ksjhgjHIU,319
2
+ hugpy_worker/central_client.py,sha256=Hkw07v2h5dgim7Gz0GDUXC2BmnT1FPEp4FdnLEiebWM,6027
3
+ hugpy_worker/worker.py,sha256=mPRSRpadclp4C1yYS5Nj5ZA1IifyYy3N6UgKXBUMM1U,26682
4
+ hugpy_worker-0.1.0.dist-info/METADATA,sha256=SAWVzbEUxRuewGNTCeCAFYf1tbNKf8-99sa1O8l-uJU,1458
5
+ hugpy_worker-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ hugpy_worker-0.1.0.dist-info/entry_points.txt,sha256=mwSMQpIIxbHQvpbT0UBqQOlMa-g6wxOdrr6iYNoTWFc,58
7
+ hugpy_worker-0.1.0.dist-info/top_level.txt,sha256=prqGr888Rup_0WQiNXltc-D5wIn31-3k-gPORCsA5ug,13
8
+ hugpy_worker-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hugpy-worker = hugpy_worker.worker:main
@@ -0,0 +1 @@
1
+ hugpy_worker