front: per-model engine_fallback option for an unavailable pin

By default a per-model engine pin is a hard constraint: if the pinned
engine is down/incompatible the request fails (no duplicate on another
card). Add an `engine_fallback` model-config flag (admin form checkbox +
persisted in models.json) that opts into the old behaviour — fall back to
a compatible engine when the pin can't be honoured. A pinned engine
that's merely busy-but-alive is still routed to (queues) in both modes;
fallback only applies when it's actually down or can't serve the model.

Threads pin_fallback through pick_engine; the front reads engine_fallback
via _load_pins/_model_info.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent caa051b4
...@@ -2680,7 +2680,7 @@ async def api_model_configure(request: Request, username: str = Depends(require_ ...@@ -2680,7 +2680,7 @@ async def api_model_configure(request: Request, username: str = Depends(require_
"max_vram", "sdcpp_flash_attn", "sdcpp_diffusion_flash_attn", "vae_tiling", "max_vram", "sdcpp_flash_attn", "sdcpp_diffusion_flash_attn", "vae_tiling",
"component_quantization", "output_crf", "force_vram_update", "component_quantization", "output_crf", "force_vram_update",
"balanced_gpu_percent", "acceleration", "balanced_gpu_percent", "acceleration",
"cache_type_k", "cache_type_v", "turboquant", "engine", "cache_type_k", "cache_type_v", "turboquant", "engine", "engine_fallback",
"quant_backend", "kv_cache_budget_mb", "kv_cache_slots", "mmproj", "quant_backend", "kv_cache_budget_mb", "kv_cache_slots", "mmproj",
"auto_compact", "auto_compact_pct", "auto_compact_strategy"): "auto_compact", "auto_compact_pct", "auto_compact_strategy"):
if key in data: if key in data:
......
...@@ -567,6 +567,11 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson } ...@@ -567,6 +567,11 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
<option value="">Default (auto — by capability)</option> <option value="">Default (auto — by capability)</option>
</select> </select>
<span class="form-hint" style="font-size:11px">Pin this model to a specific engine/card. Overrides the default engine. Only shown when multiple engines are running.</span> <span class="form-hint" style="font-size:11px">Pin this model to a specific engine/card. Overrides the default engine. Only shown when multiple engines are running.</span>
<label style="display:flex;align-items:center;gap:.4rem;margin-top:.4rem;font-size:12px">
<input type="checkbox" id="cfg-engine-fallback" style="width:auto;margin:0">
Fall back to another engine if the pinned one is unavailable
</label>
<span class="form-hint" style="font-size:11px">When off (default), a request fails if the pinned engine is down/busy-unreachable instead of running on a different card.</span>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-top:.75rem"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-top:.75rem">
<div class="form-row" style="margin:0"> <div class="form-row" style="margin:0">
...@@ -3154,6 +3159,7 @@ function openCfgModal(idx, cfgIdx){ ...@@ -3154,6 +3159,7 @@ function openCfgModal(idx, cfgIdx){
document.getElementById('cfg-offload-dir').value = s.offload_dir || _defaultOffloadDir; document.getElementById('cfg-offload-dir').value = s.offload_dir || _defaultOffloadDir;
_applyBackendCfgVisibility(m); _applyBackendCfgVisibility(m);
_populateEnginePin(s.engine || ''); _populateEnginePin(s.engine || '');
document.getElementById('cfg-engine-fallback').checked = !!s.engine_fallback;
document.getElementById('cfg-sysprompt').value = s.system_prompt || ''; document.getElementById('cfg-sysprompt').value = s.system_prompt || '';
document.getElementById('cfg-parser').value = s.parser || (!m.in_config ? _autoDetectParser(m.path) : 'auto'); document.getElementById('cfg-parser').value = s.parser || (!m.in_config ? _autoDetectParser(m.path) : 'auto');
document.getElementById('cfg-tools').checked = !!s.tools_closer_prompt; document.getElementById('cfg-tools').checked = !!s.tools_closer_prompt;
...@@ -3507,6 +3513,7 @@ async function saveModelConfig(){ ...@@ -3507,6 +3513,7 @@ async function saveModelConfig(){
offload_strategy: document.getElementById('cfg-offload-strategy').value, offload_strategy: document.getElementById('cfg-offload-strategy').value,
offload_dir: document.getElementById('cfg-offload-dir').value.trim() || './offload', offload_dir: document.getElementById('cfg-offload-dir').value.trim() || './offload',
engine: document.getElementById('cfg-engine').value.trim() || null, engine: document.getElementById('cfg-engine').value.trim() || null,
engine_fallback: document.getElementById('cfg-engine-fallback').checked,
system_prompt: document.getElementById('cfg-sysprompt').value.trim() || null, system_prompt: document.getElementById('cfg-sysprompt').value.trim() || null,
parser: document.getElementById('cfg-parser').value, parser: document.getElementById('cfg-parser').value,
tools_closer_prompt: document.getElementById('cfg-tools').checked, tools_closer_prompt: document.getElementById('cfg-tools').checked,
......
...@@ -200,7 +200,8 @@ class FrontProxy: ...@@ -200,7 +200,8 @@ class FrontProxy:
engine = _router.pick_engine( engine = _router.pick_engine(
self.registry, path, method, model, self.registry, path, method, model,
required_cap=self._required_cap(path, model), required_cap=self._required_cap(path, model),
default_engine=self.default_engine, pinned=self._pin_for(model)) default_engine=self.default_engine, pinned=self._pin_for(model),
pin_fallback=bool(self._model_info(model).get("engine_fallback")))
if engine is None: if engine is None:
return {"status_code": 503, "headers": {"content-type": "application/json"}, return {"status_code": 503, "headers": {"content-type": "application/json"},
"body": b'{"error":"No engine is ready yet."}'} "body": b'{"error":"No engine is ready yet."}'}
...@@ -260,7 +261,8 @@ class FrontProxy: ...@@ -260,7 +261,8 @@ class FrontProxy:
continue continue
rec = {"engine": (m.get("engine") or "").strip() or None, rec = {"engine": (m.get("engine") or "").strip() or None,
"backend": (m.get("backend") or "").strip() or None, "backend": (m.get("backend") or "").strip() or None,
"path": (m.get("path") or m.get("id") or "").strip() or None} "path": (m.get("path") or m.get("id") or "").strip() or None,
"engine_fallback": bool(m.get("engine_fallback"))}
for field_ in (m.get("path"), m.get("id"), m.get("alias")): for field_ in (m.get("path"), m.get("id"), m.get("alias")):
if not field_: if not field_:
continue continue
...@@ -544,7 +546,8 @@ class FrontProxy: ...@@ -544,7 +546,8 @@ class FrontProxy:
engine = _router.pick_engine( engine = _router.pick_engine(
self.registry, path, method, model, self.registry, path, method, model,
required_cap=self._required_cap(path, model), required_cap=self._required_cap(path, model),
default_engine=self.default_engine, pinned=self._pin_for(model)) default_engine=self.default_engine, pinned=self._pin_for(model),
pin_fallback=bool(self._model_info(model).get("engine_fallback")))
if engine is None: if engine is None:
return JSONResponse( return JSONResponse(
{"error": "No engine is ready yet (still starting/loading)."}, {"error": "No engine is ready yet (still starting/loading)."},
......
...@@ -51,20 +51,24 @@ def is_admin_path(path: str) -> bool: ...@@ -51,20 +51,24 @@ def is_admin_path(path: str) -> bool:
_warned_pins: set = set() _warned_pins: set = set()
def _warn_bad_pin(model, pinned, cap, engine) -> None: def _warn_bad_pin(model, pinned, cap, engine, fallback: bool = False) -> None:
key = (model, pinned) key = (model, pinned, fallback)
if key in _warned_pins: if key in _warned_pins:
return return
_warned_pins.add(key) _warned_pins.add(key)
if engine is None: if engine is None:
reason = f"no engine named/backed '{pinned}' is declared" reason = f"no engine named/backed '{pinned}' is declared"
elif not engine.healthy: elif not engine.is_alive():
reason = f"engine '{pinned}' is not healthy" reason = f"engine '{pinned}' is down"
else: elif not engine.can_serve(cap):
reason = (f"engine '{pinned}' (backend '{engine.backend}') can't serve a " reason = (f"engine '{pinned}' (backend '{engine.backend}') can't serve a "
f"'{cap}' model (capabilities: {sorted(engine.capabilities)})") f"'{cap}' model (capabilities: {sorted(engine.capabilities)})")
else:
reason = f"engine '{pinned}' is unavailable"
tail = ("falling back to another compatible engine (engine_fallback)."
if fallback else "failing the request (the pin is a hard constraint).")
print(f"[front] WARNING: model '{model}' is pinned to '{pinned}' but {reason}; " print(f"[front] WARNING: model '{model}' is pinned to '{pinned}' but {reason}; "
f"falling back to a compatible engine.", flush=True) f"{tail}", flush=True)
def required_capability(model: Optional[str], path: Optional[str] = None, def required_capability(model: Optional[str], path: Optional[str] = None,
...@@ -100,7 +104,8 @@ def required_capability(model: Optional[str], path: Optional[str] = None, ...@@ -100,7 +104,8 @@ def required_capability(model: Optional[str], path: Optional[str] = None,
def pick_engine(registry: EngineRegistry, path: str, method: str, def pick_engine(registry: EngineRegistry, path: str, method: str,
model: Optional[str], required_cap: Optional[str] = None, model: Optional[str], required_cap: Optional[str] = None,
default_engine: Optional[str] = None, default_engine: Optional[str] = None,
pinned: Optional[str] = None) -> Optional[Engine]: pinned: Optional[str] = None,
pin_fallback: bool = False) -> Optional[Engine]:
"""Return the engine to proxy this request to, or None if none are ready. """Return the engine to proxy this request to, or None if none are ready.
Precedence for inference: per-model pin → engine already holding the model → Precedence for inference: per-model pin → engine already holding the model →
...@@ -112,30 +117,31 @@ def pick_engine(registry: EngineRegistry, path: str, method: str, ...@@ -112,30 +117,31 @@ def pick_engine(registry: EngineRegistry, path: str, method: str,
if method.upper() == "POST" and is_inference_path(path): if method.upper() == "POST" and is_inference_path(path):
cap = required_cap cap = required_cap
# 0. The front's precomputed assignment is authoritative — it already folds # 0. Per-model pin (models.json "engine") is a HARD constraint: the model
# in the pin, the default engine, and balanced auto-selection, and is what # runs on that engine or not at all. Route there if it can serve and its
# keeps a model on exactly one engine. Honour it first when it's compatible. # process is alive — even when it's busy (mid-generation → failing health
# polls), so the request queues on its gen-lock rather than spawning a
# duplicate elsewhere with the wrong settings. If the pinned engine is down
# or incompatible, fail (return None → 503) instead of falling back to a
# different engine.
if pinned:
e = registry.by_name(pinned)
if e is not None and e.can_serve(cap) and e.is_alive():
return e
_warn_bad_pin(model, pinned, cap, e, fallback=pin_fallback)
# Default: a hard pin fails rather than running elsewhere. When the
# model opts in (engine_fallback), fall through to pick another engine.
if not pin_fallback:
return None
# 1. The front's precomputed assignment is authoritative — it already folds
# in the default engine and balanced auto-selection, and keeps a model on
# exactly one engine. Honour it first when it's compatible.
if model: if model:
owner = registry.engine_for_assigned(model) owner = registry.engine_for_assigned(model)
if owner is not None and owner.can_serve(cap): if owner is not None and owner.can_serve(cap):
return owner return owner
# 1. Per-model pin (models.json "engine") — only honoured if compatible.
if pinned:
e = registry.by_name(pinned)
if e and e.can_serve(cap):
if e.healthy:
return e
# Pinned engine is busy (mid-generation → failing health polls) but
# its process is alive: route here anyway so the request queues on
# its gen-lock, instead of duplicating a pinned model on another
# engine (which also ignores its configured n_ctx etc.).
if e.is_alive():
return e
# Pin can't be honoured (engine down or incompatible) — say why (once
# per model+engine) instead of silently falling back.
_warn_bad_pin(model, pinned, cap, e)
# 2. Engine that already has the model resident. # 2. Engine that already has the model resident.
if model: if model:
e = registry.engine_for_model(model, cap) e = registry.engine_for_model(model, cap)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment