""" model_client.py — the model-independence layer. The engine depends on the `ModelClient` interface, never on a concrete model or URL. Swap models by changing env vars; run tests with no server via MockModel. ARS_FABULA_BACKEND = "server" | "transformers" | "mock" (default: server) ARS_FABULA_BASE_URL = "http://localhost:8110/v1" ARS_FABULA_MODEL = "gemma-12b" (later: "gemma-26b-a4b", "gemma-31b") for the transformers backend: a HF repo id, e.g. "google/gemma-3-4b-it" ARS_FABULA_API_KEY = "not-needed" The "transformers" backend runs the model in-process — built for Hugging Face Spaces / ZeroGPU, where external servers (llama-server, ComfyUI) cannot hold a GPU. GPU time is only granted inside @spaces.GPU calls. """ from __future__ import annotations import os import json import time from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Optional, Callable, Iterator import requests # ── Config ────────────────────────────────────────────────────────────── @dataclass class ModelConfig: backend: str = "server" base_url: str = "http://localhost:8110/v1" model: str = "gemma-4-26b-a4b" api_key: str = "not-needed" temperature: float = 0.8 max_tokens: int = 1024 timeout: int = 120 # Anti-repetition sampling. llama.cpp's OpenAI endpoint honors both. # frequency penalizes tokens by how often they already appeared (n-gram # recycling); presence penalizes any reuse at all (topic recycling). frequency_penalty: float = 0.4 presence_penalty: float = 0.3 @classmethod def from_env(cls) -> "ModelConfig": return cls( backend=os.getenv("ARS_FABULA_BACKEND", "server"), base_url=os.getenv("ARS_FABULA_BASE_URL", "http://localhost:8110/v1"), model=os.getenv("ARS_FABULA_MODEL", "gemma-4-26b-a4b"), api_key=os.getenv("ARS_FABULA_API_KEY", "not-needed"), temperature=float(os.getenv("ARS_FABULA_TEMPERATURE", "0.8")), max_tokens=int(os.getenv("ARS_FABULA_MAX_TOKENS", "1024")), timeout=int(os.getenv("ARS_FABULA_TIMEOUT", "120")), frequency_penalty=float(os.getenv("ARS_FABULA_FREQUENCY_PENALTY", "0.4")), presence_penalty=float(os.getenv("ARS_FABULA_PRESENCE_PENALTY", "0.3")), ) # ── Interface ─────────────────────────────────────────────────────────── class ModelClient(ABC): """Minimal surface the engine needs. Any model that speaks it is swappable.""" @abstractmethod def chat(self, messages: list[dict], tools: Optional[list] = None, **kw) -> dict: """Return an assistant message dict: {"role","content","tool_calls"?}.""" ... def generate(self, messages: list[dict], **kw) -> str: """Convenience: text-only completion.""" return self.chat(messages, **kw).get("content") or "" def stream(self, messages: list[dict], tools: Optional[list] = None, **kw) -> Iterator[str]: """Yield text deltas as they arrive. Default (non-streaming) fallback: produce the whole completion as a single chunk. Real streaming backends override this. Callers buffer the deltas to line-break boundaries, so a one-shot multi-line chunk is split into complete lines just the same. """ yield self.chat(messages, tools, **kw).get("content") or "" def health(self) -> bool: """Check if the backend is reachable. Default False for abstract clients.""" return False # ── Real backend: any OpenAI-compatible llama-server ──────────────────── class LlamaServerClient(ModelClient): def __init__(self, config: Optional[ModelConfig] = None): self.cfg = config or ModelConfig.from_env() def chat(self, messages, tools=None, **kw) -> dict: payload = { "model": kw.get("model", self.cfg.model), "messages": messages, "temperature": kw.get("temperature", self.cfg.temperature), "max_tokens": kw.get("max_tokens", self.cfg.max_tokens), "frequency_penalty": kw.get("frequency_penalty", self.cfg.frequency_penalty), "presence_penalty": kw.get("presence_penalty", self.cfg.presence_penalty), } if tools: payload["tools"] = tools payload["tool_choice"] = kw.get("tool_choice", "auto") resp = requests.post( f"{self.cfg.base_url}/chat/completions", headers={"Authorization": f"Bearer {self.cfg.api_key}", "Content-Type": "application/json"}, json=payload, timeout=self.cfg.timeout, ) resp.raise_for_status() return resp.json()["choices"][0]["message"] def stream(self, messages, tools=None, **kw) -> Iterator[str]: """Stream text deltas via OpenAI-style SSE (stream: true).""" payload = { "model": kw.get("model", self.cfg.model), "messages": messages, "temperature": kw.get("temperature", self.cfg.temperature), "max_tokens": kw.get("max_tokens", self.cfg.max_tokens), "frequency_penalty": kw.get("frequency_penalty", self.cfg.frequency_penalty), "presence_penalty": kw.get("presence_penalty", self.cfg.presence_penalty), "stream": True, } if tools: payload["tools"] = tools payload["tool_choice"] = kw.get("tool_choice", "auto") with requests.post( f"{self.cfg.base_url}/chat/completions", headers={"Authorization": f"Bearer {self.cfg.api_key}", "Content-Type": "application/json"}, json=payload, timeout=self.cfg.timeout, stream=True, ) as resp: resp.raise_for_status() # SSE responses rarely declare a charset, and requests then # defaults text/* to ISO-8859-1 (RFC 2616) — which mangles # multi-byte UTF-8 (— ’ …) into mojibake ("—"). The payload # is JSON, which is always UTF-8; decode it as such. resp.encoding = "utf-8" for raw in resp.iter_lines(decode_unicode=True): if not raw: continue # keep-alive / frame separator if not raw.startswith("data:"): continue data = raw[len("data:"):].strip() if data == "[DONE]": break try: delta = json.loads(data)["choices"][0].get("delta", {}) except (json.JSONDecodeError, KeyError, IndexError): continue piece = delta.get("content") if piece: yield piece def health(self) -> bool: try: base = self.cfg.base_url.rsplit("/v1", 1)[0] return requests.get(f"{base}/health", timeout=5).status_code == 200 except requests.RequestException: return False def server_model(self) -> str: """The model id the SERVER actually has loaded (GET /v1/models). llama-server's `model` payload field is cosmetic — it echoes whatever the client sends — so cfg.model can lie about the real base (e.g. a stale "gemma-12b" default while a 26B-A4B gguf is loaded). This asks the server what it loaded so trace provenance is correct. Cached after the first call; falls back to cfg.model if the endpoint is unreachable. """ cached = getattr(self, "_server_model", None) if cached is not None: return cached try: payload = requests.get(f"{self.cfg.base_url}/models", timeout=5).json() # OpenAI shape: {"data": [{"id": ...}]}. The atomic-fork/ollama # shape: {"models": [{"name"/"model": ...}]}. Accept either. entries = payload.get("data") or payload.get("models") or [] first = entries[0] if entries else {} model_id = first.get("id") or first.get("name") or first.get("model") if model_id: # Strip any directory prefix so the id is the gguf name/alias. # Cache ONLY a successful lookup — a failure (e.g. the server # still loading on the first turn) must not pin the fallback # label for the rest of the process. self._server_model = os.path.basename(str(model_id)) return self._server_model except (requests.RequestException, ValueError, KeyError, IndexError, AttributeError): pass return self.cfg.model # ── Transformers backend: in-process model (HF Spaces / ZeroGPU) ──────── # # On ZeroGPU the model must be loaded and placed on "cuda" at startup # (a CUDA emulation layer makes that legal outside @spaces.GPU); the real # GPU only exists inside the @spaces.GPU-decorated generate call. The # decorator is a no-op off-Spaces, so the same code path runs anywhere # torch + transformers are installed. # HF repo ids tried in order when ARS_FABULA_MODEL isn't itself a repo id. # First choice is Gemma 4 12B in bf16 (~24GB — fits both the Space's 50GB # ephemeral disk and the 48GB ZeroGPU slice; the VN was originally tuned # against gemma-12b locally). The 26B-A4B MoE the dev box runs is NOT # viable on ZeroGPU: its transformers-format repos are unquantized bf16 # (~52GB > the 50GB disk) and no transformers-loadable 4-bit repo exists — # the 26B path is the Docker/T4 Space (llama.cpp GGUF), not this one. # Gemma repos are license-gated: the Space needs an HF_TOKEN secret from # an account that accepted the license, else we fall through to an # ungated small model rather than dropping the whole demo to mock. _HF_MODEL_CANDIDATES = [ ("google/gemma-4-12b-it", ""), ("google/gemma-3-4b-it", ""), ("Qwen/Qwen3-4B-Instruct-2507", ""), ] _HF_STATE: dict = {"model": None, "tokenizer": None, "id": None} try: import spaces as _spaces _gpu_decorator = _spaces.GPU(duration=120) except ImportError: _gpu_decorator = lambda f: f def _hf_load(model_id: Optional[str] = None): """Load the chat model once, module-wide. Returns the loaded repo id or None if nothing could be loaded (no torch, gated repo, etc.).""" if _HF_STATE["model"] is not None: return _HF_STATE["id"] try: import torch from transformers import AutoModelForCausalLM, AutoTokenizer except ImportError as e: print(f"[hf] transformers backend unavailable ({e})") return None if model_id and "/" in model_id: candidates = [(model_id, os.getenv("ARS_FABULA_QUANT", "4bit"))] else: candidates = list(_HF_MODEL_CANDIDATES) for mid, quant in candidates: try: print(f"[hf] loading {mid}" + (f" ({quant})" if quant else "") + "…") tok = AutoTokenizer.from_pretrained(mid) kwargs: dict = {"dtype": torch.bfloat16} if quant == "4bit" and torch.cuda.is_available(): from transformers import BitsAndBytesConfig kwargs["quantization_config"] = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_quant_type="nf4", ) kwargs["device_map"] = "cuda" model = AutoModelForCausalLM.from_pretrained(mid, **kwargs) if "device_map" not in kwargs and torch.cuda.is_available(): model = model.to("cuda") model.eval() _HF_STATE.update(model=model, tokenizer=tok, id=mid) print(f"[hf] {mid} ready on {model.device}") return mid except Exception as e: print(f"[hf] could not load {mid}: {type(e).__name__}: {e}") return None @_gpu_decorator def _hf_generate(messages: list[dict], max_new_tokens: int, temperature: float) -> str: import torch tok, model = _HF_STATE["tokenizer"], _HF_STATE["model"] inputs = tok.apply_chat_template( messages, add_generation_prompt=True, return_tensors="pt", return_dict=True, ).to(model.device) with torch.inference_mode(): out = model.generate( **inputs, max_new_tokens=max_new_tokens, do_sample=temperature > 0, temperature=max(temperature, 1e-3), top_p=0.95, ) return tok.decode(out[0][inputs["input_ids"].shape[-1]:], skip_special_tokens=True) def _hf_generate_worker(model, gen_kwargs): """Run model.generate in a background thread (feeds a TextIteratorStreamer). Kept module-level so the @spaces.GPU generator below can spawn it.""" import torch with torch.inference_mode(): model.generate(**gen_kwargs) @_gpu_decorator def _hf_stream_generate(messages: list[dict], max_new_tokens: int, temperature: float): """Yield text deltas token-by-token. The whole iteration runs inside ONE @spaces.GPU allocation (ZeroGPU holds the GPU for a generator's lifetime), so generate() in the worker thread and our decoding overlap — the engine's line-buffered stream parser turns the deltas into live beats. The previous behaviour (base ModelClient.stream → one giant chunk at the end) made every ZeroGPU turn feel like a long stall; this restores local-style streaming.""" import torch # noqa: F401 (ensures torch is importable before generate) from threading import Thread from transformers import TextIteratorStreamer tok, model = _HF_STATE["tokenizer"], _HF_STATE["model"] inputs = tok.apply_chat_template( messages, add_generation_prompt=True, return_tensors="pt", return_dict=True, ).to(model.device) streamer = TextIteratorStreamer(tok, skip_prompt=True, skip_special_tokens=True) gen_kwargs = dict( **inputs, max_new_tokens=max_new_tokens, do_sample=temperature > 0, temperature=max(temperature, 1e-3), top_p=0.95, streamer=streamer, ) thread = Thread(target=_hf_generate_worker, args=(model, gen_kwargs)) thread.start() for delta in streamer: if delta: yield delta thread.join() class TransformersClient(ModelClient): """In-process HF transformers chat model (the ZeroGPU path).""" def __init__(self, config: Optional[ModelConfig] = None): self.cfg = config or ModelConfig.from_env() self._loaded_id = _hf_load(self.cfg.model) def chat(self, messages, tools=None, **kw) -> dict: if not self.health(): raise RuntimeError("transformers backend has no loaded model") text = _hf_generate( messages, max_new_tokens=kw.get("max_tokens", self.cfg.max_tokens), temperature=kw.get("temperature", self.cfg.temperature), ) return {"role": "assistant", "content": text} def stream(self, messages, tools=None, **kw) -> Iterator[str]: """Token-by-token streaming (overrides the base one-chunk fallback). Used by the engine's run_turn_stream for every interactive turn.""" if not self.health(): raise RuntimeError("transformers backend has no loaded model") yield from _hf_stream_generate( messages, max_new_tokens=kw.get("max_tokens", self.cfg.max_tokens), temperature=kw.get("temperature", self.cfg.temperature), ) def health(self) -> bool: return _HF_STATE["model"] is not None def preload_transformers() -> bool: """Warm the in-process model at app startup (ZeroGPU wants the CUDA placement done at module/startup time, not lazily mid-request).""" cfg = ModelConfig.from_env() if cfg.backend != "transformers": return False return _hf_load(cfg.model) is not None # ── Mock backend: scripted, no server (for unit/CI tests) ─────────────── class MockModel(ModelClient): """Returns programmed responses and records what it was asked. Program with either: MockModel(responses=["text 1", {"role":"assistant","tool_calls":[...]}, ...]) MockModel(handler=lambda messages, tools: "...") # dynamic """ # Scripted output is not real model data — the engine skips trace logging # for mock models so tests and mock fallbacks don't pollute the dataset. is_mock = True def __init__(self, responses: Optional[list] = None, handler: Optional[Callable[[list, Optional[list]], object]] = None): self._queue = list(responses or []) self._handler = handler self.calls: list[dict] = [] # every chat() invocation, for assertions self.default = "The scene holds its breath for a moment, waiting." def chat(self, messages, tools=None, **kw) -> dict: self.calls.append({"messages": messages, "tools": tools, "kw": kw}) if self._handler: out = self._handler(messages, tools) elif self._queue: out = self._queue.pop(0) else: out = self.default if isinstance(out, str): return {"role": "assistant", "content": out} return out # already a message dict (may carry tool_calls) # test helpers def last_prompt_text(self) -> str: """Concatenated content of the most recent call's messages.""" if not self.calls: return "" return "\n".join(str(m.get("content", "")) for m in self.calls[-1]["messages"]) def queue(self, *responses): self._queue.extend(responses) # ── Factory ───────────────────────────────────────────────────────────── def get_model(config: Optional[ModelConfig] = None) -> ModelClient: cfg = config or ModelConfig.from_env() if cfg.backend == "mock": return MockModel() if cfg.backend == "transformers": return TransformersClient(cfg) return LlamaServerClient(cfg) # ── VRAM management — swap the LLM out so the image model can load ────── # # The LLM and the diffusion stack don't fit in VRAM together (e.g. Gemma # 12B IQ4_XS ~6GB on an 8GB card). llama.cpp's llama-server has NO unload # API, so we STOP the server process before a ComfyUI bake and RELAUNCH it # afterward, polling /health before resuming scene turns. # # Default behavior auto-detects a WSL-native atomic-fork llama-server (Gemma # 4 26B-A4B MoE + MTP) or falls back to a WSL→Windows llama-server.exe. # Everything is env-overridable: # ARS_FABULA_LLM_MANAGE = auto (default) | wsl | win | none | ollama # ARS_FABULA_LLM_STOP_CMD = "" # custom stop # ARS_FABULA_LLM_START_CMD = "" # custom start (detached) # ARS_FABULA_LLM_UNLOAD_URL = "http://.../unload" # ARS_FABULA_LLAMA_EXE_WIN = C:\\...\\llama-server.exe # Windows mode # ARS_FABULA_LLAMA_MODEL_WIN = C:\\...\\model.gguf # ARS_FABULA_LLAMA_ARGS = "" # shared # ARS_FABULA_LLAMA_BIN = /path/to/llama-server (WSL) # WSL mode # ARS_FABULA_LLAMA_MODEL = /path/to/model.gguf (WSL) # ARS_FABULA_LLAMA_DRAFTER = /path/to/mtp-drafter.gguf # MTP # ARS_FABULA_LLAMA_LD_PATH = /path/to/lib/dir # LD_LIBRARY_PATH _DEFAULT_LLAMA_EXE_WIN = r"C:\Users\vruizes\llama-cpp-setup\llama-server.exe" _DEFAULT_LLAMA_MODEL_WIN = r"C:\Users\vruizes\Downloads\gemma-4-12b-it-IQ4_XS.gguf" # 'none' reasoning profile from the launcher .bat — fastest, cleanest tool # output for the VN (thinking tokens just add latency to bracket emission). _DEFAULT_LLAMA_ARGS = ("-ngl 99 -fa on -ctk q8_0 -ctv q8_0 -c 16384 " "--reasoning off -np 1 --cache-ram 0 " "--host 0.0.0.0 --port 8110") # ── WSL-native defaults (atomic-llama-cpp-turboquant, Gemma 4 26B-A4B) ── _DEFAULT_LLAMA_BIN_WSL = "/mnt/c/Users/vruizes/atomic-llama-cpp-turboquant/" \ "build/bin/llama-server" _DEFAULT_LLAMA_MODEL_WSL = "/mnt/c/Users/vruizes/Downloads/" \ "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf" _DEFAULT_LLAMA_DRAFTER_WSL = "/mnt/c/Users/vruizes/Downloads/" \ "gemma-4-26B-A4B-it-assistant.Q4_K_M.gguf" _DEFAULT_LLAMA_LD_PATH_WSL = "/mnt/c/Users/vruizes/" \ "atomic-llama-cpp-turboquant/build/bin" # 32K context, CPU MoE offload (GPU can't fit 26B MoE experts), MTP drafter, # no reasoning tokens (they add latency to bracket emission). _DEFAULT_LLAMA_ARGS_WSL = ("--spec-type mtp --draft-block-size 3 " "--draft-max 8 --draft-min 0 " "-ngl 99 -ngld 99 -fa on -ctk q8_0 -ctv q8_0 " "-c 32000 --cpu-moe --reasoning off " "--host 0.0.0.0 --port 8110") def _is_wsl() -> bool: if os.name != "posix": return False try: with open("/proc/version") as f: return "microsoft" in f.read().lower() except OSError: return False def _win_to_wsl(win_path: str) -> str: """C:\\Users\\x → /mnt/c/Users/x (for existence checks from WSL).""" p = win_path.replace("\\", "/") if len(p) > 1 and p[1] == ":": return f"/mnt/{p[0].lower()}{p[2:]}" return p def _llm_manage_mode(cfg: ModelConfig) -> str: mode = os.getenv("ARS_FABULA_LLM_MANAGE", "auto").lower() if mode != "auto": return mode # auto: a custom stop hook wins; then try WSL-native atomic fork, # then Windows .exe via interop, then ollama. if os.getenv("ARS_FABULA_LLM_STOP_CMD"): return "cmd" # Check for WSL-native atomic-fork binary first bin_path = os.getenv("ARS_FABULA_LLAMA_BIN", _DEFAULT_LLAMA_BIN_WSL) if _is_wsl() and os.path.exists(bin_path): return "wsl" exe = os.getenv("ARS_FABULA_LLAMA_EXE_WIN", _DEFAULT_LLAMA_EXE_WIN) if _is_wsl() and os.path.exists(_win_to_wsl(exe)): return "win" if "11434" in cfg.base_url: return "ollama" return "none" def _win_health(cfg: ModelConfig) -> bool: try: base = cfg.base_url.rsplit("/v1", 1)[0] return requests.get(f"{base}/health", timeout=3).status_code == 200 except requests.RequestException: return False def release_vram(config: Optional[ModelConfig] = None) -> bool: """Stop/evict the LLM so ComfyUI can load. Returns True if it acted.""" cfg = config or ModelConfig.from_env() # mock has nothing to stop; transformers is in-process — on ZeroGPU the # GPU is granted per-call and released automatically, so the whole # stop/relaunch swap dance does not apply. if cfg.backend in ("mock", "transformers"): return False mode = _llm_manage_mode(cfg) if mode == "none": return False import subprocess if mode == "cmd" or os.getenv("ARS_FABULA_LLM_STOP_CMD"): cmd = os.getenv("ARS_FABULA_LLM_STOP_CMD") if cmd: try: subprocess.run(cmd, shell=True, timeout=30, check=False) print(f"[vram] LLM stopped via STOP_CMD") return True except Exception as e: print(f"[vram] STOP_CMD failed: {e}") if mode == "win": # Kill the Windows llama-server.exe from WSL via interop for killer in ("taskkill.exe", "/mnt/c/Windows/System32/taskkill.exe"): try: subprocess.run([killer, "/IM", "llama-server.exe", "/F"], timeout=20, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) print("[vram] llama-server.exe stopped (taskkill)") # give the driver a moment to release VRAM time.sleep(2) return True except Exception: continue print("[vram] taskkill unavailable — could not stop llama-server") return False if mode == "wsl": # Kill the WSL-native atomic-fork llama-server (Linux process) try: subprocess.run(["pkill", "-f", "build/bin/llama-server"], timeout=15, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) print("[vram] WSL llama-server stopped (pkill build/bin/llama-server)") time.sleep(2) return True except Exception as e: print(f"[vram] pkill failed: {e}") return False url = os.getenv("ARS_FABULA_LLM_UNLOAD_URL") if url: try: requests.post(url, json={}, timeout=15) print(f"[vram] LLM unload URL hit: {url}") return True except Exception as e: print(f"[vram] unload URL failed: {e}") if mode == "ollama": root = cfg.base_url.rsplit("/v1", 1)[0].rstrip("/") try: if requests.post(f"{root}/api/generate", json={"model": cfg.model, "keep_alive": 0, "prompt": "", "stream": False}, timeout=15).ok: print(f"[vram] LLM unloaded via ollama keep_alive=0") return True except Exception: pass return False def ensure_llm(config: Optional[ModelConfig] = None, timeout: int = 300) -> bool: """Make sure the LLM server is up and answering /health, relaunching it if we manage it. Returns True when healthy, False on give-up.""" cfg = config or ModelConfig.from_env() if cfg.backend == "mock": return False if cfg.backend == "transformers": # In-process model: there is no server to relaunch or poll. return _HF_STATE["model"] is not None or preload_transformers() if _win_health(cfg): return True mode = _llm_manage_mode(cfg) import subprocess started = False start_cmd = os.getenv("ARS_FABULA_LLM_START_CMD") if start_cmd: try: subprocess.Popen(start_cmd, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) started = True print("[vram] LLM starting via START_CMD…") except Exception as e: print(f"[vram] START_CMD failed: {e}") elif mode == "win": exe = os.getenv("ARS_FABULA_LLAMA_EXE_WIN", _DEFAULT_LLAMA_EXE_WIN) model = os.getenv("ARS_FABULA_LLAMA_MODEL_WIN", _DEFAULT_LLAMA_MODEL_WIN) args = os.getenv("ARS_FABULA_LLAMA_ARGS", _DEFAULT_LLAMA_ARGS) # Execute the Windows .exe DIRECTLY via its WSL path — no cmd.exe, # no `start` (whose title arg gets mangled across the WSL boundary). # The exe is a Windows program, so the model path stays a Windows # path; Popen returns immediately and the process keeps running. import shlex exe_wsl = _win_to_wsl(exe) cmd = [exe_wsl, "-m", model] + shlex.split(args) try: subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, start_new_session=True) started = True print(f"[vram] llama-server.exe relaunching (detached): {exe_wsl}") except Exception as e: print(f"[vram] direct launch failed ({e}); trying the headless .bat") # Fallback: run the headless launcher .bat via cmd.exe /c bat = os.getenv("ARS_FABULA_LLAMA_BAT") if bat: for shell in ("cmd.exe", "/mnt/c/Windows/System32/cmd.exe"): try: subprocess.Popen([shell, "/c", bat], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) started = True print("[vram] llama-server relaunching via .bat…") break except Exception: continue elif mode == "wsl": # Launch the WSL-native atomic-fork llama-server (Gemma 4 26B-A4B # MoE + MTP drafter). The binary is a Linux ELF with CUDA deps, so # LD_LIBRARY_PATH must point at its build/bin for libggml*.so etc. bin_path = os.getenv("ARS_FABULA_LLAMA_BIN", _DEFAULT_LLAMA_BIN_WSL) model_path = os.getenv("ARS_FABULA_LLAMA_MODEL", _DEFAULT_LLAMA_MODEL_WSL) drafter_path = os.getenv("ARS_FABULA_LLAMA_DRAFTER", _DEFAULT_LLAMA_DRAFTER_WSL) ld_path = os.getenv("ARS_FABULA_LLAMA_LD_PATH", _DEFAULT_LLAMA_LD_PATH_WSL) args_str = os.getenv("ARS_FABULA_LLAMA_ARGS", _DEFAULT_LLAMA_ARGS_WSL) import shlex cmd = [bin_path, "-m", model_path, "--mtp-head", drafter_path] + shlex.split(args_str) launch_env = os.environ.copy() launch_env["LD_LIBRARY_PATH"] = ( f"{ld_path}:{launch_env.get('LD_LIBRARY_PATH', '')}" ) try: subprocess.Popen(cmd, env=launch_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, start_new_session=True) started = True print(f"[vram] atomic-fork llama-server starting (WSL-native): " f"{bin_path}") except Exception as e: print(f"[vram] WSL launch failed: {e}") if not started: # Nothing we could launch — wait only a short grace period in case # the user is bringing it up by hand, then give up to mock. print("[vram] LLM not running and no launcher configured — " "start llama-server (or set ARS_FABULA_LLM_START_CMD). " "Using mock until it's up.") timeout = 6 # Poll /health until the model finishes loading deadline = time.time() + timeout while time.time() < deadline: if _win_health(cfg): print("[vram] LLM is healthy.") return True time.sleep(2) return _win_health(cfg)