| """
|
| 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
|
|
|
|
|
|
|
|
|
| @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
|
|
|
|
|
|
|
| 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")),
|
| )
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
| 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()
|
|
|
|
|
|
|
|
|
| resp.encoding = "utf-8"
|
| for raw in resp.iter_lines(decode_unicode=True):
|
| if not raw:
|
| continue
|
| 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()
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _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
|
| 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
|
|
|
|
|
|
|
|
|
| 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
|
| """
|
|
|
|
|
| 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] = []
|
| 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
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _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"
|
|
|
|
|
| _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")
|
|
|
|
|
| _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"
|
|
|
|
|
| _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
|
|
|
|
|
| if os.getenv("ARS_FABULA_LLM_STOP_CMD"):
|
| return "cmd"
|
|
|
| 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()
|
|
|
|
|
|
|
| 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":
|
|
|
| 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)")
|
|
|
| time.sleep(2)
|
| return True
|
| except Exception:
|
| continue
|
| print("[vram] taskkill unavailable — could not stop llama-server")
|
| return False
|
|
|
| if mode == "wsl":
|
|
|
| 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":
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
| 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")
|
|
|
| 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":
|
|
|
|
|
|
|
| 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:
|
|
|
|
|
| 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
|
|
|
|
|
| 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)
|
|
|