Skip to content

Commit 2ba8771

Browse files
authored
Merge pull request #13 from LLMSystems/fix/multi-engine-review-and-selectable-backends
feat(ha): unschedulable visibility (M#5 UI) + capability-based sleep …
2 parents dd82f09 + 6a91a42 commit 2ba8771

12 files changed

Lines changed: 135 additions & 9 deletions

File tree

apps/backend/app/api/models.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,14 @@ async def list_models(request: Request, manager: ModelManager = Depends(get_mana
5959
# (each node backfills its *owned* observed state) — on leader and follower
6060
# alike, since with per-node actuation (Phase 7) no single registry is complete.
6161
# SQLite collapsed: the local registry is the truth — identical to before.
62-
return [ModelView(**v) for v in await manager.fleet_views(prefer_store=manager.prefer_store_view())]
62+
views = await manager.fleet_views(prefer_store=manager.prefer_store_view())
63+
# Annotate any model stuck because no live node can run its engine (Medium#5).
64+
reasons = await manager.unschedulable_reasons()
65+
if reasons:
66+
for v in views:
67+
if v.get("key") in reasons:
68+
v["unschedulable_reason"] = reasons[v["key"]]
69+
return [ModelView(**v) for v in views]
6370

6471

6572
@router.post("/parse", dependencies=[Depends(require_operator)])

apps/backend/app/api/schemas.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ class ModelView(BaseModel):
3131
# HA Phase 3d: which node-agent runs this instance (multi-node deploys). None
3232
# on a single host / when unknown.
3333
node_id: Optional[str] = None
34+
# Set when this model is desired-running but no live node can run its engine, so
35+
# it will stay stopped until a matching node appears — distinguishes "won't ever
36+
# start" from "cold-starting" for the dashboard (Medium#5). None = schedulable.
37+
unschedulable_reason: Optional[str] = None
3438

3539
@classmethod
3640
def from_instance(cls, inst: ModelInstance) -> "ModelView":

apps/backend/app/llmops/manager.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,34 @@ async def fleet_state_snapshot(self) -> list:
461461
out.append(inst)
462462
return out
463463

464+
async def unschedulable_reasons(self) -> dict[str, str]:
465+
"""Desired-running models that no live node can run (their engine is advertised
466+
by no live node), each with a human reason. Lets the dashboard tell "won't ever
467+
start" from "cold-starting" (Medium#5). Computed from the shared store, so any
468+
replica serving the API returns the same answer regardless of who holds the
469+
scheduler lease. Empty outside HA/Postgres mode — collapsed single host runs
470+
everything. Best-effort: any store issue yields {}."""
471+
if not self.prefer_store_view() or not hasattr(self.store, "list_nodes"):
472+
return {}
473+
from app.llmops.scheduler import node_supports
474+
try:
475+
desired = await self.store.list_instance_desired()
476+
nodes = await self.store.list_nodes()
477+
except Exception:
478+
logger.debug("unschedulable_reasons: store read failed", exc_info=True)
479+
return {}
480+
out: dict[str, str] = {}
481+
for key, want in desired.items():
482+
if want != Desired.RUNNING.value:
483+
continue
484+
group = key.split("::")[0]
485+
engine = getattr(
486+
getattr(self.config.LLM_engines.get(group), "settings", None),
487+
"engine", "vllm")
488+
if not any(node_supports(n, engine) for n in nodes):
489+
out[key] = f"no live node runs engine '{engine}'"
490+
return out
491+
464492
async def get(self, key: str) -> ModelInstance:
465493
return self._require(key)
466494

apps/backend/tests/unit/test_ha_safety.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,3 +321,28 @@ async def test_edit_rejects_duplicate_lora_within_group(tmp_path):
321321
"lora_modules": [{"name": "same", "path": "/p1"},
322322
{"name": "same", "path": "/p2"}]},
323323
)
324+
325+
326+
# ---- Medium#5: unschedulable visibility (API) ----------------------------
327+
328+
async def test_unschedulable_reason_when_no_node_runs_engine(tmp_path):
329+
store = FakeStore()
330+
store.desired = {"Qwen3-0.6B::a": Desired.RUNNING.value} # group engine defaults vllm
331+
store.nodes = [{"node_id": "n1", "engines": '["sglang"]'}] # no vllm node
332+
mgr, _ = _manager(tmp_path, store=store)
333+
reasons = await mgr.unschedulable_reasons()
334+
assert "Qwen3-0.6B::a" in reasons and "vllm" in reasons["Qwen3-0.6B::a"]
335+
336+
337+
async def test_not_unschedulable_when_a_node_runs_engine(tmp_path):
338+
store = FakeStore()
339+
store.desired = {"Qwen3-0.6B::a": Desired.RUNNING.value}
340+
store.nodes = [{"node_id": "n1", "engines": '["vllm"]'}]
341+
mgr, _ = _manager(tmp_path, store=store)
342+
assert await mgr.unschedulable_reasons() == {}
343+
344+
345+
async def test_unschedulable_empty_in_collapsed_mode(tmp_path):
346+
# No store / SQLite (db_url None) -> nothing computed (single host runs everything).
347+
mgr, _ = _manager(tmp_path, store=None)
348+
assert await mgr.unschedulable_reasons() == {}

apps/frontend_llmops/src/components/ModelGroupCard.vue

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script setup lang="ts">
22
import { computed, ref } from 'vue'
3-
import { Box, ChevronDown, ChevronRight, Gauge, Loader2, Play, Plus, Power, RotateCw, Shuffle, Sparkles, Square } from '@lucide/vue'
3+
import { AlertTriangle, Box, ChevronDown, ChevronRight, Gauge, Loader2, Play, Plus, Power, RotateCw, Shuffle, Sparkles, Square } from '@lucide/vue'
44
import { useI18n } from 'vue-i18n'
55
import Card from '@/components/ui/Card.vue'
66
import Badge from '@/components/ui/Badge.vue'
@@ -385,6 +385,14 @@ const startLockTitle = computed(() =>
385385
<RotateCw class="size-3" />{{ model.restart_count }}
386386
</span>
387387
</Tooltip>
388+
<Tooltip
389+
v-if="model.unschedulable_reason"
390+
:text="t('modelGroup.unschedulable', { reason: model.unschedulable_reason })"
391+
>
392+
<span class="flex items-center gap-0.5 text-[10px] text-status-failed" @click.stop>
393+
<AlertTriangle class="size-3" />{{ t('modelGroup.unschedulableShort') }}
394+
</span>
395+
</Tooltip>
388396

389397
<div class="ml-auto flex items-center gap-2.5">
390398
<Tooltip v-if="metricsFor(model)">

apps/frontend_llmops/src/i18n/locales/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,8 @@ export default {
11021102
embedding: 'Embedding',
11031103
reranking: 'Reranking',
11041104
crashRestart: 'Crashed and auto-restarted {n} times',
1105+
unschedulableShort: 'unschedulable',
1106+
unschedulable: 'Wanted running but {reason} — it stays stopped until a matching backend is available.',
11051107
liveLoad: 'Live load (router /metrics)',
11061108
runningDesc: ' Running — currently generating requests',
11071109
waitingDesc: ' Waiting — queued requests for this instance',

apps/frontend_llmops/src/i18n/locales/zh-TW.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,6 +1079,8 @@ export default {
10791079
embedding: '嵌入',
10801080
reranking: '重排序',
10811081
crashRestart: '崩潰後自動重啟 {n} 次',
1082+
unschedulableShort: '無法排程',
1083+
unschedulable: '想跑但{reason}——會一直停著,直到有能跑它的 backend 出現。',
10821084
liveLoad: '即時負載(路由器 /metrics)',
10831085
runningDesc: ' 執行中 — 目前正在生成的請求',
10841086
waitingDesc: ' 等待中 — 此實例的排隊請求',

apps/frontend_llmops/src/types/api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export interface ModelView {
4141
ready_at: number | null
4242
updated_at: number | null
4343
restart_count?: number
44+
node_id?: string | null
45+
// Set when the model is desired-running but no live node can run its engine
46+
// (mixed-engine fleets) — it will stay stopped until a matching backend appears.
47+
unschedulable_reason?: string | null
4448
}
4549

4650
export interface MemoryInfo {

apps/router-server/src/llm_router/metrics_poller.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import logging
33
from typing import Any, Dict
44

5+
from src.llm_router.vllm_metrics_client import engine_sleep_capable
6+
57
logger = logging.getLogger(__name__)
68

79

@@ -80,7 +82,12 @@ async def poll_metrics_forever(app, interval: float = 1.0):
8082
# nothing extra.
8183
sleep_backends: Dict[str, str] = {}
8284
for model_key, model_cfg in llm_engines.items():
83-
if not model_cfg.get("model_config", {}).get("enable_sleep_mode"):
85+
mc = model_cfg.get("model_config", {}) or {}
86+
# Gate on the engine's *capability*, not just the config toggle: only
87+
# engines that actually have /sleep+/is_sleeping are probed, so an
88+
# enable_sleep_mode set on a non-sleep engine can't make the router
89+
# mark its instances sleeping. (Low#3)
90+
if not mc.get("enable_sleep_mode") or not engine_sleep_capable(mc.get("engine", "vllm")):
8491
continue
8592
for instance in model_cfg.get("instances", []):
8693
composite = f"{model_key}\x00{instance['id']}"

apps/router-server/src/llm_router/vllm_metrics_client.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,21 @@ def to_dict(self):
8888
}
8989

9090

91+
# Engines that expose vLLM's /sleep + /wake_up (level-1 warm standby that frees VRAM).
92+
# The router only probes /is_sleeping for these — SGLang and llama.cpp have no such
93+
# endpoint, so probing them is wasted, and a config that mistakenly set
94+
# enable_sleep_mode on a non-sleep engine must not make the router treat its instances
95+
# as sleeping. Kept beside METRIC_NAMES_BY_ENGINE so the router's engine→behaviour
96+
# knowledge (which metrics an engine speaks, whether it can sleep) lives in one place
97+
# rather than scattered `engine == "..."` / config-key checks. (Low#3)
98+
ENGINE_SLEEP_CAPABLE = frozenset({"vllm"})
99+
100+
101+
def engine_sleep_capable(engine: str) -> bool:
102+
"""Whether `engine` supports sleep/wake (and thus /is_sleeping is worth probing)."""
103+
return engine in ENGINE_SLEEP_CAPABLE
104+
105+
91106
class VLLMMetricsClient:
92107
# Default (vLLM) names; engine-specific lookups use METRIC_NAMES_BY_ENGINE.
93108
METRIC_NAMES = METRIC_NAMES_BY_ENGINE["vllm"]

0 commit comments

Comments
 (0)