AQIT 0.1.0
Loading...
Searching...
No Matches
model_daemon.py
Go to the documentation of this file.
1# Copyright (c) 2025-present Aquin Labs Private Limited. All Rights Reserved.
2# This file is part of the Aquin Engine. Unauthorized copying, modification,
3# distribution, or use of this file, via any medium, is strictly prohibited.
4# Proprietary and confidential. See LICENSE for terms.
5
6"""
7Client + lifecycle for the persistent model daemon (aquin.engine.daemon_main).
8
9The daemon keeps one model resident in VRAM across separate `aquin` invocations.
10This module is the thin client the CLI uses to start it, check it, switch models,
11warm SAEs, dispatch tools, and stop it. Everything here is best-effort: if the
12daemon is unavailable, callers fall back to loading in-process.
13"""
14from __future__ import annotations
15
16import json
17import os
18import subprocess
19import sys
20import time
21import urllib.error
22import urllib.request
23from pathlib import Path
24from typing import Any
25
26from aquin.engine.engine_info import (
27 DEFAULT_ENGINE_HOST,
28 resolve_engine_port,
29 set_daemon_state,
30)
31
32_HOST = DEFAULT_ENGINE_HOST
33_LOG_PATH = Path.home() / ".aquin" / "daemon.log"
34
35# Generous ceiling for a large model load / long tool run.
36DEFAULT_TIMEOUT = 1800
37FAST_TIMEOUT = 0.2
38
39
40def _port() -> int:
41 return resolve_engine_port()
42
43
44def _base() -> str:
45 return f"http://{_HOST}:{_port()}"
46
47
48def _url(path: str) -> str:
49 return f"{_base()}{path}"
50
51
52def _get(path: str, timeout: float = 1.5) -> tuple[int | None, Any]:
53 try:
54 with urllib.request.urlopen(_url(path), timeout=timeout) as resp:
55 raw = resp.read().decode() or "{}"
56 return resp.status, json.loads(raw)
57 except Exception:
58 return None, None
59
60
61def _post(path: str, body: dict | None, timeout: float = DEFAULT_TIMEOUT) -> tuple[int | None, Any]:
62 data = json.dumps(body or {}).encode()
63 req = urllib.request.Request(
64 _url(path), data=data, headers={"Content-Type": "application/json"}, method="POST",
65 )
66 try:
67 with urllib.request.urlopen(req, timeout=timeout) as resp:
68 raw = resp.read().decode() or "{}"
69 return resp.status, json.loads(raw)
70 except urllib.error.HTTPError as exc:
71 try:
72 return exc.code, json.loads(exc.read().decode() or "{}")
73 except Exception:
74 return exc.code, None
75 except Exception:
76 return None, None
77
78
79def health(timeout: float = 1.5) -> dict | None:
80 """Return the daemon health payload, or None if no daemon is answering."""
81 status, data = _get("/health", timeout=timeout)
82 if status == 200 and isinstance(data, dict) and data.get("daemon"):
83 return data
84 return None
85
86
87def health_fast() -> dict | None:
88 """Quick probe for status screens — fails fast when daemon is down."""
89 return health(timeout=FAST_TIMEOUT)
90
92def is_running() -> bool:
93 return health() is not None
94
95
96def loaded_model() -> str | None:
97 h = health()
98 return h.get("model_id") if h else None
99
101def _port_occupied() -> bool:
102 """Something is listening on the port (may be a non-daemon chat server)."""
103 status, _ = _get("/health", timeout=0.6)
104 return status is not None
106
107def _write_state(pid: int) -> None:
108 try:
109 set_daemon_state(
110 status="running",
111 pid=pid,
112 port=_port(),
113 started_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
114 model_id=None,
115 )
116 except Exception:
117 pass
118
119
120def _clear_state() -> None:
121 try:
122 set_daemon_state(status="stopped", pid=None, started_at=None, model_id=None)
123 except Exception:
124 pass
125
126
127def _spawn() -> bool:
128 """Spawn the daemon as a detached background process. Returns spawn success."""
129 try:
130 _LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
131 logf = open(_LOG_PATH, "ab")
132 except Exception:
133 logf = subprocess.DEVNULL # type: ignore[assignment]
134
135 creationflags = 0
136 popen_kwargs: dict[str, Any] = {}
137 if os.name == "nt":
138 # DETACHED_PROCESS (0x00000008) + new process group so it outlives this shell.
139 creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | 0x00000008
140 else:
141 popen_kwargs["start_new_session"] = True
142
143 port = _port()
144 try:
145 proc = subprocess.Popen(
146 [sys.executable, "-m", "aquin.engine.daemon_main", "--port", str(port)],
147 stdout=logf,
148 stderr=logf,
149 stdin=subprocess.DEVNULL,
150 creationflags=creationflags,
151 close_fds=True,
152 **popen_kwargs,
153 )
154 except Exception:
155 return False
156 _write_state(proc.pid)
157 return True
158
159
160def ensure_running(wait_seconds: float = 40.0) -> bool:
161 """
162 Make sure the daemon HTTP server is up. Returns True if it is (or becomes)
163 reachable. Does NOT load a model; use switch_model for that.
164 """
165 if is_running():
166 return True
167 # Another server (e.g. `aquin chat`) may hold the port; do not fight it.
168 if _port_occupied():
169 return False
170 if not _spawn():
171 return False
172 deadline = time.time() + wait_seconds
173 while time.time() < deadline:
174 if is_running():
175 return True
176 time.sleep(0.5)
177 return False
178
179
180def switch_model(
181 model_id: str,
182 timeout: float = DEFAULT_TIMEOUT,
183 *,
184 on_tick: Any = None,
185) -> dict | None:
186 """Load model_id in the daemon, evicting any previously resident model."""
187 status, data = _post("/model/switch", {"model_id": model_id}, timeout=10.0)
188 if status != 200 or not isinstance(data, dict):
189 return {
190 "ok": False,
191 "error": "background engine did not respond to model switch request",
192 }
193 if not data.get("ok"):
194 return data
195 if data.get("status") == "ready":
196 h = health(timeout=2.0) or {}
197 return {"ok": True, "model_id": data.get("model_id") or model_id, "vram": h.get("vram")}
198
199 from aquin.load_model_display import watch_daemon_load
200
201 target_model = data.get("model_id") or model_id
202 return watch_daemon_load(
203 target_model=target_model,
204 poll=lambda: health(timeout=2.0),
205 timeout=timeout,
206 on_tick=on_tick,
207 )
208
209
210def warm_sae(model_id: str, layer: int, timeout: float = 600) -> dict | None:
211 status, data = _post("/sae/load", {"model_id": model_id, "layer": layer}, timeout=timeout)
212 if status == 200 and isinstance(data, dict):
213 return data
214 return None
215
216
217def dispatch(name: str, args: dict, ctx: dict, timeout: float = DEFAULT_TIMEOUT) -> dict | None:
218 """Run a tool in the daemon. Returns {"ok": bool, "result"|"error": ...} or None."""
219 status, data = _post("/dispatch", {"name": name, "args": args, "ctx": ctx}, timeout=timeout)
220 if status == 200 and isinstance(data, dict):
221 return data
222 return None
223
224
225def unload() -> bool:
226 """Free the daemon's VRAM (keeps the daemon process alive)."""
227 status, _ = _post("/unload", {}, timeout=120)
228 return status == 200
230
231def prompt(
232 text: str,
233 *,
234 model_id: str | None = None,
235 max_new_tokens: int = 200,
236 temperature: float = 0.7,
237 timeout: float = DEFAULT_TIMEOUT,
238) -> dict | None:
239 """Generate a completion from the daemon's resident model."""
240 body: dict[str, Any] = {
241 "prompt": text,
242 "max_new_tokens": max_new_tokens,
243 "temperature": temperature,
244 }
245 if model_id:
246 body["model_id"] = model_id
247 status, data = _post("/prompt", body, timeout=timeout)
248 if status == 200 and isinstance(data, dict):
249 return data
250 return None
251
252
253def stop() -> bool:
254 """Ask the daemon to shut down and clear local state."""
255 status, _ = _post("/shutdown", {}, timeout=10)
257 return status == 200
tuple[int|None, Any] _get(str path, float timeout=1.5)
bool ensure_running(float wait_seconds=40.0)
dict|None health(float timeout=1.5)
dict|None prompt(str text, *, str|None model_id=None, int max_new_tokens=200, float temperature=0.7, float timeout=DEFAULT_TIMEOUT)
dict|None switch_model(str model_id, float timeout=DEFAULT_TIMEOUT, *, Any on_tick=None)
dict|None warm_sae(str model_id, int layer, float timeout=600)
dict|None dispatch(str name, dict args, dict ctx, float timeout=DEFAULT_TIMEOUT)
tuple[int|None, Any] _post(str path, dict|None body, float timeout=DEFAULT_TIMEOUT)