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.
Files changed (128) hide show
  1. hugpy_engine/__init__.py +163 -0
  2. hugpy_engine/alloc_modes.py +1254 -0
  3. hugpy_engine/allocation.py +68 -0
  4. hugpy_engine/apis/__init__.py +0 -0
  5. hugpy_engine/apis/call_api.py +149 -0
  6. hugpy_engine/apis/get_module.py +606 -0
  7. hugpy_engine/apis/reclassify.py +123 -0
  8. hugpy_engine/apis/reconcile.py +628 -0
  9. hugpy_engine/apis/serve/__init__.py +22 -0
  10. hugpy_engine/apis/serve/serve.py +34 -0
  11. hugpy_engine/apis/serve/serve_cli.py +9 -0
  12. hugpy_engine/apis/systemd_units.py +9 -0
  13. hugpy_engine/backend.py +86 -0
  14. hugpy_engine/backends/__init__.py +5 -0
  15. hugpy_engine/backends/local.py +163 -0
  16. hugpy_engine/catalog.py +99 -0
  17. hugpy_engine/catalog_bridge.py +223 -0
  18. hugpy_engine/categories.py +117 -0
  19. hugpy_engine/chat_context/__init__.py +27 -0
  20. hugpy_engine/chat_context/chat_context.py +199 -0
  21. hugpy_engine/chat_context/context_budget.py +223 -0
  22. hugpy_engine/chat_context/unbounded.py +55 -0
  23. hugpy_engine/config/__init__.py +0 -0
  24. hugpy_engine/config/main.py +270 -0
  25. hugpy_engine/config/models/__init__.py +0 -0
  26. hugpy_engine/config/models/model_meta.py +169 -0
  27. hugpy_engine/config/models/models_config.py +1495 -0
  28. hugpy_engine/config/models/models_default.py +196 -0
  29. hugpy_engine/config/models/models_dict.py +73 -0
  30. hugpy_engine/contracts.py +44 -0
  31. hugpy_engine/dispatch/__init__.py +37 -0
  32. hugpy_engine/dispatch/acquire.py +59 -0
  33. hugpy_engine/dispatch/activity.py +92 -0
  34. hugpy_engine/dispatch/dispatch.py +1100 -0
  35. hugpy_engine/draft_models.py +72 -0
  36. hugpy_engine/engines.py +44 -0
  37. hugpy_engine/eviction.py +654 -0
  38. hugpy_engine/generate/__init__.py +16 -0
  39. hugpy_engine/generate/coder.py +667 -0
  40. hugpy_engine/generate/config.py +185 -0
  41. hugpy_engine/generate/generate_runner.py +273 -0
  42. hugpy_engine/gguf_election.py +259 -0
  43. hugpy_engine/llama/__init__.py +23 -0
  44. hugpy_engine/llama/runners/__init__.py +23 -0
  45. hugpy_engine/llama/runners/chat_runner.py +243 -0
  46. hugpy_engine/llama/runners/get.py +524 -0
  47. hugpy_engine/llama/runners/src/__init__.py +9 -0
  48. hugpy_engine/llama/runners/src/base_runner.py +529 -0
  49. hugpy_engine/llama/runners/src/ccp_runner.py +332 -0
  50. hugpy_engine/llama/runners/src/imports/__init__.py +25 -0
  51. hugpy_engine/llama/runners/src/imports/config.py +16 -0
  52. hugpy_engine/llama/runners/src/imports/constants.py +38 -0
  53. hugpy_engine/llama/runners/src/imports/init_imports.py +6 -0
  54. hugpy_engine/llama/runners/src/imports/utils.py +64 -0
  55. hugpy_engine/llama/runners/src/python_runner.py +432 -0
  56. hugpy_engine/llama/runners/src/shard_server.py +339 -0
  57. hugpy_engine/llama/runners/src/shard_server.py.bak-shardorder +329 -0
  58. hugpy_engine/llama/serve.py +9 -0
  59. hugpy_engine/manifest.py +53 -0
  60. hugpy_engine/model_classifier.py +374 -0
  61. hugpy_engine/model_index/__init__.py +74 -0
  62. hugpy_engine/model_index/client.py +94 -0
  63. hugpy_engine/model_index/query_registry.py +245 -0
  64. hugpy_engine/model_index/repositories.py +163 -0
  65. hugpy_engine/model_index/service.py +243 -0
  66. hugpy_engine/name_match.py +287 -0
  67. hugpy_engine/native/__init__.py +19 -0
  68. hugpy_engine/native/build.py +98 -0
  69. hugpy_engine/native/fetch.py +194 -0
  70. hugpy_engine/native/resolve.py +352 -0
  71. hugpy_engine/peft_adapters.py +293 -0
  72. hugpy_engine/placement.py +459 -0
  73. hugpy_engine/py.typed +0 -0
  74. hugpy_engine/query.py +173 -0
  75. hugpy_engine/resolvers/__init__.py +24 -0
  76. hugpy_engine/resolvers/allocator.py +126 -0
  77. hugpy_engine/resolvers/assure_model_key.py +331 -0
  78. hugpy_engine/resolvers/categories/__init__.py +22 -0
  79. hugpy_engine/resolvers/categories/builders.py +159 -0
  80. hugpy_engine/resolvers/categories/frameworks.py +67 -0
  81. hugpy_engine/resolvers/groups.py +651 -0
  82. hugpy_engine/resolvers/model_dict_resolver.py +122 -0
  83. hugpy_engine/resolvers/model_resolver.py +533 -0
  84. hugpy_engine/resolvers/remote.py +2992 -0
  85. hugpy_engine/runtime.py +68 -0
  86. hugpy_engine/schemas/__init__.py +0 -0
  87. hugpy_engine/schemas/chat_schemas.py +320 -0
  88. hugpy_engine/schemas/event_schemas.py +52 -0
  89. hugpy_engine/schemas/metadata_schemas.py +62 -0
  90. hugpy_engine/schemas/model_schemas.py +101 -0
  91. hugpy_engine/schemas/runner_schemas.py +33 -0
  92. hugpy_engine/schemas/task_schemas.py +45 -0
  93. hugpy_engine/serve/__init__.py +58 -0
  94. hugpy_engine/serve/chunksum_verify.py +213 -0
  95. hugpy_engine/serve/hot_cache.py +1014 -0
  96. hugpy_engine/serve/model_cache.py +218 -0
  97. hugpy_engine/serve/overrides.py +968 -0
  98. hugpy_engine/serve/policy.py +45 -0
  99. hugpy_engine/serve/profiles.py +362 -0
  100. hugpy_engine/serve/serve.py +889 -0
  101. hugpy_engine/serve/serve.py.bak-present-20260918-035851 +863 -0
  102. hugpy_engine/serve/serve.py.bak-servelog-20260918-035149 +863 -0
  103. hugpy_engine/serve/serve_cli.py +85 -0
  104. hugpy_engine/serve/slot_agent.py +2119 -0
  105. hugpy_engine/serve/slot_agent.py.bak-quietlog-20260918-023710 +2046 -0
  106. hugpy_engine/serve/slot_agent.py.bak-slotparallel-20260918-112917 +2064 -0
  107. hugpy_engine/serve/slot_agent.py.bak-wzfilter-20260918-041625 +2047 -0
  108. hugpy_engine/serve/slots.py +630 -0
  109. hugpy_engine/serve/supervisor.py +148 -0
  110. hugpy_engine/spill.py +1464 -0
  111. hugpy_engine/task_deps.py +67 -0
  112. hugpy_engine/tasks.py +349 -0
  113. hugpy_engine/utils/__init__.py +0 -0
  114. hugpy_engine/utils/json_scavenge.py +111 -0
  115. hugpy_engine/utils/no_think.py +370 -0
  116. hugpy_engine/wire/__init__.py +0 -0
  117. hugpy_engine/wire/chat_schemas.py +66 -0
  118. hugpy_engine/wire/config_schemas.py +31 -0
  119. hugpy_engine/wire/install_schemas.py +17 -0
  120. hugpy_engine/wire/model_schemas.py +43 -0
  121. hugpy_engine/wire/request_schemas.py +13 -0
  122. hugpy_engine/wire/specs_schemas.py +9 -0
  123. hugpy_engine-0.2.0a0.dist-info/METADATA +116 -0
  124. hugpy_engine-0.2.0a0.dist-info/RECORD +128 -0
  125. hugpy_engine-0.2.0a0.dist-info/WHEEL +5 -0
  126. hugpy_engine-0.2.0a0.dist-info/entry_points.txt +2 -0
  127. hugpy_engine-0.2.0a0.dist-info/licenses/LICENSE +41 -0
  128. 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
+ }