hugpy-engine 0.2.0a0__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.
- hugpy_engine/__init__.py +163 -0
- hugpy_engine/alloc_modes.py +1254 -0
- hugpy_engine/allocation.py +68 -0
- hugpy_engine/apis/__init__.py +0 -0
- hugpy_engine/apis/call_api.py +149 -0
- hugpy_engine/apis/get_module.py +606 -0
- hugpy_engine/apis/reclassify.py +123 -0
- hugpy_engine/apis/reconcile.py +628 -0
- hugpy_engine/apis/serve/__init__.py +22 -0
- hugpy_engine/apis/serve/serve.py +34 -0
- hugpy_engine/apis/serve/serve_cli.py +9 -0
- hugpy_engine/apis/systemd_units.py +9 -0
- hugpy_engine/backend.py +86 -0
- hugpy_engine/backends/__init__.py +5 -0
- hugpy_engine/backends/local.py +163 -0
- hugpy_engine/catalog.py +99 -0
- hugpy_engine/catalog_bridge.py +223 -0
- hugpy_engine/categories.py +117 -0
- hugpy_engine/chat_context/__init__.py +27 -0
- hugpy_engine/chat_context/chat_context.py +199 -0
- hugpy_engine/chat_context/context_budget.py +223 -0
- hugpy_engine/chat_context/unbounded.py +55 -0
- hugpy_engine/config/__init__.py +0 -0
- hugpy_engine/config/main.py +270 -0
- hugpy_engine/config/models/__init__.py +0 -0
- hugpy_engine/config/models/model_meta.py +169 -0
- hugpy_engine/config/models/models_config.py +1495 -0
- hugpy_engine/config/models/models_default.py +196 -0
- hugpy_engine/config/models/models_dict.py +73 -0
- hugpy_engine/contracts.py +44 -0
- hugpy_engine/dispatch/__init__.py +37 -0
- hugpy_engine/dispatch/acquire.py +59 -0
- hugpy_engine/dispatch/activity.py +92 -0
- hugpy_engine/dispatch/dispatch.py +1100 -0
- hugpy_engine/draft_models.py +72 -0
- hugpy_engine/engines.py +44 -0
- hugpy_engine/eviction.py +654 -0
- hugpy_engine/generate/__init__.py +16 -0
- hugpy_engine/generate/coder.py +667 -0
- hugpy_engine/generate/config.py +185 -0
- hugpy_engine/generate/generate_runner.py +273 -0
- hugpy_engine/gguf_election.py +259 -0
- hugpy_engine/llama/__init__.py +23 -0
- hugpy_engine/llama/runners/__init__.py +23 -0
- hugpy_engine/llama/runners/chat_runner.py +243 -0
- hugpy_engine/llama/runners/get.py +524 -0
- hugpy_engine/llama/runners/src/__init__.py +9 -0
- hugpy_engine/llama/runners/src/base_runner.py +529 -0
- hugpy_engine/llama/runners/src/ccp_runner.py +332 -0
- hugpy_engine/llama/runners/src/imports/__init__.py +25 -0
- hugpy_engine/llama/runners/src/imports/config.py +16 -0
- hugpy_engine/llama/runners/src/imports/constants.py +38 -0
- hugpy_engine/llama/runners/src/imports/init_imports.py +6 -0
- hugpy_engine/llama/runners/src/imports/utils.py +64 -0
- hugpy_engine/llama/runners/src/python_runner.py +432 -0
- hugpy_engine/llama/runners/src/shard_server.py +339 -0
- hugpy_engine/llama/runners/src/shard_server.py.bak-shardorder +329 -0
- hugpy_engine/llama/serve.py +9 -0
- hugpy_engine/manifest.py +53 -0
- hugpy_engine/model_classifier.py +374 -0
- hugpy_engine/model_index/__init__.py +74 -0
- hugpy_engine/model_index/client.py +94 -0
- hugpy_engine/model_index/query_registry.py +245 -0
- hugpy_engine/model_index/repositories.py +163 -0
- hugpy_engine/model_index/service.py +243 -0
- hugpy_engine/name_match.py +287 -0
- hugpy_engine/native/__init__.py +19 -0
- hugpy_engine/native/build.py +98 -0
- hugpy_engine/native/fetch.py +194 -0
- hugpy_engine/native/resolve.py +352 -0
- hugpy_engine/peft_adapters.py +293 -0
- hugpy_engine/placement.py +459 -0
- hugpy_engine/py.typed +0 -0
- hugpy_engine/query.py +173 -0
- hugpy_engine/resolvers/__init__.py +24 -0
- hugpy_engine/resolvers/allocator.py +126 -0
- hugpy_engine/resolvers/assure_model_key.py +331 -0
- hugpy_engine/resolvers/categories/__init__.py +22 -0
- hugpy_engine/resolvers/categories/builders.py +159 -0
- hugpy_engine/resolvers/categories/frameworks.py +67 -0
- hugpy_engine/resolvers/groups.py +651 -0
- hugpy_engine/resolvers/model_dict_resolver.py +122 -0
- hugpy_engine/resolvers/model_resolver.py +533 -0
- hugpy_engine/resolvers/remote.py +2992 -0
- hugpy_engine/runtime.py +68 -0
- hugpy_engine/schemas/__init__.py +0 -0
- hugpy_engine/schemas/chat_schemas.py +320 -0
- hugpy_engine/schemas/event_schemas.py +52 -0
- hugpy_engine/schemas/metadata_schemas.py +62 -0
- hugpy_engine/schemas/model_schemas.py +101 -0
- hugpy_engine/schemas/runner_schemas.py +33 -0
- hugpy_engine/schemas/task_schemas.py +45 -0
- hugpy_engine/serve/__init__.py +58 -0
- hugpy_engine/serve/chunksum_verify.py +213 -0
- hugpy_engine/serve/hot_cache.py +1014 -0
- hugpy_engine/serve/model_cache.py +218 -0
- hugpy_engine/serve/overrides.py +968 -0
- hugpy_engine/serve/policy.py +45 -0
- hugpy_engine/serve/profiles.py +362 -0
- hugpy_engine/serve/serve.py +889 -0
- hugpy_engine/serve/serve.py.bak-present-20260918-035851 +863 -0
- hugpy_engine/serve/serve.py.bak-servelog-20260918-035149 +863 -0
- hugpy_engine/serve/serve_cli.py +85 -0
- hugpy_engine/serve/slot_agent.py +2119 -0
- hugpy_engine/serve/slot_agent.py.bak-quietlog-20260918-023710 +2046 -0
- hugpy_engine/serve/slot_agent.py.bak-slotparallel-20260918-112917 +2064 -0
- hugpy_engine/serve/slot_agent.py.bak-wzfilter-20260918-041625 +2047 -0
- hugpy_engine/serve/slots.py +630 -0
- hugpy_engine/serve/supervisor.py +148 -0
- hugpy_engine/spill.py +1464 -0
- hugpy_engine/task_deps.py +67 -0
- hugpy_engine/tasks.py +349 -0
- hugpy_engine/utils/__init__.py +0 -0
- hugpy_engine/utils/json_scavenge.py +111 -0
- hugpy_engine/utils/no_think.py +370 -0
- hugpy_engine/wire/__init__.py +0 -0
- hugpy_engine/wire/chat_schemas.py +66 -0
- hugpy_engine/wire/config_schemas.py +31 -0
- hugpy_engine/wire/install_schemas.py +17 -0
- hugpy_engine/wire/model_schemas.py +43 -0
- hugpy_engine/wire/request_schemas.py +13 -0
- hugpy_engine/wire/specs_schemas.py +9 -0
- hugpy_engine-0.2.0a0.dist-info/METADATA +116 -0
- hugpy_engine-0.2.0a0.dist-info/RECORD +128 -0
- hugpy_engine-0.2.0a0.dist-info/WHEEL +5 -0
- hugpy_engine-0.2.0a0.dist-info/entry_points.txt +2 -0
- hugpy_engine-0.2.0a0.dist-info/licenses/LICENSE +41 -0
- hugpy_engine-0.2.0a0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
# imports/apis/serve.py (or managers/serve/serve.py — see note at bottom)
|
|
2
|
+
"""
|
|
3
|
+
Serving abstraction. One model, one resolved ServeSpec; how it actually gets
|
|
4
|
+
served is a pluggable driver keyed by an explicit mode — not inferred from
|
|
5
|
+
whether cfg.port happens to be set.
|
|
6
|
+
|
|
7
|
+
mode = off -> not served externally; in-process runner handles it
|
|
8
|
+
mode = systemd -> a dedicated llama-server under its own unit (always-on)
|
|
9
|
+
mode = swap -> an entry in one shared llama-swap proxy (on-demand)
|
|
10
|
+
|
|
11
|
+
Why this shape:
|
|
12
|
+
- registry, not port-sniffing: serve_spec_for() reads MODEL_REGISTRY + env,
|
|
13
|
+
the same way resolve_model_source() does. cfg.port stops meaning four
|
|
14
|
+
things at once.
|
|
15
|
+
- driver registry, like vision_backends.register_backend and
|
|
16
|
+
FRAMEWORK_RUNNERS: adding a serving mechanism is one @register_serve_driver.
|
|
17
|
+
- plans, not callbacks: a driver returns a ServePlan (writes + commands).
|
|
18
|
+
Dry-run renders it for the UI; apply() drains it. The run()/write()
|
|
19
|
+
callables are the seam for local-vs-remote (ssh to a peer) execution.
|
|
20
|
+
|
|
21
|
+
Caters to either deployment with one env var:
|
|
22
|
+
DEFAULT_SERVE_MODE=systemd # every llama_cpp model gets its own unit
|
|
23
|
+
DEFAULT_SERVE_MODE=swap # everything routes through one llama-swap proxy
|
|
24
|
+
Per-model override lives in the overlay: cfg.extra["serve_mode"] = "swap".
|
|
25
|
+
|
|
26
|
+
os.path throughout, no pathlib.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import getpass
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
import re
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from enum import Enum
|
|
35
|
+
from typing import Optional, Tuple
|
|
36
|
+
|
|
37
|
+
from abstract_essentials import get_env_value, get_logFile, make_list
|
|
38
|
+
from hugpy_engine.config.main import get_model_config, get_model_path, resolve_model_source
|
|
39
|
+
from hugpy_engine.config.models.models_config import get_model_registry
|
|
40
|
+
from hugpy_platform.constants import LLAMA_HOST
|
|
41
|
+
|
|
42
|
+
logger = get_logFile(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --------------------------------------------------------------------------- #
|
|
46
|
+
# explicit environment wiring #
|
|
47
|
+
# --------------------------------------------------------------------------- #
|
|
48
|
+
|
|
49
|
+
# Native engine location is resolved per-OS by hugpy.engine.resolve (env override
|
|
50
|
+
# -> a `hugpy install-engine` download/build -> PATH). The placeholder keeps an
|
|
51
|
+
# absolute, well-formed default when the engine isn't installed yet; the serve
|
|
52
|
+
# drivers report "run hugpy install-engine" rather than serving a missing binary.
|
|
53
|
+
from hugpy_platform.binaries import with_exe as _with_exe
|
|
54
|
+
from hugpy_platform.app_dirs import engine_dir as _engine_dir
|
|
55
|
+
from hugpy_engine.native import resolve as _engine_resolve
|
|
56
|
+
|
|
57
|
+
LLAMA_CPP_DIR = get_env_value("LLAMA_CPP_DIR") or _engine_dir()
|
|
58
|
+
LLAMA_SERVER_BIN = (get_env_value("LLAMA_SERVER_BIN")
|
|
59
|
+
or _engine_resolve.server_bin()
|
|
60
|
+
or os.path.join(LLAMA_CPP_DIR, "build", "bin", _with_exe("llama-server")))
|
|
61
|
+
LLAMA_SERVICE_USER = get_env_value("LLAMA_SERVICE_USER") or getpass.getuser()
|
|
62
|
+
LLAMA_SERVICE_GROUP = get_env_value("LLAMA_SERVICE_GROUP") or getpass.getuser()
|
|
63
|
+
SYSTEMD_UNIT_DIR = get_env_value("SYSTEMD_UNIT_DIR") or "/etc/systemd/system"
|
|
64
|
+
LLAMA_UNIT_PREFIX = get_env_value("LLAMA_UNIT_PREFIX") or "llama"
|
|
65
|
+
|
|
66
|
+
# llama-swap: one proxy, one config, one unit to bounce on config change.
|
|
67
|
+
LLAMA_SWAP_HOST = get_env_value("LLAMA_SWAP_HOST") or "127.0.0.1"
|
|
68
|
+
LLAMA_SWAP_PORT = int(get_env_value("LLAMA_SWAP_PORT") or 9292)
|
|
69
|
+
LLAMA_SWAP_CONFIG = get_env_value("LLAMA_SWAP_CONFIG") or "/etc/llama-swap/config.yaml"
|
|
70
|
+
LLAMA_SWAP_UNIT = get_env_value("LLAMA_SWAP_UNIT") or "llama-swap.service"
|
|
71
|
+
LLAMA_SWAP_TTL = int(get_env_value("LLAMA_SWAP_TTL") or 600) # on-demand unload, seconds
|
|
72
|
+
|
|
73
|
+
# Fallback context window for a served llama-server when neither the model
|
|
74
|
+
# config nor DEFAULT_LLAMA_CTX (env) specifies one. Now matches the in-process
|
|
75
|
+
# runner default DEFAULT_N_CTX (managers/llama/runners/src/imports/constants.py);
|
|
76
|
+
# the previous 4096 silently truncated long outputs — the transcript overflowed,
|
|
77
|
+
# the model lost its framing and looped with no stop. The env override wins.
|
|
78
|
+
DEFAULT_LLAMA_CTX = int(get_env_value("DEFAULT_LLAMA_CTX") or 16384)
|
|
79
|
+
DEFAULT_LLAMA_THREADS = int(get_env_value("DEFAULT_LLAMA_THREADS") or 6)
|
|
80
|
+
|
|
81
|
+
# CONTEXT-as-allocation resolver (slice 11 / t27). The worker agent (which owns
|
|
82
|
+
# _RUNTIME_SETTINGS / ctx_pct) registers fn(model_key, cfg) -> resolved_ctx|None
|
|
83
|
+
# here at boot — same reverse-injection pattern as slots.set_residency_lookup, so
|
|
84
|
+
# managers/serve never imports worker_agent. When set AND it returns a ctx, that
|
|
85
|
+
# RESOLVED value is what gets served (-c) — the allocation is a contract the load
|
|
86
|
+
# HONORS, not a hint. None (unset resolver, or no ctx_pct for the model) ->
|
|
87
|
+
# today's default ctx path, byte-identical.
|
|
88
|
+
_CTX_RESOLVER = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def set_ctx_resolver(fn) -> None:
|
|
92
|
+
global _CTX_RESOLVER
|
|
93
|
+
_CTX_RESOLVER = fn
|
|
94
|
+
# GPU offload for llama-server. -1 = put every layer on the GPU (the right
|
|
95
|
+
# default for a CUDA-built llama-server); 0 = CPU only; N = first N layers.
|
|
96
|
+
# Without this flag llama-server defaults to CPU — the usual "GPU sits idle".
|
|
97
|
+
DEFAULT_LLAMA_NGL = int(get_env_value("DEFAULT_LLAMA_NGL") or -1)
|
|
98
|
+
# Default serving mechanism for a model with no explicit serve_mode.
|
|
99
|
+
#
|
|
100
|
+
# HISTORY: this defaulted to `systemd` (one always-on unit per model) from an era
|
|
101
|
+
# when a "served model" *was* a local always-on llama-server. That predates the
|
|
102
|
+
# worker fleet + local slot pool: today routing is worker-first (resolve() →
|
|
103
|
+
# DelegatingRunner), then the on-demand slot pool, with a local server only as a
|
|
104
|
+
# fallback. An always-on-systemd default therefore (a) pins CPU/RAM on a possibly
|
|
105
|
+
# GPU-less central for models that workers already serve, and (b) makes the
|
|
106
|
+
# Serving overview report "systemd / always-on" for models that were never
|
|
107
|
+
# installed and aren't running. So the modern default is `swap`: on-demand,
|
|
108
|
+
# slot-first (serve_endpoint still prefers a slot), no pinned unit. An operator
|
|
109
|
+
# who genuinely wants a pinned local unit sets serve_mode=systemd per model, and
|
|
110
|
+
# DEFAULT_SERVE_MODE=<mode> (env) still overrides globally.
|
|
111
|
+
from hugpy_platform.platform_facade import IS_LINUX as _IS_LINUX
|
|
112
|
+
def _clean_mode_value(value) -> str:
|
|
113
|
+
"""Sanitize a serve-mode string from env/config: strip an inline
|
|
114
|
+
``# comment`` and surrounding whitespace, lowercase. An .env line like
|
|
115
|
+
``DEFAULT_SERVE_MODE=systemd # one unit per model`` otherwise reaches
|
|
116
|
+
ServeMode() verbatim and every serve-spec build raises ValueError
|
|
117
|
+
(observed breaking GET /llm/serving fleet-wide on the op worker)."""
|
|
118
|
+
return str(value or "").split("#", 1)[0].strip().lower()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _default_serve_mode() -> str:
|
|
122
|
+
explicit = _clean_mode_value(get_env_value("DEFAULT_SERVE_MODE"))
|
|
123
|
+
if explicit:
|
|
124
|
+
return explicit
|
|
125
|
+
return "swap"
|
|
126
|
+
DEFAULT_SERVE_MODE = _default_serve_mode()
|
|
127
|
+
|
|
128
|
+
# Deterministic auto-port range for systemd units when a model has no explicit
|
|
129
|
+
# port. Wide span keeps hash collisions rare; an explicit cfg.port always wins.
|
|
130
|
+
LLAMA_PORT_BASE = int(get_env_value("LLAMA_PORT_BASE") or 7001)
|
|
131
|
+
LLAMA_PORT_SPAN = int(get_env_value("LLAMA_PORT_SPAN") or 4000)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ServeMode(str, Enum):
|
|
135
|
+
OFF = "off"
|
|
136
|
+
SYSTEMD = "systemd" # Linux only — one systemd unit per model
|
|
137
|
+
SUPERVISED = "supervised" # portable — detached background process per model
|
|
138
|
+
SWAP = "swap"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --------------------------------------------------------------------------- #
|
|
142
|
+
# small resolvers #
|
|
143
|
+
# --------------------------------------------------------------------------- #
|
|
144
|
+
|
|
145
|
+
def _unit_slug(value):
|
|
146
|
+
value = (value or "").strip().lower()
|
|
147
|
+
value = re.sub(r"[^a-z0-9._-]+", "-", value)
|
|
148
|
+
value = re.sub(r"-+", "-", value).strip("-._")
|
|
149
|
+
return value or "model"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _bare_host(value):
|
|
153
|
+
value = value or LLAMA_HOST
|
|
154
|
+
if "://" in value:
|
|
155
|
+
value = value.split("://", 1)[1]
|
|
156
|
+
return value.split("/", 1)[0] or "127.0.0.1"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _effective_extra(model_key, cfg) -> dict:
|
|
160
|
+
"""cfg.extra with the persisted per-model UI override merged on top."""
|
|
161
|
+
extra = dict(getattr(cfg, "extra", {}) or {})
|
|
162
|
+
try:
|
|
163
|
+
from hugpy_engine.serve.overrides import get_override
|
|
164
|
+
extra.update(get_override(model_key))
|
|
165
|
+
except Exception: # overrides are optional; never break spec resolution
|
|
166
|
+
pass
|
|
167
|
+
return extra
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _ctx_for(cfg, model_key, extra=None):
|
|
171
|
+
extra = extra if extra is not None else _effective_extra(model_key, cfg)
|
|
172
|
+
if extra.get("llama_ctx"):
|
|
173
|
+
return int(extra["llama_ctx"])
|
|
174
|
+
# CONTEXT allocation (slice 11): if the worker set a ctx_pct for this model,
|
|
175
|
+
# SERVE the resolved value — the allocation is a contract, so the served -c
|
|
176
|
+
# equals what fit/admission reserved KV for. The resolver already clamps to
|
|
177
|
+
# DEFAULT_LLAMA_CTX (the 'capping' logic is now enforcement of the RESOLVED
|
|
178
|
+
# value). Unset resolver / no ctx_pct -> falls through to today's default.
|
|
179
|
+
if _CTX_RESOLVER is not None:
|
|
180
|
+
try:
|
|
181
|
+
resolved = _CTX_RESOLVER(model_key, cfg)
|
|
182
|
+
if resolved:
|
|
183
|
+
logger.debug("%s: serving -c %s (ctx allocation — ctx_pct)",
|
|
184
|
+
model_key, int(resolved))
|
|
185
|
+
return int(resolved)
|
|
186
|
+
except Exception: # noqa: BLE001 — a broken resolver never breaks serving
|
|
187
|
+
pass
|
|
188
|
+
mml = getattr(cfg, "model_max_length", None) or DEFAULT_LLAMA_CTX
|
|
189
|
+
capped = min(int(mml), DEFAULT_LLAMA_CTX)
|
|
190
|
+
if capped < int(mml):
|
|
191
|
+
logger.debug("%s: capping -c %s -> %s (set extra['llama_ctx'] to override)",
|
|
192
|
+
model_key, int(mml), capped)
|
|
193
|
+
return capped
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _model_file_for(model_key, cfg):
|
|
197
|
+
"""Best-effort absolute GGUF path; '' if it can't be located yet. Never
|
|
198
|
+
raises — a unit/config can be staged before the download lands."""
|
|
199
|
+
# Operator-selected .gguf variant (UI serving control) wins over the
|
|
200
|
+
# registry/auto resolution, so systemd/swap and in-process agree.
|
|
201
|
+
try:
|
|
202
|
+
from hugpy_engine.serve.overrides import resolve_override_gguf
|
|
203
|
+
picked = resolve_override_gguf(model_key, get_model_path(model_key))
|
|
204
|
+
if picked:
|
|
205
|
+
return picked
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
# Fit-aware quant auto-pick ("most amicable gguf"): only reachable when NO
|
|
209
|
+
# designation exists (autofit_gguf_prefer returns None for any pin /
|
|
210
|
+
# cfg.filename), so the precedence chain per-worker pin -> model-wide pin ->
|
|
211
|
+
# cfg.filename -> auto-pick -> election holds by construction.
|
|
212
|
+
try:
|
|
213
|
+
from hugpy_engine.serve.overrides import autofit_gguf_prefer
|
|
214
|
+
pick = autofit_gguf_prefer(model_key, get_model_path(model_key), cfg)
|
|
215
|
+
if pick:
|
|
216
|
+
from hugpy_engine.config.main import get_gguf_file
|
|
217
|
+
got = get_gguf_file(get_model_path(model_key), cfg, prefer=pick)
|
|
218
|
+
if got:
|
|
219
|
+
return got
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
try:
|
|
223
|
+
source = resolve_model_source(model_key)
|
|
224
|
+
if source and os.path.isfile(source):
|
|
225
|
+
return source
|
|
226
|
+
except (FileNotFoundError, KeyError) as exc:
|
|
227
|
+
logger.info("%s: resolve_model_source unavailable (%s)", model_key, exc)
|
|
228
|
+
if getattr(cfg, "filename", None):
|
|
229
|
+
return os.path.join(get_model_path(model_key), cfg.filename)
|
|
230
|
+
return ""
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _auto_port(model_key: str) -> int:
|
|
234
|
+
"""Deterministic per-model port so a GGUF model can get a systemd unit
|
|
235
|
+
without hand-assigning one. Stable across runs and identical whether
|
|
236
|
+
resolved for a single model (the HTTP runner) or the whole batch (install),
|
|
237
|
+
so the endpoint and the unit always agree. Explicit cfg.port still wins.
|
|
238
|
+
"""
|
|
239
|
+
import hashlib
|
|
240
|
+
h = int(hashlib.sha1(model_key.encode("utf-8")).hexdigest(), 16)
|
|
241
|
+
return LLAMA_PORT_BASE + (h % LLAMA_PORT_SPAN)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _resolve_port(model_key, cfg) -> int:
|
|
245
|
+
p = getattr(cfg, "port", None)
|
|
246
|
+
try:
|
|
247
|
+
if p is not None and int(p) > 0:
|
|
248
|
+
return int(p)
|
|
249
|
+
except (TypeError, ValueError):
|
|
250
|
+
pass
|
|
251
|
+
return _auto_port(model_key)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _resolve_mode(cfg, extra=None) -> ServeMode:
|
|
255
|
+
if getattr(cfg, "framework", None) != "gguf":
|
|
256
|
+
return ServeMode.OFF
|
|
257
|
+
extra = extra if extra is not None else (getattr(cfg, "extra", {}) or {})
|
|
258
|
+
explicit = _clean_mode_value(extra.get("serve_mode"))
|
|
259
|
+
# systemd no longer falls back to off for a missing port — _resolve_port
|
|
260
|
+
# auto-assigns a deterministic one.
|
|
261
|
+
mode = ServeMode(explicit) if explicit else ServeMode(DEFAULT_SERVE_MODE)
|
|
262
|
+
# systemd is Linux-only. An explicit systemd request elsewhere is a user
|
|
263
|
+
# error worth surfacing; a default that resolved to systemd off-Linux can't
|
|
264
|
+
# happen (see _default_serve_mode), but guard anyway by degrading gracefully.
|
|
265
|
+
if mode is ServeMode.SYSTEMD and not _IS_LINUX:
|
|
266
|
+
if explicit:
|
|
267
|
+
raise ValueError(
|
|
268
|
+
"serve_mode=systemd is Linux-only; use 'supervised' on this OS")
|
|
269
|
+
mode = ServeMode.SUPERVISED
|
|
270
|
+
return mode
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# --------------------------------------------------------------------------- #
|
|
274
|
+
# schema #
|
|
275
|
+
# --------------------------------------------------------------------------- #
|
|
276
|
+
|
|
277
|
+
@dataclass(frozen=True)
|
|
278
|
+
class ServeSpec:
|
|
279
|
+
model_key: str
|
|
280
|
+
mode: ServeMode
|
|
281
|
+
model_file: str = ""
|
|
282
|
+
host: str = LLAMA_SWAP_HOST
|
|
283
|
+
port: Optional[int] = None
|
|
284
|
+
ctx_size: int = DEFAULT_LLAMA_CTX
|
|
285
|
+
threads: int = DEFAULT_LLAMA_THREADS
|
|
286
|
+
n_gpu_layers: int = DEFAULT_LLAMA_NGL
|
|
287
|
+
# Was ``n_gpu_layers`` ACTUALLY SPECIFIED by someone (persisted override /
|
|
288
|
+
# cfg.extra / a materialized k37 alloc_mode), or is the value above merely
|
|
289
|
+
# the fill-in default (DEFAULT_LLAMA_NGL, historically -1)?
|
|
290
|
+
#
|
|
291
|
+
# This matters because -1 is overloaded: it is BOTH the historical fill-in
|
|
292
|
+
# default AND a load-bearing explicit force ("Max GPU" — put every layer on
|
|
293
|
+
# the card; see slot_agent._effective_ngl). Downstream placement policies
|
|
294
|
+
# (notably the detected-MoE auto expert split in slot_agent._build_cmd) must
|
|
295
|
+
# fire when nobody asked for a layer count, and must NOT fire when a caller
|
|
296
|
+
# explicitly demanded -1. Collapsing the two made a 48 GB MoE launch with
|
|
297
|
+
# --n-gpu-layers -1 and no --n-cpu-moe onto a 24 GB card (ae, 2026-07-25).
|
|
298
|
+
#
|
|
299
|
+
# Wire note: this is a LOCAL spec field only. It is never serialized to a
|
|
300
|
+
# worker (central->worker relay ships model_dump()s of OTHER models under
|
|
301
|
+
# extra=forbid), so it cannot break the frozen relay schema.
|
|
302
|
+
ngl_explicit: bool = False
|
|
303
|
+
always_on: bool = False # modern default: on-demand, not a pinned unit
|
|
304
|
+
ttl_seconds: Optional[int] = None
|
|
305
|
+
user: str = LLAMA_SERVICE_USER
|
|
306
|
+
group: str = LLAMA_SERVICE_GROUP
|
|
307
|
+
working_directory: str = LLAMA_CPP_DIR
|
|
308
|
+
server_bin: str = LLAMA_SERVER_BIN
|
|
309
|
+
extra_args: Tuple[str, ...] = ()
|
|
310
|
+
|
|
311
|
+
def __post_init__(self):
|
|
312
|
+
if self.mode is ServeMode.OFF:
|
|
313
|
+
return
|
|
314
|
+
if not os.path.isabs(self.server_bin):
|
|
315
|
+
raise ValueError(f"{self.model_key}: server_bin must be absolute")
|
|
316
|
+
if self.mode in (ServeMode.SYSTEMD, ServeMode.SUPERVISED):
|
|
317
|
+
if not self.port or not 0 < self.port < 65536:
|
|
318
|
+
raise ValueError(f"{self.model_key}: {self.mode.value} needs a valid port, got {self.port!r}")
|
|
319
|
+
if not self.model_file:
|
|
320
|
+
logger.info("%s: %s will reference an unresolved model path",
|
|
321
|
+
self.model_key, self.mode.value)
|
|
322
|
+
if self.ctx_size < 1 or self.threads < 1:
|
|
323
|
+
raise ValueError(f"{self.model_key}: ctx_size and threads must be >= 1")
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def unit_name(self) -> str:
|
|
327
|
+
return _unit_slug(f"{LLAMA_UNIT_PREFIX}-{self.model_key}")
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def swap_name(self) -> str:
|
|
331
|
+
return _unit_slug(self.model_key)
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def slot_n_gpu_layers(self):
|
|
335
|
+
"""``n_gpu_layers`` for a SLOT load, carrying its provenance.
|
|
336
|
+
|
|
337
|
+
Identical in value to :attr:`n_gpu_layers` (same int, same ``str()``,
|
|
338
|
+
same JSON), but when the value is only the fill-in default it is wrapped
|
|
339
|
+
in ``slot_agent._NglDefaulted`` so the slot's placement policy still
|
|
340
|
+
treats it as unset — which is what lets the detected-MoE auto expert
|
|
341
|
+
split fire instead of launching all 48 layers onto a 24 GB card.
|
|
342
|
+
|
|
343
|
+
Unit/swap/supervisor argv keep using :attr:`n_gpu_layers` unchanged."""
|
|
344
|
+
if self.ngl_explicit:
|
|
345
|
+
return self.n_gpu_layers
|
|
346
|
+
try:
|
|
347
|
+
from hugpy_engine.serve.slot_agent import _NglDefaulted
|
|
348
|
+
except Exception: # noqa: BLE001 — slot module optional; degrade to the int
|
|
349
|
+
return self.n_gpu_layers
|
|
350
|
+
return _NglDefaulted(self.n_gpu_layers)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _ngl_for_alloc_mode(alloc_mode, model_file, extra) -> "int | None":
|
|
354
|
+
"""Materialize a persisted k37 ``alloc_mode`` onto the serve spec's
|
|
355
|
+
n_gpu_layers (the -ngl the unit/in-process load uses), or None to keep the
|
|
356
|
+
legacy extra/default resolution (mode unset, or the file is unresolvable
|
|
357
|
+
so honest layer math is impossible — degrade, never guess 0).
|
|
358
|
+
|
|
359
|
+
Same-box rail: the code computing this IS the version that honors it, so
|
|
360
|
+
no version gate applies here (unlike central's relay emission seam)."""
|
|
361
|
+
from hugpy_engine.alloc_modes import resolve_alloc_mode
|
|
362
|
+
canonical, _ = resolve_alloc_mode(alloc_mode)
|
|
363
|
+
if canonical is None:
|
|
364
|
+
return None
|
|
365
|
+
if canonical == "gpu-only":
|
|
366
|
+
return -1
|
|
367
|
+
if canonical == "ram-only":
|
|
368
|
+
return 0
|
|
369
|
+
if not model_file or not os.path.isfile(model_file):
|
|
370
|
+
return None # can't price layers — keep legacy path
|
|
371
|
+
try:
|
|
372
|
+
from hugpy_engine import spill as _spill
|
|
373
|
+
if canonical == "max-ram":
|
|
374
|
+
return _spill.maxram_gpu_layers(model_file)
|
|
375
|
+
# max-gpu / explicit: honest fit-and-spill. explicit's VRAM target
|
|
376
|
+
# (gpu_mem_gib) caps the fit; leniency-floor enforcement is the worker
|
|
377
|
+
# admission engine's job (flex.plan_explicit_offload).
|
|
378
|
+
fv = _spill.free_vram_bytes()
|
|
379
|
+
try:
|
|
380
|
+
tgt = extra.get("gpu_mem_gib")
|
|
381
|
+
if canonical == "explicit" and tgt:
|
|
382
|
+
cap = int(float(tgt) * 2 ** 30)
|
|
383
|
+
fv = min(fv, cap) if fv else cap
|
|
384
|
+
except (TypeError, ValueError):
|
|
385
|
+
pass
|
|
386
|
+
return _spill.autofit_gpu_layers(model_file, free_vram=fv)
|
|
387
|
+
except Exception: # noqa: BLE001 — spec build must never die on layer math
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def serve_spec_for(model_key=None, *, cfg=None) -> ServeSpec:
|
|
392
|
+
cfg = cfg if cfg is not None else get_model_config(model_key)
|
|
393
|
+
model_key = cfg.model_key or model_key or cfg.name # canonical registry key wins
|
|
394
|
+
extra = _effective_extra(model_key, cfg) # cfg.extra + persisted UI override
|
|
395
|
+
mode = _resolve_mode(cfg, extra)
|
|
396
|
+
|
|
397
|
+
if mode is ServeMode.SWAP:
|
|
398
|
+
host, port = LLAMA_SWAP_HOST, LLAMA_SWAP_PORT
|
|
399
|
+
elif mode in (ServeMode.SYSTEMD, ServeMode.SUPERVISED):
|
|
400
|
+
host, port = _bare_host(getattr(cfg, "host", None)), _resolve_port(model_key, cfg)
|
|
401
|
+
else:
|
|
402
|
+
host = _bare_host(getattr(cfg, "host", None))
|
|
403
|
+
port = int(cfg.port) if getattr(cfg, "port", None) else None
|
|
404
|
+
|
|
405
|
+
model_file = _model_file_for(model_key, cfg)
|
|
406
|
+
extra_args = list(extra.get("llama_extra_args") or ())
|
|
407
|
+
# Vision GGUF: load the multimodal projector beside the model so the served
|
|
408
|
+
# /v1/chat/completions accepts image_url content. No-op for text models.
|
|
409
|
+
if "--mmproj" not in extra_args:
|
|
410
|
+
from hugpy_platform.utils import find_mmproj
|
|
411
|
+
mmproj = find_mmproj(model_file)
|
|
412
|
+
if mmproj:
|
|
413
|
+
extra_args += ["--mmproj", mmproj]
|
|
414
|
+
|
|
415
|
+
# MoE expert split (2026-07-24): a persisted n_cpu_moe override rides the
|
|
416
|
+
# spec as llama-server argv (--n-cpu-moe N — experts of the first N MoE
|
|
417
|
+
# layers stay on CPU; 999 = all). Explicit argv beats any unit-level
|
|
418
|
+
# LLAMA_ARG_N_CPU_MOE env, so the served split is deterministic. Absent ->
|
|
419
|
+
# no arg (the slot path applies the detected-MoE auto policy; the
|
|
420
|
+
# systemd/swap spec stays byte-identical for dense/unset).
|
|
421
|
+
_ncm = extra.get("n_cpu_moe")
|
|
422
|
+
if _ncm not in (None, "") and "--n-cpu-moe" not in extra_args:
|
|
423
|
+
try:
|
|
424
|
+
extra_args += ["--n-cpu-moe", str(int(_ncm))]
|
|
425
|
+
except (TypeError, ValueError):
|
|
426
|
+
pass
|
|
427
|
+
|
|
428
|
+
# k37: a persisted alloc_mode materializes onto n_gpu_layers here (gpu-only
|
|
429
|
+
# -> -1, ram-only -> 0, max-gpu/explicit -> honest fit, max-ram -> inverted
|
|
430
|
+
# RAM-first fit). Mode unset (the common case) keeps the legacy resolution
|
|
431
|
+
# byte-identical.
|
|
432
|
+
mode_ngl = _ngl_for_alloc_mode(extra.get("alloc_mode"), model_file, extra)
|
|
433
|
+
|
|
434
|
+
# "Explicit" == somebody actually chose a layer placement: a materialized
|
|
435
|
+
# k37 alloc_mode, or a persisted/registry n_gpu_layers. Absent both, the
|
|
436
|
+
# spec's n_gpu_layers below is only DEFAULT_LLAMA_NGL — a fill-in, not a
|
|
437
|
+
# demand — and downstream policy (MoE auto-split) is free to override it.
|
|
438
|
+
ngl_explicit = (mode_ngl is not None
|
|
439
|
+
or extra.get("n_gpu_layers") not in (None, ""))
|
|
440
|
+
|
|
441
|
+
return ServeSpec(
|
|
442
|
+
model_key=model_key,
|
|
443
|
+
mode=mode,
|
|
444
|
+
model_file=model_file,
|
|
445
|
+
host=host,
|
|
446
|
+
port=port,
|
|
447
|
+
ctx_size=_ctx_for(cfg, model_key, extra),
|
|
448
|
+
threads=int(extra.get("threads") or DEFAULT_LLAMA_THREADS),
|
|
449
|
+
n_gpu_layers=(int(mode_ngl) if mode_ngl is not None
|
|
450
|
+
else int(extra.get("n_gpu_layers", DEFAULT_LLAMA_NGL))),
|
|
451
|
+
ngl_explicit=ngl_explicit,
|
|
452
|
+
always_on=bool(extra.get("always_on", False)),
|
|
453
|
+
ttl_seconds=extra.get("ttl_seconds") or (None if extra.get("always_on", False) else LLAMA_SWAP_TTL),
|
|
454
|
+
extra_args=tuple(extra_args),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def build_serve_specs(registry=None, *, only=None):
|
|
459
|
+
registry = registry if registry is not None else get_model_registry()
|
|
460
|
+
only = set(make_list(only)) if only else None
|
|
461
|
+
specs = {}
|
|
462
|
+
for key, cfg in registry.items():
|
|
463
|
+
if only and key not in only:
|
|
464
|
+
continue
|
|
465
|
+
spec = serve_spec_for(key, cfg=cfg)
|
|
466
|
+
specs[key] = spec
|
|
467
|
+
return specs
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _assert_no_port_collisions(specs):
|
|
471
|
+
seen = {}
|
|
472
|
+
for spec in specs:
|
|
473
|
+
seen.setdefault(spec.port, []).append(spec.model_key)
|
|
474
|
+
clashes = {p: keys for p, keys in seen.items() if len(keys) > 1}
|
|
475
|
+
if clashes:
|
|
476
|
+
detail = "; ".join(f"{p} -> {keys}" for p, keys in sorted(clashes.items()))
|
|
477
|
+
raise ValueError(f"systemd port collision: {detail}")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# --------------------------------------------------------------------------- #
|
|
481
|
+
# plan: a queue of writes + commands, executed only when you say so #
|
|
482
|
+
# --------------------------------------------------------------------------- #
|
|
483
|
+
|
|
484
|
+
@dataclass(frozen=True)
|
|
485
|
+
class WriteFile:
|
|
486
|
+
path: str
|
|
487
|
+
content: str
|
|
488
|
+
|
|
489
|
+
def describe(self) -> str:
|
|
490
|
+
return f"write {self.path} ({len(self.content)} bytes)"
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@dataclass(frozen=True)
|
|
494
|
+
class RunCmd:
|
|
495
|
+
argv: Tuple[str, ...]
|
|
496
|
+
|
|
497
|
+
def describe(self) -> str:
|
|
498
|
+
return "$ " + " ".join(self.argv)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
@dataclass(frozen=True)
|
|
502
|
+
class ServePlan:
|
|
503
|
+
steps: Tuple[object, ...] = ()
|
|
504
|
+
|
|
505
|
+
def __add__(self, other):
|
|
506
|
+
return ServePlan(self.steps + other.steps)
|
|
507
|
+
|
|
508
|
+
def describe(self):
|
|
509
|
+
return [s.describe() for s in self.steps]
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def apply_plan(plan, *, run=None, write=None):
|
|
513
|
+
"""Drain a ServePlan in order.
|
|
514
|
+
|
|
515
|
+
Dry run (run=write=None) returns plan.describe() — hand that to the console.
|
|
516
|
+
To execute, pass callables:
|
|
517
|
+
run(argv) e.g. partial(subprocess.run, check=True)
|
|
518
|
+
write(path, text) e.g. local writer, or an SSH writer for a peer node
|
|
519
|
+
Local vs remote is entirely the caller's run/write — the plan is host-agnostic.
|
|
520
|
+
"""
|
|
521
|
+
if run is None and write is None:
|
|
522
|
+
return plan.describe()
|
|
523
|
+
results = []
|
|
524
|
+
for step in plan.steps:
|
|
525
|
+
if isinstance(step, WriteFile):
|
|
526
|
+
if write is None:
|
|
527
|
+
raise RuntimeError("plan has file writes but no write() was provided")
|
|
528
|
+
results.append(write(step.path, step.content))
|
|
529
|
+
else:
|
|
530
|
+
if run is None:
|
|
531
|
+
raise RuntimeError("plan has commands but no run() was provided")
|
|
532
|
+
results.append(run(list(step.argv)))
|
|
533
|
+
return results
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# --------------------------------------------------------------------------- #
|
|
537
|
+
# driver registry #
|
|
538
|
+
# --------------------------------------------------------------------------- #
|
|
539
|
+
|
|
540
|
+
_SERVE_DRIVERS = {}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def register_serve_driver(mode):
|
|
544
|
+
def deco(cls):
|
|
545
|
+
if mode in _SERVE_DRIVERS:
|
|
546
|
+
raise KeyError(f"serve driver for {mode} already registered")
|
|
547
|
+
_SERVE_DRIVERS[mode] = cls() # stateless singleton
|
|
548
|
+
return cls
|
|
549
|
+
return deco
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def get_serve_driver(mode):
|
|
553
|
+
if mode not in _SERVE_DRIVERS:
|
|
554
|
+
raise KeyError(f"no serve driver for mode={mode!r}; have {sorted(_SERVE_DRIVERS)}")
|
|
555
|
+
return _SERVE_DRIVERS[mode]
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# ---- systemd: one unit per model, always-on -------------------------------
|
|
559
|
+
|
|
560
|
+
@register_serve_driver(ServeMode.SYSTEMD)
|
|
561
|
+
class SystemdDriver:
|
|
562
|
+
name = "systemd"
|
|
563
|
+
|
|
564
|
+
def endpoint(self, spec):
|
|
565
|
+
return f"http://{spec.host}:{spec.port}"
|
|
566
|
+
|
|
567
|
+
def model_name(self, spec):
|
|
568
|
+
return spec.swap_name
|
|
569
|
+
|
|
570
|
+
def render_unit(self, spec):
|
|
571
|
+
exec_start = " \\\n ".join((
|
|
572
|
+
spec.server_bin,
|
|
573
|
+
f"-m {spec.model_file}",
|
|
574
|
+
f"--host {spec.host}",
|
|
575
|
+
f"--port {spec.port}",
|
|
576
|
+
f"-c {spec.ctx_size}",
|
|
577
|
+
f"-t {spec.threads}",
|
|
578
|
+
# GPU offload — without this llama-server runs CPU-only.
|
|
579
|
+
f"--n-gpu-layers {spec.n_gpu_layers}",
|
|
580
|
+
*spec.extra_args,
|
|
581
|
+
))
|
|
582
|
+
return "\n".join((
|
|
583
|
+
"[Unit]",
|
|
584
|
+
f"Description=llama.cpp server for {spec.model_key}",
|
|
585
|
+
"After=network.target",
|
|
586
|
+
# Restart-loop limiter lives in [Unit] (systemd ignores it in
|
|
587
|
+
# [Service] — 'Unknown key name StartLimitIntervalSec in section
|
|
588
|
+
# Service'). Caps thrash on a bad GGUF.
|
|
589
|
+
"StartLimitIntervalSec=120",
|
|
590
|
+
"StartLimitBurst=5",
|
|
591
|
+
"",
|
|
592
|
+
"[Service]",
|
|
593
|
+
"Type=simple",
|
|
594
|
+
f"User={spec.user}",
|
|
595
|
+
f"Group={spec.group}",
|
|
596
|
+
f"WorkingDirectory={spec.working_directory}",
|
|
597
|
+
f"ExecStart={exec_start}",
|
|
598
|
+
"Restart=always",
|
|
599
|
+
"RestartSec=5",
|
|
600
|
+
"TimeoutStartSec=300",
|
|
601
|
+
"TimeoutStopSec=30",
|
|
602
|
+
"",
|
|
603
|
+
"[Install]",
|
|
604
|
+
"WantedBy=multi-user.target",
|
|
605
|
+
"",
|
|
606
|
+
))
|
|
607
|
+
|
|
608
|
+
def install_plan(self, specs):
|
|
609
|
+
steps = []
|
|
610
|
+
for spec in specs:
|
|
611
|
+
path = os.path.join(SYSTEMD_UNIT_DIR, spec.unit_name + ".service")
|
|
612
|
+
steps.append(WriteFile(path, self.render_unit(spec)))
|
|
613
|
+
steps.append(RunCmd(("systemctl", "daemon-reload")))
|
|
614
|
+
for spec in specs:
|
|
615
|
+
unit = spec.unit_name + ".service"
|
|
616
|
+
steps.append(RunCmd(("systemctl", "enable", "--now" if spec.always_on else unit, unit)
|
|
617
|
+
if spec.always_on else ("systemctl", "enable", unit)))
|
|
618
|
+
return ServePlan(tuple(steps))
|
|
619
|
+
|
|
620
|
+
def start_plan(self, spec):
|
|
621
|
+
return ServePlan((RunCmd(("systemctl", "start", spec.unit_name + ".service")),))
|
|
622
|
+
|
|
623
|
+
def stop_plan(self, spec):
|
|
624
|
+
return ServePlan((RunCmd(("systemctl", "stop", spec.unit_name + ".service")),))
|
|
625
|
+
|
|
626
|
+
def status_plan(self, spec):
|
|
627
|
+
return ServePlan((RunCmd(("systemctl", "is-active", spec.unit_name + ".service")),))
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
# ---- swap: one shared llama-swap proxy, on-demand -------------------------
|
|
631
|
+
|
|
632
|
+
@register_serve_driver(ServeMode.SWAP)
|
|
633
|
+
class SwapDriver:
|
|
634
|
+
name = "swap"
|
|
635
|
+
|
|
636
|
+
def endpoint(self, spec):
|
|
637
|
+
return f"http://{spec.host}:{spec.port}" # the proxy, same for all
|
|
638
|
+
|
|
639
|
+
def model_name(self, spec):
|
|
640
|
+
return spec.swap_name # request "model" field
|
|
641
|
+
|
|
642
|
+
def render_config(self, specs):
|
|
643
|
+
try:
|
|
644
|
+
import yaml
|
|
645
|
+
except ImportError as exc:
|
|
646
|
+
raise ImportError("swap mode needs PyYAML: pip install pyyaml") from exc
|
|
647
|
+
|
|
648
|
+
models = {}
|
|
649
|
+
members = []
|
|
650
|
+
for spec in specs:
|
|
651
|
+
slug = spec.swap_name
|
|
652
|
+
cmd = " ".join((
|
|
653
|
+
spec.server_bin, "-m", spec.model_file,
|
|
654
|
+
"--host", "127.0.0.1", "--port", "${PORT}",
|
|
655
|
+
"-c", str(spec.ctx_size), "-t", str(spec.threads),
|
|
656
|
+
"--n-gpu-layers", str(spec.n_gpu_layers),
|
|
657
|
+
*spec.extra_args,
|
|
658
|
+
))
|
|
659
|
+
entry = {"cmd": cmd}
|
|
660
|
+
if spec.always_on:
|
|
661
|
+
members.append(slug)
|
|
662
|
+
elif spec.ttl_seconds:
|
|
663
|
+
entry["ttl"] = spec.ttl_seconds
|
|
664
|
+
models[slug] = entry
|
|
665
|
+
|
|
666
|
+
cfg = {"models": models}
|
|
667
|
+
if members:
|
|
668
|
+
# always-on models share a group so they coexist instead of evicting
|
|
669
|
+
# each other. NOTE: verify the coexistence flag names against your
|
|
670
|
+
# llama-swap version — group schema has shifted across releases.
|
|
671
|
+
cfg["groups"] = {"persistent": {"swap": False, "exclusive": False, "members": members}}
|
|
672
|
+
return yaml.safe_dump(cfg, sort_keys=False)
|
|
673
|
+
|
|
674
|
+
def install_plan(self, specs):
|
|
675
|
+
steps = [
|
|
676
|
+
WriteFile(LLAMA_SWAP_CONFIG, self.render_config(specs)),
|
|
677
|
+
RunCmd(("systemctl", "restart", LLAMA_SWAP_UNIT)),
|
|
678
|
+
]
|
|
679
|
+
return ServePlan(tuple(steps))
|
|
680
|
+
|
|
681
|
+
def start_plan(self, spec):
|
|
682
|
+
# on-demand: the proxy loads on first request. Optional warmup hit.
|
|
683
|
+
return ServePlan(())
|
|
684
|
+
|
|
685
|
+
def stop_plan(self, spec):
|
|
686
|
+
url = f"http://{spec.host}:{spec.port}/unload?model={spec.swap_name}"
|
|
687
|
+
return ServePlan((RunCmd(("curl", "-s", "-X", "POST", url)),))
|
|
688
|
+
|
|
689
|
+
def status_plan(self, spec):
|
|
690
|
+
return ServePlan((RunCmd(("curl", "-s", f"http://{spec.host}:{spec.port}/running")),))
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
# ---- supervised: portable always-on, no init system -----------------------
|
|
694
|
+
|
|
695
|
+
@register_serve_driver(ServeMode.SUPERVISED)
|
|
696
|
+
class SupervisedDriver:
|
|
697
|
+
"""One detached ``llama-server`` per model, supervised by hugpy itself.
|
|
698
|
+
|
|
699
|
+
The cross-platform replacement for systemd units. Plans shell out to the
|
|
700
|
+
``hugpy.managers.serve.supervisor`` subcommand, which spawns/kills the child
|
|
701
|
+
via the OS-appropriate process primitive and tracks it in a pidfile under the
|
|
702
|
+
per-OS data dir. No unit files, no root, works on Windows/macOS/Linux.
|
|
703
|
+
"""
|
|
704
|
+
name = "supervised"
|
|
705
|
+
|
|
706
|
+
def endpoint(self, spec):
|
|
707
|
+
return f"http://{spec.host}:{spec.port}"
|
|
708
|
+
|
|
709
|
+
def model_name(self, spec):
|
|
710
|
+
return spec.swap_name
|
|
711
|
+
|
|
712
|
+
def _cmd(self, action, model_key):
|
|
713
|
+
return RunCmd((sys.executable, "-m", "hugpy_engine.serve.supervisor",
|
|
714
|
+
action, model_key))
|
|
715
|
+
|
|
716
|
+
def install_plan(self, specs):
|
|
717
|
+
# Nothing to write ahead of time; "install" == start each always-on model.
|
|
718
|
+
steps = [self._cmd("start", s.model_key) for s in specs if s.always_on]
|
|
719
|
+
return ServePlan(tuple(steps))
|
|
720
|
+
|
|
721
|
+
def start_plan(self, spec):
|
|
722
|
+
return ServePlan((self._cmd("start", spec.model_key),))
|
|
723
|
+
|
|
724
|
+
def stop_plan(self, spec):
|
|
725
|
+
return ServePlan((self._cmd("stop", spec.model_key),))
|
|
726
|
+
|
|
727
|
+
def status_plan(self, spec):
|
|
728
|
+
return ServePlan((self._cmd("status", spec.model_key),))
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# ---- off: in-process only, nothing to install -----------------------------
|
|
732
|
+
|
|
733
|
+
@register_serve_driver(ServeMode.OFF)
|
|
734
|
+
class OffDriver:
|
|
735
|
+
name = "off"
|
|
736
|
+
|
|
737
|
+
def endpoint(self, spec):
|
|
738
|
+
return None # runner falls back to in-process
|
|
739
|
+
|
|
740
|
+
def model_name(self, spec):
|
|
741
|
+
return spec.swap_name
|
|
742
|
+
|
|
743
|
+
def install_plan(self, specs):
|
|
744
|
+
return ServePlan(())
|
|
745
|
+
|
|
746
|
+
def start_plan(self, spec):
|
|
747
|
+
return ServePlan(())
|
|
748
|
+
|
|
749
|
+
def stop_plan(self, spec):
|
|
750
|
+
return ServePlan(())
|
|
751
|
+
|
|
752
|
+
def status_plan(self, spec):
|
|
753
|
+
return ServePlan(())
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
# --------------------------------------------------------------------------- #
|
|
757
|
+
# top-level API — what the runner and the console call #
|
|
758
|
+
# --------------------------------------------------------------------------- #
|
|
759
|
+
|
|
760
|
+
def serve_endpoint(model_key) -> Optional[str]:
|
|
761
|
+
"""Base URL the HTTP runner should hit, or None for in-process.
|
|
762
|
+
|
|
763
|
+
For llama.cpp models the slot pool is preferred: a free/loaded slot serves
|
|
764
|
+
the model on the GPU, and only when every slot is busy do we fall back to
|
|
765
|
+
the swap proxy (the configured overflow). Non-llama models stay in-process.
|
|
766
|
+
"""
|
|
767
|
+
# Per-box "never serve locally" policy: every serve mode here (slot pool,
|
|
768
|
+
# llama-swap proxy, local systemd unit) is LOCAL to this box, so a policy box
|
|
769
|
+
# must expose no local endpoint at all. Return None -> the HTTP runner treats
|
|
770
|
+
# it as mode=off and get_llama_runner's own policy gate raises the actionable
|
|
771
|
+
# LocalEngineUnavailable. Default off === today's behavior. See .policy.
|
|
772
|
+
from hugpy_engine.serve.policy import no_local_serving
|
|
773
|
+
if no_local_serving():
|
|
774
|
+
return None
|
|
775
|
+
|
|
776
|
+
# Resolve a possibly-bare/ambiguous key to its canonical registry key,
|
|
777
|
+
# preferring a variant already loaded in a slot so we reuse it instead of
|
|
778
|
+
# loading a second copy. Everything downstream uses the canonical key.
|
|
779
|
+
pool = None
|
|
780
|
+
prefer = None
|
|
781
|
+
try:
|
|
782
|
+
from hugpy_engine.serve.slots import SlotPool, slots_enabled
|
|
783
|
+
if slots_enabled():
|
|
784
|
+
pool = SlotPool()
|
|
785
|
+
prefer = [s.get("model_key") for s in pool.statuses() if s.get("model_key")]
|
|
786
|
+
except Exception as exc: # never let slot scheduling break serving
|
|
787
|
+
logger.warning("slot status lookup failed for %s: %s", model_key, exc)
|
|
788
|
+
|
|
789
|
+
cfg = get_model_config(model_key, prefer=prefer)
|
|
790
|
+
model_key = cfg.model_key # canonical from here on
|
|
791
|
+
spec = serve_spec_for(model_key, cfg=cfg)
|
|
792
|
+
if spec.mode is ServeMode.OFF:
|
|
793
|
+
return None
|
|
794
|
+
|
|
795
|
+
try:
|
|
796
|
+
if pool is not None:
|
|
797
|
+
endpoint = pool.endpoint_for(model_key)
|
|
798
|
+
if endpoint:
|
|
799
|
+
return endpoint
|
|
800
|
+
logger.info("all slots busy; routing %s via swap proxy", model_key)
|
|
801
|
+
return f"http://{LLAMA_SWAP_HOST}:{LLAMA_SWAP_PORT}"
|
|
802
|
+
except Exception as exc: # never let slot scheduling break serving
|
|
803
|
+
logger.warning("slot routing failed for %s: %s; using %s",
|
|
804
|
+
model_key, exc, spec.mode.value)
|
|
805
|
+
|
|
806
|
+
return get_serve_driver(spec.mode).endpoint(spec)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def serve_model_name(model_key) -> str:
|
|
810
|
+
"""Value to put in the request 'model' field (matters for swap routing)."""
|
|
811
|
+
spec = serve_spec_for(model_key)
|
|
812
|
+
return get_serve_driver(spec.mode).model_name(spec)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def install_serving(*, only=None, registry=None) -> ServePlan:
|
|
816
|
+
"""One plan that stands up everything. Batches by mode so all swap models
|
|
817
|
+
land in one config write, each systemd model in its own unit."""
|
|
818
|
+
specs = build_serve_specs(registry=registry, only=only)
|
|
819
|
+
# Port-bound modes (systemd unit / supervised child) must not clash on a port.
|
|
820
|
+
_assert_no_port_collisions([s for s in specs.values()
|
|
821
|
+
if s.mode in (ServeMode.SYSTEMD, ServeMode.SUPERVISED)])
|
|
822
|
+
by_mode = {}
|
|
823
|
+
for spec in specs.values():
|
|
824
|
+
by_mode.setdefault(spec.mode, []).append(spec)
|
|
825
|
+
|
|
826
|
+
plan = ServePlan(())
|
|
827
|
+
for mode, mode_specs in by_mode.items():
|
|
828
|
+
plan = plan + get_serve_driver(mode).install_plan(mode_specs)
|
|
829
|
+
return plan
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def start_serving(model_key) -> ServePlan:
|
|
833
|
+
spec = serve_spec_for(model_key)
|
|
834
|
+
return get_serve_driver(spec.mode).start_plan(spec)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def stop_serving(model_key) -> ServePlan:
|
|
838
|
+
spec = serve_spec_for(model_key)
|
|
839
|
+
return get_serve_driver(spec.mode).stop_plan(spec)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def serving_overview(registry=None):
|
|
843
|
+
"""Rows for the console: what each model's serving looks like (no side effects).
|
|
844
|
+
|
|
845
|
+
2026-09-18: each row is annotated ``present`` = whether central has the
|
|
846
|
+
model's weights on disk (the ``model_physical`` store — the post-migration
|
|
847
|
+
source of truth). The registry lists every CONFIGURED model, including ones
|
|
848
|
+
nothing has downloaded yet, so the console can grey-out / defer models that
|
|
849
|
+
aren't actually servable instead of showing them as ready.
|
|
850
|
+
"""
|
|
851
|
+
registry = registry if registry is not None else get_model_registry()
|
|
852
|
+
specs = build_serve_specs(registry=registry)
|
|
853
|
+
rows = []
|
|
854
|
+
for key, spec in specs.items():
|
|
855
|
+
driver = get_serve_driver(spec.mode)
|
|
856
|
+
row = spec_row(spec, driver)
|
|
857
|
+
row["present"] = _central_has_weights(key, registry.get(key))
|
|
858
|
+
rows.append(row)
|
|
859
|
+
return rows
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _central_has_weights(model_key, cfg) -> bool:
|
|
863
|
+
"""True if central's model_physical store has this model's weights on disk.
|
|
864
|
+
Never raises — presence is advisory, a store hiccup degrades to False."""
|
|
865
|
+
if cfg is None:
|
|
866
|
+
return False
|
|
867
|
+
try:
|
|
868
|
+
from hugpy_storage.model_physical import lookup_physical
|
|
869
|
+
rec, _reason = lookup_physical(model_key, cfg)
|
|
870
|
+
return rec is not None
|
|
871
|
+
except Exception: # noqa: BLE001 — never break the overview on a presence lookup
|
|
872
|
+
return False
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def spec_row(spec, driver=None) -> dict:
|
|
876
|
+
"""One serving row for the console — the editable knobs + resolved endpoint."""
|
|
877
|
+
driver = driver or get_serve_driver(spec.mode)
|
|
878
|
+
return {
|
|
879
|
+
"key": spec.model_key,
|
|
880
|
+
"mode": spec.mode.value,
|
|
881
|
+
"always_on": spec.always_on,
|
|
882
|
+
"endpoint": driver.endpoint(spec),
|
|
883
|
+
"model_name": driver.model_name(spec),
|
|
884
|
+
"port": spec.port,
|
|
885
|
+
"n_gpu_layers": spec.n_gpu_layers,
|
|
886
|
+
"threads": spec.threads,
|
|
887
|
+
"ctx_size": spec.ctx_size,
|
|
888
|
+
"ttl_seconds": spec.ttl_seconds,
|
|
889
|
+
}
|