mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 06:15:58 -10:00
2953 lines
172 KiB
Python
2953 lines
172 KiB
Python
#!/usr/bin/env python3
|
|
"""tools/strata_mcp.py - an MCP server that lets an AI assistant install, start, check and stop Strata.
|
|
|
|
claude mcp add strata -- python /path/to/Strata/tools/strata_mcp.py
|
|
|
|
Add it to Claude Code, Claude Desktop, Cursor, VS Code, Codex ... (docs/MCP_SERVER.md has every client's snippet) and
|
|
ask your AI "install Strata for this PC", "start Strata", "is Strata running?". It speaks MCP (JSON-RPC 2.0, one
|
|
message per line) over stdin/stdout, uses the Python standard library only (it runs before setup made .venv; Python
|
|
3.10 or newer), and offers a fixed set of tools: strata_status, strata_models, strata_install, strata_start,
|
|
strata_stop, strata_logs, strata_benchmark, strata_connect_info.
|
|
|
|
Safety: no tool takes a shell command or a free path. Every argument is checked against setup.py's own choices
|
|
(models, families, contexts ...); the only path argument (install's data_dir) must be the Strata data folder, a folder
|
|
inside the Strata folder, or a new folder named Strata* outside the system folders. Setup and the model run as
|
|
background processes this server starts itself; it keeps their process id and start time in .strata-mcp/ and only
|
|
ever stops a process it started (a Strata started elsewhere, e.g. from its own window, is only asked over its API to
|
|
unload the model).
|
|
|
|
Not to be confused with serve/mcp.py: that is Strata's MCP *client* (the model calling your tools from the chat page).
|
|
This file is an MCP *server* for the AI assistant you already use.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import traceback
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
PROTOCOL = "2025-06-18"
|
|
PROTOCOLS = ("2025-06-18", "2025-03-26", "2024-11-05") # the revisions this server answers in
|
|
VERSION = "0.1.0"
|
|
WIN = os.name == "nt"
|
|
DEFAULT_ROOT = Path(__file__).resolve().parent.parent
|
|
# The desktop app runs this under Strata Control's Python (STRATA_CONTROL_INSTALL=1): the install runs on that Python, makes no
|
|
# .venv and installs no package (setup.py's own CONTROL mode).
|
|
CONTROL = os.environ.get("STRATA_CONTROL_INSTALL") == "1"
|
|
DEFAULT_PORT = 8080
|
|
STATE_DIR = ".strata-mcp" # in the Strata folder (git-ignored)
|
|
|
|
# What setup.py offers, used only when setup.py cannot be imported (the numbers are setup's MODELS / FAMILIES /
|
|
# CONTEXTS; tools/test_strata_mcp.py checks they still match).
|
|
FALLBACK_MODELS = {
|
|
"Q2_0": {"about": "2-bit, the fastest", "download_gb": 66.4, "ram_gb": 48, "arena_gb": 34.0, "families": ("qwen",)},
|
|
"IQ2_XS": {"about": "2-bit i-quant, a little better quality, close in speed", "download_gb": 68.0, "ram_gb": 48,
|
|
"arena_gb": 35.5},
|
|
"IQ3_XXS": {"about": "3-bit i-quant, better quality, slower (more CPU work per token)", "download_gb": 75.8,
|
|
"ram_gb": 60, "arena_gb": 42.9},
|
|
"IQ3_S": {"about": "3.5-bit i-quant, the best quality (matches the full model), the slowest; needs a 64 GB PC "
|
|
"with little else running", "download_gb": 83.6, "ram_gb": 62, "arena_gb": 50.3},
|
|
"IQ1_M": {"about": "the Coder's only size: half the experts, stored like IQ3_S (3.5 bits)", "download_gb": 58.4,
|
|
"ram_gb": 32, "arena_gb": 23.4, "families": ("coder",)},
|
|
"UD-IQ4_XS": {"about": "~4-bit i-quant (Unsloth Dynamic), between IQ3_S and UD-Q4_K_XL in quality; on a PC with "
|
|
"less than ~80 GB of RAM part of its experts are read from the SSD",
|
|
"download_gb": 93.7, "ram_gb": 48, "arena_gb": 59.5, "families": ("unsloth",), "budget": True,
|
|
"vision": True},
|
|
"UD-Q4_K_XL": {"about": "4-bit (Unsloth Dynamic): K-quant experts, between UD-IQ4_XS and UD-Q5_K_XL in quality",
|
|
"download_gb": 111.3, "ram_gb": 48, "arena_gb": 77.0, "families": ("unsloth",), "budget": True,
|
|
"vision": True}, # #967: images allowed (setup warns: untested with this file)
|
|
"UD-Q5_K_XL": {"about": "5-bit (Unsloth Dynamic): Q5_K experts with Q8_0 downs, close to Q8_0 in quality",
|
|
"download_gb": 158.3, "ram_gb": 108, "arena_gb": 98.4, "families": ("unsloth",), "budget": True,
|
|
"vision": True},
|
|
"UD-Q6_K_XL": {"about": "6-bit (Unsloth Dynamic): Q6_K experts with Q8_0 downs",
|
|
"download_gb": 169.2, "ram_gb": 118, "arena_gb": 109.2, "families": ("unsloth",), "budget": True,
|
|
"vision": True},
|
|
"Q8_0": {"about": "8-bit, the reference quality (every expert Q8_0); for a 192 GB PC",
|
|
"download_gb": 188.2, "ram_gb": 136, "arena_gb": 128.3, "families": ("unsloth",), "budget": True,
|
|
"vision": True},
|
|
# round 0214: Unsloth's v3.1 sets (setup.py MODELS; the about lines come with the wording commit)
|
|
"UD-IQ1_M": {"about": "~1-bit (Unsloth Dynamic v3.1), the smallest download", "download_gb": 68.0, "ram_gb": 48, "arena_gb": 34.8, "families": ("unsloth",), "budget": True,
|
|
"vision": True},
|
|
"UD-Q2_0": {"about": "2-bit (Unsloth Dynamic v3.1)", "download_gb": 70.5, "ram_gb": 48, "arena_gb": 37.4, "families": ("unsloth",), "budget": True,
|
|
"vision": True},
|
|
"UD-IQ2_XS": {"about": "2-bit i-quant (Unsloth Dynamic v3.1)", "download_gb": 75.8, "ram_gb": 48, "arena_gb": 42.3, "families": ("unsloth",), "budget": True,
|
|
"vision": True},
|
|
"UD-Q2_K_XL": {"about": "2-bit (Unsloth Dynamic v3.1) with some 3-bit experts", "download_gb": 79.6, "ram_gb": 48, "arena_gb": 46.0, "families": ("unsloth",), "budget": True,
|
|
"vision": True},
|
|
"UD-IQ3_XXS": {"about": "3-bit i-quant (Unsloth Dynamic v3.1)", "download_gb": 83.6, "ram_gb": 48, "arena_gb": 50.3, "families": ("unsloth",), "budget": True,
|
|
"vision": True},
|
|
"UD-Q3_K_XL": {"about": "3-bit (Unsloth Dynamic v3.1) with some 4-bit experts", "download_gb": 89.5, "ram_gb": 48, "arena_gb": 56.2, "families": ("unsloth",), "budget": True,
|
|
"vision": True},
|
|
"UD-IQ4_XS-v31": {"about": "~4-bit i-quant (Unsloth Dynamic v3.1), a newer file than UD-IQ4_XS with other tensor types", "download_gb": 93.7, "ram_gb": 48, "arena_gb": 60.4, "families": ("unsloth",), "budget": True,
|
|
"vision": True},
|
|
"Gyro-S": {"about": "the smallest option, for PCs with little memory (27.6 GB of VRAM + RAM together); lower quality "
|
|
"than the 2-3-bit sizes, and the 2-bit Q2_0 decodes faster",
|
|
"engine": (0, 1, 45, 1),
|
|
"download_gb": 58.5, "ram_gb": 27.6, "arena_gb": 25.8, "families": ("gyro",), "mem_total": True,
|
|
"vision": True},
|
|
"Gyro-M": {"about": "about IQ3_XXS quality with about 20% less GPU memory (35 GB of VRAM + RAM together); decodes "
|
|
"about 16-21% slower than Q2_0 on 12 GB cards",
|
|
"engine": (0, 1, 45, 1),
|
|
"download_gb": 92.0, "ram_gb": 35.0, "arena_gb": 32.1, "families": ("gyro",), "mem_total": True,
|
|
"vision": True},
|
|
"NVFP4": {"about": "4-bit NVFP4 experts, GPTQ (sergqwer's strata-nvfp4 pack); RTX 50 has FP4 tensor cores, other "
|
|
"NVIDIA cards run it at 8-bit precision", "download_gb": 128.0, "ram_gb": 80, "arena_gb": 68.0,
|
|
"families": ("nvfp4",), "vision": True},
|
|
"NVFP4-NVIDIA": {"about": "NVIDIA's own NVFP4 checkpoint (plain rounding), converted by sergqwer; the same size "
|
|
"and speed as NVFP4", "download_gb": 128.0, "ram_gb": 80, "arena_gb": 68.0,
|
|
"families": ("nvfp4",), "vision": True},
|
|
"NVFP4-QAD": {"about": "NVIDIA's FP4, trained for FP4 - slightly faster on short prompts; a 102 GB download "
|
|
"that this PC turns into a Strata pack once; it reads pictures",
|
|
"download_gb": 102.2, "ram_gb": 80, "arena_gb": 68.0, "families": ("nvfp4",), "vision": True},
|
|
}
|
|
FALLBACK_FAMILIES = {
|
|
"qwen": {"title": "Qwen3.8-Flash-Next", "about": "the original model", "tag": ""},
|
|
"swift": {"title": "Swift 1.5", "about": "thinks much shorter (-63% thinking tokens, 1.8x sooner answers by its "
|
|
"authors' numbers)", "tag": "swift-"},
|
|
"coder": {"title": "Qwen3.8-Flash-Next Coder", "about": "half the experts (code, tools, images kept): needs ~32 GB "
|
|
"of RAM, faster; weaker outside coding", "tag": "coder-"},
|
|
"unsloth": {"title": "Qwen3.8-Flash-Next (Unsloth)", "about": "UD-IQ1_M, UD-Q2_0, UD-IQ2_XS, UD-Q2_K_XL, UD-IQ3_XXS, UD-Q3_K_XL, UD-IQ4_XS, UD-IQ4_XS-v31, UD-Q4_K_XL, UD-Q5_K_XL, UD-Q6_K_XL or Q8_0 "
|
|
"(68 to 188 GB downloads): every expert in RAM from "
|
|
"~50 GB (UD-IQ1_M) to ~136 GB (Q8_0) of RAM, else "
|
|
"part of them read from the SSD",
|
|
"tag": "unsloth-", "vision": True},
|
|
"gyro": {"title": "Qwen3.8-Flash-Next (Gyro)", "about": "Gyro-S (27.6 GB of memory) and Gyro-M (35 GB): the smallest "
|
|
"sizes, for PCs with little memory; 58 and 92 GB "
|
|
"downloads (the files include the n-gram table)",
|
|
"tag": "gyro-"},
|
|
"nvfp4": {"title": "Qwen3.8-Flash-Next (NVFP4)", "about": "4-bit NVFP4 experts, NVIDIA cards; a 128 GB download, "
|
|
"~80 GB of RAM to keep every expert in RAM",
|
|
"tag": "nvfp4-"},
|
|
}
|
|
OWN_PATH_ENGINE = (0, 1, 45) # the first engine that runs Qwen3.6-35B-A3B with pictures and parallel requests (setup.py MIN_ENGINE note)
|
|
MEMORY_TIERS = ((62.0, "qwen", "IQ3_S"), (46.0, "qwen", "IQ3_XXS"), (38.0, "swift", "IQ2_XS")) # as setup.py's: RAM + VRAM floors of the owner's 64 / 48 / 40 GB tiers
|
|
# #3425 (owner's rule, same as setup.py's TIER_SYSTEM_RESERVE_GB): the floors count RAM + VRAM less the 5 GB Windows and the system keep
|
|
TIER_SYSTEM_RESERVE_GB = 5.0
|
|
GYRO_ROOM_GB = 4.0 # recommend() suggests a Gyro size only when VRAM + RAM exceed its memory figure by this much
|
|
FALLBACK_CONTEXTS = [8192, 32768, 65536, 131072, 204800, 262144, 393216, 524288]
|
|
BENCH_PROMPT = ("Write a short story (about 300 words) about a lighthouse keeper who finds a message in a bottle. "
|
|
"Plain prose, no title.")
|
|
|
|
if WIN:
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
|
|
_k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
_k32.OpenProcess.restype = wintypes.HANDLE
|
|
_k32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
|
_k32.GetProcessTimes.argtypes = (wintypes.HANDLE, *(ctypes.POINTER(wintypes.FILETIME),) * 4)
|
|
_k32.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
|
|
_k32.TerminateProcess.argtypes = (wintypes.HANDLE, wintypes.UINT)
|
|
_k32.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD)
|
|
_k32.CloseHandle.argtypes = (wintypes.HANDLE,)
|
|
_QUERY, _TERMINATE, _SYNCHRONIZE, _STILL_ACTIVE = 0x1000, 0x0001, 0x00100000, 259
|
|
|
|
class _PROCESSENTRY32W(ctypes.Structure):
|
|
_fields_ = [("dwSize", wintypes.DWORD), ("cntUsage", wintypes.DWORD), ("th32ProcessID", wintypes.DWORD),
|
|
("th32DefaultHeapID", ctypes.c_size_t), ("th32ModuleID", wintypes.DWORD),
|
|
("cntThreads", wintypes.DWORD), ("th32ParentProcessID", wintypes.DWORD),
|
|
("pcPriClassBase", ctypes.c_long), ("dwFlags", wintypes.DWORD), ("szExeFile", ctypes.c_wchar * 260)]
|
|
_k32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
|
|
_k32.CreateToolhelp32Snapshot.argtypes = (wintypes.DWORD, wintypes.DWORD)
|
|
_k32.Process32FirstW.argtypes = (wintypes.HANDLE, ctypes.POINTER(_PROCESSENTRY32W))
|
|
_k32.Process32NextW.argtypes = (wintypes.HANDLE, ctypes.POINTER(_PROCESSENTRY32W))
|
|
CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_BREAKAWAY_FROM_JOB = 0x200, 0x08000000, 0x01000000
|
|
|
|
|
|
def log(msg: str) -> None:
|
|
"""Diagnostics go to stderr: stdout carries only the protocol."""
|
|
try:
|
|
sys.stderr.write(f"[strata-mcp] {msg}\n")
|
|
sys.stderr.flush()
|
|
except (OSError, ValueError):
|
|
pass
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------ processes
|
|
def proc_identity(pid) -> str | None:
|
|
"""A token for a RUNNING process: its start time (so a reused process id is never mistaken for ours), or None
|
|
when no such process runs."""
|
|
try:
|
|
pid = int(pid)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if pid <= 0:
|
|
return None
|
|
if WIN:
|
|
h = _k32.OpenProcess(_QUERY, False, pid)
|
|
if not h:
|
|
return None
|
|
try:
|
|
code = wintypes.DWORD()
|
|
if not _k32.GetExitCodeProcess(h, ctypes.byref(code)) or code.value != _STILL_ACTIVE:
|
|
return None
|
|
c, e, k, u = (wintypes.FILETIME() for _ in range(4))
|
|
if not _k32.GetProcessTimes(h, ctypes.byref(c), ctypes.byref(e), ctypes.byref(k), ctypes.byref(u)):
|
|
return None
|
|
return f"win:{(c.dwHighDateTime << 32) | c.dwLowDateTime}"
|
|
finally:
|
|
_k32.CloseHandle(h)
|
|
stat = Path(f"/proc/{pid}/stat")
|
|
if Path("/proc/self/stat").exists():
|
|
try:
|
|
fields = stat.read_text().rsplit(")", 1)[1].split()
|
|
except (OSError, IndexError):
|
|
return None
|
|
if fields[0] in ("Z", "X"): # a zombie has ended
|
|
return None
|
|
return f"linux:{fields[19]}" # field 22 of stat: the start time in clock ticks
|
|
try: # elsewhere (no /proc): alive, without a start time
|
|
os.kill(pid, 0)
|
|
return "pid"
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def proc_alive(pid, ident) -> bool:
|
|
return bool(ident) and proc_identity(pid) == ident
|
|
|
|
|
|
def proc_terminate(pid, ident) -> bool:
|
|
"""Ask a process WE started to end (POSIX: SIGTERM, the server's clean path; Windows: TerminateProcess). Never
|
|
touches a process whose start time differs from the one recorded."""
|
|
if not proc_alive(pid, ident):
|
|
return False
|
|
if WIN:
|
|
h = _k32.OpenProcess(_TERMINATE | _QUERY, False, int(pid))
|
|
if not h:
|
|
return False
|
|
try:
|
|
return bool(_k32.TerminateProcess(h, 1))
|
|
finally:
|
|
_k32.CloseHandle(h)
|
|
try:
|
|
os.kill(int(pid), signal.SIGTERM)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _rmtree_ignore_missing(func, path, exc_info):
|
|
"""shutil.rmtree error hook: a file that vanished while its folder was being removed is already gone."""
|
|
exc = exc_info[1] if isinstance(exc_info, tuple) else exc_info
|
|
if isinstance(exc, FileNotFoundError):
|
|
return
|
|
raise exc
|
|
|
|
|
|
def proc_children_map() -> dict:
|
|
"""Windows: {parent pid: [child pids]} of every running process (a Toolhelp snapshot); {} elsewhere or when it fails."""
|
|
if not WIN:
|
|
return {}
|
|
snap = _k32.CreateToolhelp32Snapshot(0x2, 0) # TH32CS_SNAPPROCESS
|
|
if not snap or snap == wintypes.HANDLE(-1).value:
|
|
return {}
|
|
kids: dict = {}
|
|
try:
|
|
entry = _PROCESSENTRY32W()
|
|
entry.dwSize = ctypes.sizeof(_PROCESSENTRY32W)
|
|
ok = _k32.Process32FirstW(snap, ctypes.byref(entry))
|
|
while ok:
|
|
kids.setdefault(entry.th32ParentProcessID, []).append(entry.th32ProcessID)
|
|
ok = _k32.Process32NextW(snap, ctypes.byref(entry))
|
|
finally:
|
|
_k32.CloseHandle(snap)
|
|
return kids
|
|
|
|
|
|
def proc_tree_snapshot(pid) -> list:
|
|
"""Every process below `pid` as [(pid, identity)], taken NOW: once a middle process of the tree ends, taskkill /T can no longer reach
|
|
what it started (#3257: a cancelled install left tools/mtp_fetch.py writing). Windows only; [] elsewhere (a process group does that)."""
|
|
kids = proc_children_map()
|
|
out, seen, todo = [], {int(pid)}, [int(pid)]
|
|
while todo:
|
|
for child in kids.get(todo.pop(), []):
|
|
if child in seen:
|
|
continue
|
|
seen.add(child)
|
|
ident = proc_identity(child)
|
|
if ident:
|
|
out.append((child, ident))
|
|
todo.append(child)
|
|
return out
|
|
|
|
|
|
def kill_snapshot(procs) -> None:
|
|
"""End what a proc_tree_snapshot() listed and still runs (the same process: its start time still matches)."""
|
|
for pid, ident in reversed(list(procs)):
|
|
if proc_alive(pid, ident):
|
|
proc_terminate(pid, ident)
|
|
|
|
|
|
def proc_kill_tree(pid, ident) -> bool:
|
|
"""End a process WE started together with the processes it started (setup's pip / cmake, the engine)."""
|
|
if not proc_alive(pid, ident):
|
|
return False
|
|
if WIN:
|
|
tree = proc_tree_snapshot(pid) # before taskkill: it walks parent ids, and a break in the chain hides the rest
|
|
r = subprocess.run(["taskkill", "/PID", str(int(pid)), "/T", "/F"], capture_output=True,
|
|
creationflags=CREATE_NO_WINDOW)
|
|
kill_snapshot(tree)
|
|
return r.returncode == 0 or not proc_alive(pid, ident)
|
|
try:
|
|
os.killpg(int(pid), signal.SIGKILL) # started as a session leader: its group is its own
|
|
return True
|
|
except OSError:
|
|
try:
|
|
os.kill(int(pid), signal.SIGKILL)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def end_group_leftovers(pgid) -> None:
|
|
"""POSIX: after the server WE started (a session leader) is gone, end what is left of its process group - an
|
|
engine it started but could not close (e.g. stopped while loading, before its own SIGTERM handler existed).
|
|
While any member lives, the group id cannot belong to anybody else."""
|
|
if WIN or not pgid:
|
|
return
|
|
for sig, wait in ((signal.SIGTERM, 20.0), (signal.SIGKILL, 5.0)):
|
|
try:
|
|
os.killpg(int(pgid), sig)
|
|
except OSError:
|
|
return # no such group left
|
|
end = time.time() + wait
|
|
while time.time() < end:
|
|
try:
|
|
os.killpg(int(pgid), 0)
|
|
except OSError:
|
|
return
|
|
time.sleep(0.25)
|
|
|
|
|
|
def wait_gone(pid, ident, seconds: float) -> bool:
|
|
end = time.time() + seconds
|
|
while time.time() < end:
|
|
if not proc_alive(pid, ident):
|
|
return True
|
|
time.sleep(0.25)
|
|
return not proc_alive(pid, ident)
|
|
|
|
|
|
def spawn_detached(cmd: list, cwd: Path, log_path: Path, env: dict | None = None) -> subprocess.Popen:
|
|
"""Start a background process whose output goes to log_path. It keeps running when the AI client (and this
|
|
server) ends: its own process group / session, no console window on Windows."""
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(log_path, "ab") as logf:
|
|
kw = dict(cwd=str(cwd), stdin=subprocess.DEVNULL, stdout=logf, stderr=subprocess.STDOUT,
|
|
env=env, close_fds=True)
|
|
if WIN:
|
|
flags = CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
|
|
try: # out of the AI client's job, if it lets us
|
|
return subprocess.Popen(cmd, creationflags=flags | CREATE_BREAKAWAY_FROM_JOB, **kw)
|
|
except OSError:
|
|
return subprocess.Popen(cmd, creationflags=flags, **kw)
|
|
return subprocess.Popen(cmd, start_new_session=True, **kw)
|
|
|
|
|
|
def run_quiet(cmd, timeout=30) -> str:
|
|
"""A command's stdout ('' when it is missing or fails), without a console window flashing up on Windows."""
|
|
try:
|
|
return subprocess.run(cmd, capture_output=True, text=True, errors="replace", timeout=timeout, stdin=subprocess.DEVNULL,
|
|
creationflags=CREATE_NO_WINDOW if WIN else 0).stdout or ""
|
|
except (OSError, subprocess.SubprocessError, ValueError):
|
|
return ""
|
|
|
|
|
|
def read_tail(path: Path, lines: int, max_bytes: int = 512 * 1024) -> list[str]:
|
|
"""The last lines of a log; a progress line rewritten with \\r counts as its last version."""
|
|
try:
|
|
with open(path, "rb") as f:
|
|
f.seek(0, os.SEEK_END)
|
|
size = f.tell()
|
|
f.seek(max(0, size - max_bytes))
|
|
data = f.read().decode("utf-8", "replace")
|
|
except OSError:
|
|
return []
|
|
out = []
|
|
for raw in data.split("\n"):
|
|
part = [p for p in raw.split("\r") if p.strip()]
|
|
if part:
|
|
out.append(part[-1].rstrip())
|
|
return out[-lines:]
|
|
|
|
|
|
def dir_size_gb(path: Path, limit_files: int = 20000) -> float:
|
|
total, n = 0, 0
|
|
for base, _dirs, files in os.walk(path):
|
|
for f in files:
|
|
try:
|
|
total += os.stat(os.path.join(base, f)).st_size
|
|
except OSError:
|
|
pass
|
|
n += 1
|
|
if n >= limit_files:
|
|
return round(total / 1e9, 1)
|
|
return round(total / 1e9, 1)
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------ HTTP
|
|
_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) # localhost: never through a proxy
|
|
|
|
|
|
def http_json(url: str, method="GET", body=None, api_key=None, timeout=3.0):
|
|
"""(status, JSON or None); status 0 when nothing answers."""
|
|
data = json.dumps(body).encode() if body is not None else (b"" if method == "POST" else None)
|
|
req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"})
|
|
if api_key:
|
|
req.add_header("Authorization", f"Bearer {api_key}")
|
|
try:
|
|
with _OPENER.open(req, timeout=timeout) as r:
|
|
raw = r.read()
|
|
status = r.status
|
|
except urllib.error.HTTPError as e:
|
|
raw, status = e.read(), e.code
|
|
except (OSError, ValueError):
|
|
return 0, None
|
|
try:
|
|
return status, json.loads(raw or b"null")
|
|
except ValueError:
|
|
return status, None
|
|
|
|
|
|
def port_free(port: int, host="127.0.0.1") -> bool:
|
|
import socket
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
try:
|
|
s.bind((host, port))
|
|
return True
|
|
except OSError:
|
|
return False
|
|
finally:
|
|
s.close()
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------ validation
|
|
class ToolError(Exception):
|
|
"""A clear message for the AI (and its user): the tool result is an error, the server goes on."""
|
|
|
|
|
|
def check_schema(schema: dict, args) -> dict:
|
|
"""The tools' JSON schemas, enforced: object, known properties only, types, enums, ranges, patterns."""
|
|
if args is None:
|
|
args = {}
|
|
if not isinstance(args, dict):
|
|
raise ToolError("arguments must be a JSON object")
|
|
props = schema.get("properties", {})
|
|
for k in args:
|
|
if k not in props:
|
|
raise ToolError(f"unknown argument {k!r}; this tool takes: {', '.join(props) or 'no arguments'}")
|
|
for k in schema.get("required", []):
|
|
if k not in args:
|
|
raise ToolError(f"missing argument {k!r}")
|
|
for k, v in args.items():
|
|
p = props[k]
|
|
t = p.get("type")
|
|
if t == "integer" and (isinstance(v, bool) or not isinstance(v, int)):
|
|
if isinstance(v, float) and v.is_integer():
|
|
v = args[k] = int(v)
|
|
elif isinstance(v, str) and re.fullmatch(r"\d{1,7}", v.strip()):
|
|
v = args[k] = int(v.strip()) # some clients send numbers as strings
|
|
else:
|
|
raise ToolError(f"{k} must be a whole number")
|
|
if t == "boolean" and not isinstance(v, bool):
|
|
if v in ("true", "false"):
|
|
v = args[k] = v == "true"
|
|
else:
|
|
raise ToolError(f"{k} must be true or false")
|
|
if t == "string":
|
|
if not isinstance(v, str):
|
|
raise ToolError(f"{k} must be a string")
|
|
if len(v) > p.get("maxLength", 300) or "\x00" in v or "\n" in v or "\r" in v:
|
|
raise ToolError(f"{k} is too long or contains control characters")
|
|
if "pattern" in p and not re.fullmatch(p["pattern"], v):
|
|
raise ToolError(f"{k}={v!r} is not valid ({p.get('description', p['pattern'])})")
|
|
if "enum" in p and v not in p["enum"]:
|
|
raise ToolError(f"{k} must be one of: {', '.join(map(str, p['enum']))} (got {v!r})")
|
|
if t == "integer":
|
|
if "minimum" in p and v < p["minimum"] or "maximum" in p and v > p["maximum"]:
|
|
raise ToolError(f"{k} must be between {p.get('minimum')} and {p.get('maximum')} (got {v})")
|
|
return args
|
|
|
|
|
|
def is_inside(path: Path, root: Path) -> bool:
|
|
try:
|
|
path.relative_to(root)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def norm(p) -> Path:
|
|
"""A path to compare: absolute, links and Windows' short names (AI-SER~1) resolved, case folded on Windows."""
|
|
return Path(os.path.normcase(os.path.realpath(os.path.abspath(str(p)))))
|
|
|
|
|
|
def system_folders() -> list[Path]:
|
|
if WIN:
|
|
env = [os.environ.get(k) for k in ("SystemRoot", "windir", "ProgramFiles", "ProgramFiles(x86)", "ProgramData",
|
|
"ProgramW6432")]
|
|
return [norm(p) for p in env if p]
|
|
return [norm(p) for p in ("/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/proc", "/run", "/sbin",
|
|
"/sys", "/usr", "/var", "/snap")]
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------ Strata
|
|
class Strata:
|
|
"""One Strata folder: what is installed there, the PC, and the processes this server started."""
|
|
|
|
def __init__(self, root: Path | str = DEFAULT_ROOT, src: Path | str | None = None):
|
|
self.root = Path(root).resolve()
|
|
# where setup.py, tools/ and serve/ are (the app: inside Strata Control); `root` is where the installs live
|
|
self.src = Path(src).resolve() if src else self.root
|
|
self.state_dir = self.root / STATE_DIR
|
|
self.lock = threading.Lock() # install / start / stop one at a time
|
|
self.children: list[subprocess.Popen] = [] # reaped now and then (no zombies on POSIX)
|
|
self._setup = None
|
|
self._setup_tried = False
|
|
self._hw_cache = None
|
|
# tests: the interpreter that runs setup / the server. The app's Control runs the installer on its own Python.
|
|
self.python_override: str | None = sys.executable if CONTROL else None
|
|
self.health_timeout = 2.0
|
|
|
|
# ---- setup.py's own tables and logic
|
|
def setup_module(self):
|
|
"""setup.py imported as a module (its tables and its pure checks), or None. Its command runner is replaced
|
|
by one without console windows; nothing of its install path runs."""
|
|
if not self._setup_tried:
|
|
self._setup_tried = True
|
|
try:
|
|
if str(self.src) not in sys.path:
|
|
sys.path.insert(0, str(self.src)) # setup.py imports strata_control
|
|
spec = importlib.util.spec_from_file_location("strata_setup_for_mcp", self.src / "setup.py")
|
|
mod = importlib.util.module_from_spec(spec)
|
|
real = sys.stdout
|
|
sys.stdout = sys.stderr # nothing it prints may reach the protocol stream
|
|
try:
|
|
spec.loader.exec_module(mod)
|
|
finally:
|
|
sys.stdout = real
|
|
if all(hasattr(mod, a) for a in ("MODELS", "FAMILIES", "CONTEXTS")):
|
|
mod.out = lambda cmd: run_quiet(cmd, 60)
|
|
self._setup = mod
|
|
except (Exception, SystemExit) as e: # noqa: BLE001 - any failure: the built-in tables
|
|
log(f"setup.py could not be imported ({e}); using the built-in model table")
|
|
return self._setup
|
|
|
|
def tables(self):
|
|
s = self.setup_module()
|
|
if s is not None:
|
|
return s.MODELS, {**s.FAMILIES, **self.own_families()}, list(s.CONTEXTS), "setup.py"
|
|
return FALLBACK_MODELS, FALLBACK_FAMILIES, FALLBACK_CONTEXTS, "built-in table (setup.py not importable)"
|
|
|
|
def own_families(self) -> dict:
|
|
"""setup.py's OWN_PATH_FAMILIES (Qwen3.6-35B-A3B): families with an engine path of their own. They are not in FAMILIES
|
|
(whose entries are Flash-Next files with packs), so the model list used to leave them out and the app's Qwen3.6 page
|
|
showed no versions at all. Each is returned as a family entry (title, about, tag, license) of this server's table."""
|
|
s = self.setup_module()
|
|
out = {}
|
|
for f, d in (getattr(s, "OWN_PATH_FAMILIES", None) or {}).items():
|
|
out[f] = {"title": d["title"], "about": d.get("about"), "tag": d["tag"], "license": d.get("license"),
|
|
"vision": bool(d.get("vision", True)), "own_path": True}
|
|
for f, d in (getattr(s, "MULTI_FILE_FAMILIES", None) or {}).items(): # DeepSeek-V4.1-Flash: nine shards, its own engine
|
|
out[f] = {"title": d["title"], "about": d.get("about"), "tag": d["tag"], "license": d.get("license"),
|
|
"vision": bool(d.get("vision", False)), "own_path": True, "multi_file": True}
|
|
return out
|
|
|
|
def own_size_names(self) -> list:
|
|
"""The size names of the families that have an engine path of their own (Qwen3.6, DeepSeek-V4.1-Flash): the install tool accepts them as `model`."""
|
|
s = self.setup_module()
|
|
names = []
|
|
for table in ("OWN_PATH_FAMILIES", "MULTI_FILE_FAMILIES"):
|
|
for fam in (getattr(s, table, None) or {}).values():
|
|
names.extend(n for n in fam.get("models", {}) if n not in names)
|
|
return names
|
|
|
|
def multi_family(self, family) -> dict | None:
|
|
"""setup.py's MULTI_FILE_FAMILIES entry (DeepSeek-V4.1-Flash), else None."""
|
|
return (getattr(self.setup_module(), "MULTI_FILE_FAMILIES", None) or {}).get(family)
|
|
|
|
def own_size(self, family, model) -> dict | None:
|
|
"""One size of an own-path family as a model-table row for install_plan / model_table (None: not an own-path size).
|
|
The download is the GGUF; the picture reader (mmproj, ~0.9 GB) is counted by the plan when pictures are on."""
|
|
big = self.multi_family(family)
|
|
if big and model in big["models"]:
|
|
# nine shards; the memory the weights need is VRAM + RAM together WITHOUT the n-gram table (it stays on the SSD)
|
|
gb = sum(big["sha256"][n][0] for n in [*big["models"][model]["shards"], *big["models"][model].get("shared", [])]) / 1e9
|
|
return {"about": big["models"][model]["about"], "download_gb": round(gb, 1), "ram_gb": big["models"][model]["ram_gb"],
|
|
"arena_gb": round(float(big["models"][model]["ram_gb"]) - 17.0, 1), "mem_total": True, "vision": bool(big.get("vision", True)),
|
|
"engine": None, "families": (family,), "multi_file": True}
|
|
d = (getattr(self.setup_module(), "OWN_PATH_FAMILIES", None) or {}).get(family)
|
|
if not d or model not in d["models"]:
|
|
return None
|
|
size_gb = d["sha256"][d["file"].format(q=model)][0] / 1e9
|
|
return {"about": d["models"][model]["about"], "download_gb": round(size_gb, 1), "ram_gb": d["models"][model]["ram_gb"],
|
|
# the routed experts are ~93% of the file (setup.own_path_parallel_fits_all); the dense part and buffers ~1 GB more
|
|
"arena_gb": round(size_gb * 0.93, 1), "vision": bool(d.get("vision", True)),
|
|
"engine": OWN_PATH_ENGINE, "families": (family,)}
|
|
|
|
def sizes_of(self, models, family) -> list:
|
|
"""The family's sizes as setup lists them: an experimental one last (never the default)."""
|
|
own = (getattr(self.setup_module(), "OWN_PATH_FAMILIES", None) or {}).get(family) or self.multi_family(family)
|
|
if own:
|
|
return list(own["models"])
|
|
return sorted((m for m in models if family in models[m].get("families", ("qwen", "swift"))),
|
|
key=lambda m: bool(models[m].get("experimental")))
|
|
|
|
# ---- paths
|
|
def settings_path(self) -> Path:
|
|
if os.environ.get("STRATA_SETTINGS_FILE"):
|
|
return Path(os.environ["STRATA_SETTINGS_FILE"])
|
|
if WIN:
|
|
return Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") / "Strata" / "settings.json"
|
|
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "strata" / "settings.json"
|
|
|
|
def data_dir(self) -> Path:
|
|
try:
|
|
s = json.loads(self.settings_path().read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
s = {}
|
|
d = s.get("data_dir") if isinstance(s, dict) else None
|
|
return Path(d) if d else self.root.parent / "Strata-data"
|
|
|
|
def allowed_roots(self) -> list[Path]:
|
|
return [norm(self.root), norm(self.data_dir())]
|
|
|
|
def allowed(self, p) -> bool:
|
|
p = norm(p)
|
|
return any(is_inside(p, r) for r in self.allowed_roots())
|
|
|
|
def venv_python(self) -> Path:
|
|
return self.root / ".venv" / ("Scripts/python.exe" if WIN else "bin/python")
|
|
|
|
def run_python(self) -> str:
|
|
return self.python_override or str(self.venv_python())
|
|
|
|
def state(self, name: str) -> dict:
|
|
try:
|
|
d = json.loads((self.state_dir / f"{name}.json").read_text(encoding="utf-8"))
|
|
return d if isinstance(d, dict) else {}
|
|
except (OSError, ValueError):
|
|
return {}
|
|
|
|
def save_state(self, name: str, d: dict | None) -> None:
|
|
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
f = self.state_dir / f"{name}.json"
|
|
if d is None:
|
|
f.unlink(missing_ok=True)
|
|
else:
|
|
tmp = f.with_suffix(".tmp")
|
|
tmp.write_text(json.dumps(d, indent=1), encoding="utf-8")
|
|
os.replace(tmp, f)
|
|
|
|
def reap(self):
|
|
self.children = [p for p in self.children if p.poll() is None]
|
|
|
|
# ---- what is installed
|
|
def configs(self) -> list[Path]:
|
|
return sorted((p for p in self.root.glob("strata-*.json") if not p.name.endswith(".shared-settings.json")),
|
|
key=lambda p: p.stat().st_mtime, reverse=True) # #346: the Chat settings file is no config
|
|
|
|
@staticmethod
|
|
def read_config(path: Path) -> dict:
|
|
try:
|
|
cfg = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
return cfg if isinstance(cfg, dict) else {}
|
|
except (OSError, ValueError):
|
|
return {}
|
|
|
|
def describe_config(self, path: Path, usage: dict | None = None) -> dict:
|
|
cfg = self.read_config(path)
|
|
a = cfg.get("args") if isinstance(cfg.get("args"), list) else []
|
|
|
|
def val(k):
|
|
return a[a.index(k) + 1] if k in a and a.index(k) + 1 < len(a) else None
|
|
tag = path.stem[len("strata-"):]
|
|
missing = [p for p in [cfg.get("exe"), *[x for x in a if isinstance(x, str) and x.endswith(".gguf")]]
|
|
if not p or not Path(p).exists()]
|
|
gaps = shard_gaps([x for x in a if isinstance(x, str) and x.endswith(".gguf")]) # a model in parts is whole only with every part
|
|
missing += gaps
|
|
script = self.root / f"run-{tag}.{'bat' if WIN else 'sh'}"
|
|
vis = cfg.get("vision")
|
|
own_usage = (usage or self.disk_usage())["models"].get(tag) or {"gb": 0.0, "bytes": 0, "shared_bytes": 0}
|
|
plan = self.removal_plan(path)
|
|
return {"model": tag, "config": path.name, "model_name": cfg.get("model_name"),
|
|
"context": int(val("--max-context")) if str(val("--max-context") or "").isdigit() else None,
|
|
"kv": val("--kv"), "images": ("gpu" if vis.get("gpu") else "cpu") if isinstance(vis, dict)
|
|
else ("on" if cfg.get("arch") == "deepseek41" and str(val("--vision") or "on").lower() not in ("off", "no", "false", "0") else "off"),
|
|
"port": cfg.get("port", DEFAULT_PORT), "host": cfg.get("host", "127.0.0.1"),
|
|
"gpu": cfg.get("gpu"), "gpu_roles": cfg.get("gpu_roles"), "api_key_set": bool(cfg.get("api_key")),
|
|
"low_ram": "--resident-experts" in a or "--mmap-experts" in a,
|
|
"start_script": script.name if script.exists() else None,
|
|
"ready": not missing and bool(cfg), "missing_files": missing[:3], "parts_missing": len(gaps),
|
|
"disk_gb": own_usage["gb"], "disk_bytes": own_usage["bytes"],
|
|
"shared_bytes": own_usage["shared_bytes"],
|
|
# what Delete would remove (files in Strata's own folders that no other model uses); the person's own GGUF folders stay
|
|
"removable_gb": plan["gb"], "kept_files": len(plan["keep"]),
|
|
"last_used": time.strftime("%Y-%m-%d %H:%M", time.localtime(path.stat().st_mtime))}
|
|
|
|
# ---- what a model owns on disk (Delete, and the per-model disk usage in Storage)
|
|
FILE_FLAGS = ("--native", "--ple-gguf", "--embd-gguf", "--native-dense-gguf")
|
|
DIR_FLAGS = ("--pack", "--mtp")
|
|
|
|
def model_paths(self, cfg: dict) -> tuple[list, list]:
|
|
"""(the GGUF files, the folders) a run config names: --native/--ple-gguf/... and --pack/--mtp, plus any other .gguf."""
|
|
a = [x for x in (cfg.get("args") or []) if isinstance(x, str)]
|
|
files = [Path(a[i + 1]) for i, x in enumerate(a[:-1]) if x in self.FILE_FLAGS]
|
|
files += [Path(x) for x in a if x.lower().endswith(".gguf")]
|
|
dirs = [Path(a[i + 1]) for i, x in enumerate(a[:-1]) if x in self.DIR_FLAGS]
|
|
seen, once = set(), []
|
|
for f in files: # one file named by several flags is still one file
|
|
if norm(f) not in seen:
|
|
seen.add(norm(f))
|
|
once.append(f)
|
|
seen_d, once_d = set(), []
|
|
for d in dirs:
|
|
if norm(d) not in seen_d:
|
|
seen_d.add(norm(d))
|
|
once_d.append(d)
|
|
return once, once_d
|
|
|
|
@staticmethod
|
|
def with_shards(files: list) -> list:
|
|
"""The files plus the other parts of every multi-part GGUF among them (name-00001-of-00004.gguf: the config names only the
|
|
first), so deleting a model takes all its parts and never leaves 100 GB behind (#3716)."""
|
|
out, seen = [], set()
|
|
for f in files:
|
|
names = [Path(f)]
|
|
m = SHARD_NAME.match(str(f))
|
|
if m:
|
|
names += [Path(f"{m.group(1)}-{i:05d}-of-{m.group(3)}.gguf") for i in range(1, int(m.group(3)) + 1)]
|
|
for n in names:
|
|
key = norm(n)
|
|
if key not in seen:
|
|
seen.add(key)
|
|
out.append(n)
|
|
return out
|
|
|
|
@staticmethod
|
|
def tree_bytes(path: Path) -> int:
|
|
if path.is_file():
|
|
try:
|
|
return path.stat().st_size
|
|
except OSError:
|
|
return 0
|
|
total = 0
|
|
for base, _d, names in os.walk(path):
|
|
for n in names:
|
|
try:
|
|
total += os.stat(os.path.join(base, n)).st_size
|
|
except OSError:
|
|
pass
|
|
return total
|
|
|
|
@staticmethod
|
|
def file_key(path: Path):
|
|
"""What makes two names the same file on disk: device + inode (hard links), else the resolved path."""
|
|
try:
|
|
st = os.stat(path)
|
|
if st.st_ino:
|
|
return ("ino", st.st_dev, st.st_ino), st.st_size
|
|
return ("path", str(norm(path))), st.st_size
|
|
except OSError:
|
|
return None, 0
|
|
|
|
def used_files(self, cfg: dict) -> dict:
|
|
"""Every file a model really uses, from its run config: all parts of a multi-part GGUF (the config names the first), the
|
|
picture reader, the packs and the draft layer folders. {file identity: bytes}, wherever they live (the Strata folders or the
|
|
person's own)."""
|
|
files, dirs = self.model_paths(cfg)
|
|
paths = list(files)
|
|
vis = cfg.get("vision")
|
|
if isinstance(vis, dict) and isinstance(vis.get("mmproj"), str):
|
|
paths.append(Path(vis["mmproj"]))
|
|
for f in list(paths): # the other parts of a multi-part file
|
|
m = SHARD_NAME.match(str(f))
|
|
if m:
|
|
paths += [Path(f"{m.group(1)}-{i:05d}-of-{m.group(3)}.gguf") for i in range(1, int(m.group(3)) + 1)]
|
|
for d in dirs:
|
|
for base, _sub, names in os.walk(d):
|
|
paths += [Path(base) / n for n in names]
|
|
out = {}
|
|
for p in paths:
|
|
if p.is_file():
|
|
key, size = self.file_key(p)
|
|
if key is not None:
|
|
out[key] = size
|
|
return out
|
|
|
|
def disk_usage(self) -> dict:
|
|
"""What the installed models take on disk, from the real files: each model's own files, the files shared between models
|
|
(the picture reader, the draft layer) counted ONCE, and the total. A model's own figure leaves out the shared files and
|
|
every other model's; rows plus 'shared' add up to the total."""
|
|
per = {c.stem[len("strata-"):]: self.used_files(self.read_config(c)) for c in self.configs()}
|
|
owners: dict = {}
|
|
for files in per.values():
|
|
for k in files:
|
|
owners[k] = owners.get(k, 0) + 1
|
|
models, shared = {}, {}
|
|
for tag, files in per.items():
|
|
own = sum(b for k, b in files.items() if owners[k] == 1)
|
|
models[tag] = {"bytes": own, "gb": round(own / 1e9, 2), "files": len(files),
|
|
"shared_bytes": sum(b for k, b in files.items() if owners[k] > 1)}
|
|
shared.update({k: b for k, b in files.items() if owners[k] > 1})
|
|
shared_bytes = sum(shared.values())
|
|
total = sum(m["bytes"] for m in models.values()) + shared_bytes
|
|
return {"models": models, "shared_bytes": shared_bytes, "shared_gb": round(shared_bytes / 1e9, 2),
|
|
"total_bytes": total, "total_gb": round(total / 1e9, 2)}
|
|
|
|
@staticmethod
|
|
def made_by_strata(p: Path, is_dir: bool) -> bool:
|
|
"""A download or pack Strata wrote itself, even in a models folder the person picked or the app chose: a finished GGUF has its
|
|
.done mark beside it, a prepared pack has its conversion record. The person's own GGUF files have neither (#3716)."""
|
|
try:
|
|
if is_dir:
|
|
return any((p / n).is_file() for n in ("conversions.json", "experts.bin.src.json"))
|
|
return p.with_name(p.name + ".done").is_file()
|
|
except OSError:
|
|
return False
|
|
|
|
def removal_plan(self, config: Path) -> dict:
|
|
"""What deleting this model removes and what it keeps. A file or folder another installed model's config still names
|
|
(the shared MTP draft, a shared PLE shard) is kept; so is anything outside Strata's own folders."""
|
|
cfg = self.read_config(config)
|
|
files, dirs = self.model_paths(cfg)
|
|
files = self.with_shards(files)
|
|
used_files, used_dirs = set(), set()
|
|
for other in self.configs():
|
|
if norm(other) == norm(config):
|
|
continue
|
|
f, d = self.model_paths(self.read_config(other))
|
|
f = self.with_shards(f)
|
|
used_files |= {norm(x) for x in f}
|
|
used_dirs |= {norm(x) for x in d}
|
|
remove, keep = [], []
|
|
|
|
def shared(p: Path, is_dir: bool) -> bool:
|
|
n = norm(p)
|
|
if n in used_files or n in used_dirs or any(is_inside(u, n) for u in used_files | used_dirs) and is_dir:
|
|
return True
|
|
return any(is_inside(n, u) for u in used_dirs)
|
|
|
|
for p, is_dir in [(x, False) for x in files] + [(x, True) for x in dirs]:
|
|
if not p.exists():
|
|
continue
|
|
if not self.allowed(p) and not self.made_by_strata(p, is_dir):
|
|
keep.append({"path": str(p), "reason": "outside Strata's folders"})
|
|
elif shared(p, is_dir):
|
|
keep.append({"path": str(p), "reason": "another installed model still uses it"})
|
|
else:
|
|
remove.append({"path": str(p), "kind": "folder" if is_dir else "file", "bytes": self.tree_bytes(p)})
|
|
if not is_dir:
|
|
for extra in (p.with_name(p.name + ".done"), p.with_name(p.name + ".part"),
|
|
p.with_name(p.name + ".part.chunks")):
|
|
if extra.exists():
|
|
remove.append({"path": str(extra), "kind": "file", "bytes": self.tree_bytes(extra)})
|
|
tag = config.stem[len("strata-"):]
|
|
for extra in (config, config.with_name(config.name + ".bak"), config.with_name(f"strata-{tag}.log"),
|
|
self.root / f"run-{tag}.bat", self.root / f"run-{tag}.sh"):
|
|
if extra.exists():
|
|
remove.append({"path": str(extra), "kind": "file", "bytes": self.tree_bytes(extra)})
|
|
total = sum(x["bytes"] for x in remove)
|
|
return {"model": tag, "remove": remove, "keep": keep, "bytes": total, "gb": round(total / 1e9, 2)}
|
|
|
|
def find_config(self, model: str | None) -> Path:
|
|
have = self.configs()
|
|
if not have:
|
|
raise ToolError("no model is installed in " + str(self.root) + " yet: install one first (strata_install)")
|
|
if not model:
|
|
return have[0] # the most recently used, as setup starts it
|
|
m = model.strip().lower()
|
|
exact = [c for c in have if c.stem[len("strata-"):] == m]
|
|
if exact:
|
|
return exact[0]
|
|
by_name = [c for c in have if str(self.read_config(c).get("model_name", "")).lower() == m]
|
|
if by_name:
|
|
return by_name[0]
|
|
ends = [c for c in have if c.stem.endswith("-" + m)]
|
|
if len(ends) == 1:
|
|
return ends[0]
|
|
raise ToolError(f"no installed model {model!r}; installed: " +
|
|
", ".join(c.stem[len("strata-"):] for c in have))
|
|
|
|
def engine_info(self) -> dict:
|
|
for d in ("engine",):
|
|
try:
|
|
meta = json.loads((self.root / d / "BUILD.json").read_text())
|
|
return {"folder": d, "version": meta.get("version"), "source": meta.get("source"),
|
|
"backend": meta.get("backend")}
|
|
except (OSError, ValueError):
|
|
continue
|
|
return {"folder": None, "version": None}
|
|
|
|
def installed(self) -> dict:
|
|
data = self.data_dir()
|
|
items = {}
|
|
for sub in ("models", "packs"):
|
|
for base in dict.fromkeys([data, self.root]):
|
|
d = base / sub
|
|
if d.is_dir():
|
|
for c in sorted(d.iterdir()):
|
|
if c.is_dir():
|
|
ggufs = list(c.glob("*.gguf"))
|
|
entry = {"folder": str(c), "size_gb": dir_size_gb(c)}
|
|
if sub == "models":
|
|
entry["complete"] = bool(ggufs) and all(
|
|
g.with_name(g.name + ".done").exists() for g in ggufs)
|
|
entry["downloading"] = bool(list(c.glob("*.part")))
|
|
items.setdefault(sub, []).append(entry)
|
|
mtp = data / "mtp" / "rt" / "experts.bin"
|
|
usage = self.disk_usage()
|
|
return {"strata_folder": str(self.root), "data_folder": str(data), "data_folder_exists": data.is_dir(),
|
|
"python_env": self.venv_python().exists(), "engine": self.engine_info(),
|
|
"models": [self.describe_config(c, usage) for c in self.configs()],
|
|
"usage": usage,
|
|
"downloads": items.get("models", []), "prepared": items.get("packs", []),
|
|
"draft_layer": mtp.exists()}
|
|
|
|
# ---- the PC
|
|
def hardware(self, fresh=False) -> dict:
|
|
if self._hw_cache and not fresh and time.time() - self._hw_cache[0] < 600:
|
|
hw = dict(self._hw_cache[1])
|
|
else:
|
|
hw = self._hardware()
|
|
self._hw_cache = (time.time(), hw)
|
|
hw = dict(hw)
|
|
hw["disk_free_gb"] = self.disk_free(self.data_dir())
|
|
return hw
|
|
|
|
@staticmethod
|
|
def disk_free(p: Path) -> float | None:
|
|
p = Path(p)
|
|
while not p.exists() and p.parent != p:
|
|
p = p.parent
|
|
try:
|
|
return round(shutil.disk_usage(p).free / 1e9, 1)
|
|
except OSError:
|
|
return None
|
|
|
|
def _hardware(self) -> dict:
|
|
S = self.setup_module()
|
|
hw = {"os": f"{platform.system()} {platform.release()}", "os_version": platform.version(),
|
|
"machine": platform.machine(), "python": platform.python_version()}
|
|
if not WIN and Path("/proc/version").exists():
|
|
try:
|
|
hw["wsl"] = "microsoft" in Path("/proc/version").read_text().lower()
|
|
except OSError:
|
|
pass
|
|
try:
|
|
hw["ram_gb"] = round(S.ram_gb(), 1) if S else round(own_ram_gb(), 1)
|
|
except Exception: # noqa: BLE001
|
|
hw["ram_gb"] = round(own_ram_gb(), 1)
|
|
if S and WIN:
|
|
try:
|
|
hw["page_file_gb"] = round(S.page_file_gb(), 1)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
try:
|
|
if S:
|
|
name, avx2, avx512 = S.cpu_info()
|
|
else:
|
|
name, avx2, avx512 = platform.processor() or "unknown CPU", None, None
|
|
hw["cpu"] = {"name": name, "cores": physical_cores(), "threads": os.cpu_count(), "avx2": avx2, "avx512": avx512}
|
|
except Exception: # noqa: BLE001
|
|
hw["cpu"] = {"name": platform.processor(), "cores": physical_cores(), "threads": os.cpu_count()}
|
|
if sys.platform == "darwin": # a Mac: one pool of unified memory, the Metal engine (strata_control/mac_fit.py)
|
|
return self._mac_hardware(hw)
|
|
nv = []
|
|
try:
|
|
nv = S.gpus() if S else own_nvidia_gpus()
|
|
except Exception: # noqa: BLE001
|
|
nv = own_nvidia_gpus()
|
|
for g in nv:
|
|
g["vendor"] = "nvidia"
|
|
g["vram_gb"] = round(g["vram_gb"], 1)
|
|
try:
|
|
g["problem"] = S.gpu_problem(g) if S else (None if int(g["arch"]) >= 75 else "older than RTX 20")
|
|
except Exception: # noqa: BLE001
|
|
g["problem"] = None
|
|
g["usable"] = g["problem"] is None
|
|
amd = []
|
|
try: # Linux: the kernel's KFD topology; Windows: the display adapters' PCI ids + registry (setup.amd_gpus_win)
|
|
amd = S.amd_gpus() if S else []
|
|
for g in amd:
|
|
g["vendor"] = "amd"
|
|
g["vram_gb"] = round(g["vram_gb"], 1)
|
|
g["problem"] = S.amd_problem(g)
|
|
g["usable"] = g["problem"] is None
|
|
except Exception: # noqa: BLE001
|
|
amd = []
|
|
if not WIN and not amd and shutil.which("rocm-smi"):
|
|
amd = rocm_smi_gpus()
|
|
if not WIN and not amd: # no setup module, no rocm-smi (ROCm from a Python venv): the kernel's own list
|
|
amd = kfd_gpus()
|
|
# A PC whose only NVIDIA cards are Pascal / Volta (two GTX 1080s) and that has no AMD card Strata can use: setup runs them with the
|
|
# CUDA 12 engine (old_gpus_opt_in), so the app must not call it "no supported graphics card" and start both cards Off (#3655).
|
|
# A PC with a newer card keeps recommending that one; naming an old card is still the opt-in.
|
|
try:
|
|
if S and nv and S.old_gpus_opt_in(nv, (), None, any(g.get("usable") for g in amd)):
|
|
for g in nv:
|
|
if S.sm60_card(g["arch"]) and g.get("problem") and "older than the RTX 20" in str(g["problem"]):
|
|
g["problem"], g["usable"], g["engine_variant"] = None, True, "cuda12"
|
|
except Exception: # noqa: BLE001 - the hardware page never breaks over this
|
|
pass
|
|
if any(g.get("usable") for g in nv):
|
|
for g in amd: # a Ryzen's integrated graphics beside an NVIDIA card: the engine runs on the NVIDIA card
|
|
if g.get("uma") and g.get("usable"):
|
|
g["usable"], g["problem"] = False, "integrated graphics: Strata uses the NVIDIA card in this computer"
|
|
try: # does the card drive a display? (the app keeps less VRAM free on one that does not, #3215)
|
|
mark_display(nv + amd, nvidia_display_active() if nv else {}, windows_desktop_adapters() if WIN and amd else [])
|
|
except Exception: # noqa: BLE001 - unknown stays unknown: the app then keeps its safety default
|
|
pass
|
|
hw["gpus"] = nv + amd
|
|
for g in hw["gpus"]: # each make numbers its cards from 0 (the engine drives one make): a stable id tells two cards apart
|
|
g.setdefault("id", f"{g.get('vendor') or 'gpu'}:{g.get('index')}")
|
|
try: # how fast each card is (SMs x clock): the app and the Terminal pick the Main card by it
|
|
from strata_control import gpu_roles
|
|
gpu_roles.add_speeds(hw["gpus"])
|
|
except Exception: # noqa: BLE001 - unknown speed: the card with the most memory is Main
|
|
pass
|
|
self._engine_code(hw)
|
|
self._engine_vendor(hw)
|
|
if WIN:
|
|
hw["display_adapters"] = windows_video_controllers()
|
|
hw["nvidia_smi"] = bool(shutil.which("nvidia-smi"))
|
|
return hw
|
|
|
|
@staticmethod
|
|
def _engine_code(hw: dict) -> None:
|
|
"""An AMD card the installed engine package has no code for is not one Strata can run on (the engine would stop on it, #3203): flag it
|
|
unusable with the plain reason, so the card lists and "Automatic" leave it out. Only a package that lists its archs speaks here."""
|
|
amd = [g for g in hw.get("gpus", []) if g.get("vendor") == "amd" and g.get("arch")]
|
|
if not amd:
|
|
return
|
|
try:
|
|
from strata_control import engine_component as ec
|
|
rep = ec.status()
|
|
eng = rep.get("engine") or {}
|
|
built = [a for a in eng.get("archs") or [] if str(a).startswith("gfx")]
|
|
if not rep.get("usable") or eng.get("backend") != "hip" or not built:
|
|
return
|
|
except Exception: # noqa: BLE001 - never break the hardware page over this
|
|
return
|
|
for g in amd:
|
|
if g["arch"] not in built and g.get("usable") is not False:
|
|
g["usable"] = False
|
|
g["problem"] = (ec.no_package_note([g["arch"]]) or f"this Strata engine has no code for {g['arch']} (it is built for {', '.join(built)})")
|
|
|
|
@staticmethod
|
|
def _engine_vendor(hw: dict) -> None:
|
|
"""One engine drives the cards of ONE make (CUDA: NVIDIA, HIP: AMD, ...): note which one for this PC (the installed engine's, else the one
|
|
setup fetches first) and, when the other make has the bigger card, the one plain line that says so. The model list below only counts
|
|
the cards this engine can drive; a model is never split across makes (strata_control/gpu_fit.py)."""
|
|
try:
|
|
from strata_control import gpu_fit
|
|
backend = None
|
|
try:
|
|
from strata_control import engine_component as ec
|
|
rep = ec.status()
|
|
backend = (rep.get("engine") or {}).get("backend") if rep.get("usable") else None
|
|
except Exception: # noqa: BLE001
|
|
backend = None
|
|
vendor = gpu_fit.pick_vendor(hw.get("gpus", []), backend)
|
|
hw["engine_vendor"] = vendor
|
|
note = gpu_fit.other_vendor_note(hw.get("gpus", []), vendor)
|
|
if note:
|
|
hw["gpu_note"] = note
|
|
except Exception: # noqa: BLE001 - never break the hardware page over this
|
|
pass
|
|
|
|
@staticmethod
|
|
def engine_gpus(hw: dict | None) -> list:
|
|
"""The usable cards the engine of this PC drives (all usable cards when the make is not known)."""
|
|
hw = hw or {}
|
|
vendor = hw.get("engine_vendor")
|
|
return [g for g in hw.get("gpus", []) if g.get("usable") and (not vendor or not g.get("vendor") or g.get("vendor") == vendor)]
|
|
|
|
# ---- a Mac (Apple Silicon): unified memory, the Metal engine. The rules are strata_control/mac_fit.py's (the same module
|
|
# metal/setup_mac.py uses), so the app, the Terminal and a Mac that builds the engine itself judge a model the same way.
|
|
def mac(self):
|
|
"""strata_control/mac_fit.py, or None when this Strata folder does not carry it."""
|
|
try:
|
|
if str(self.src) not in sys.path:
|
|
sys.path.insert(0, str(self.src))
|
|
from strata_control import mac_fit
|
|
return mac_fit
|
|
except Exception as e: # noqa: BLE001
|
|
log(f"strata_control/mac_fit.py could not be imported ({e})")
|
|
return None
|
|
|
|
def _mac_hardware(self, hw: dict) -> dict:
|
|
mf = self.mac()
|
|
info = mf.mac_info() if mf else {"chip": platform.processor() or "Apple silicon", "ram_gb": hw.get("ram_gb") or 0.0,
|
|
"macos": platform.mac_ver()[0], "arm64": platform.machine() == "arm64", "clt": "x"}
|
|
problems = mf.mac_problems(info, need_compiler=False) if mf else []
|
|
ram = round(float(info.get("ram_gb") or hw.get("ram_gb") or 0.0), 1)
|
|
hw["ram_gb"] = ram
|
|
hw["cpu"] = {"name": info["chip"], "cores": os.cpu_count(), "avx2": None, "avx512": None}
|
|
# The graphics memory IS the RAM here: vram_gb stays 0 so nothing is counted twice (the pages that fit RAM + VRAM add them up).
|
|
hw["gpus"] = [{"index": 0, "name": info["chip"], "vendor": "apple", "vram_gb": 0.0, "unified_gb": ram, "arch": "apple-gpu",
|
|
"problem": problems[0] if problems else None, "usable": not problems}]
|
|
hw["mac"] = {"chip": info["chip"], "macos": info.get("macos"), "unified_gb": ram}
|
|
hw["nvidia_smi"] = False
|
|
return hw
|
|
|
|
def _mac_sizes(self, models: dict, family: str = "qwen") -> list:
|
|
"""The sizes the app offers on a Mac: the GSQ-RCO files of the Qwen family (two shards, no repack), agentionai's Gyro files (one
|
|
file, no repack) and, since round 0214, Unsloth's sets (many shards: setup.py's Mac install repacks them with
|
|
metal/tools/metal_repack.py, as the Terminal's setup does); a set the Metal engine cannot run yet is left out (mac_fit.MAC_MODELS "missing")."""
|
|
mf = self.mac()
|
|
return [m for m in mf.app_sizes(family) if m in models] if mf else []
|
|
|
|
def _mac_verdict(self, model: str, ram: float) -> str:
|
|
mf = self.mac()
|
|
if ram < mf.MIN_STREAM_RAM_GB:
|
|
return "does not fit"
|
|
if not mf.streams(model, ram):
|
|
return "fits"
|
|
speed = mf.STREAM_MEASURED.get(model)
|
|
if speed:
|
|
return (f"part of its experts in memory, the rest read from the SSD (slower: {speed} measured on a 32 GB Mac mini)")
|
|
return "slow here: part of its experts read from the SSD (slower, not measured on a Mac of this size)"
|
|
|
|
def _mac_recommend(self, hw: dict, models: dict, families: dict) -> dict:
|
|
mf = self.mac()
|
|
ram = float(hw.get("ram_gb") or 0)
|
|
usable = [g for g in hw.get("gpus", []) if g.get("usable")]
|
|
if not mf or not usable:
|
|
why = (hw.get("gpus") or [{}])[0].get("problem") or "this Mac cannot run the Metal engine"
|
|
return {"family": None, "model": None, "why": why}
|
|
if ram < mf.MIN_STREAM_RAM_GB:
|
|
return {"family": None, "model": None,
|
|
"why": f"{ram:.0f} GB of unified memory: Strata needs {mf.MIN_STREAM_RAM_GB:.0f} GB or more on a Mac (32 GB or more is what it was tested on)"}
|
|
model, notes = mf.recommend_model(ram)
|
|
notes = [n for n in notes if "UD-Q4_K_XL" not in n] # the app does not offer the Unsloth files yet
|
|
if model not in self._mac_sizes(models):
|
|
model = "Q2_0"
|
|
if mf.streams(model, ram):
|
|
speed = mf.STREAM_MEASURED.get(model)
|
|
why = (f"{ram:.0f} GB of unified memory: no size fits whole, so {model}'s experts are read from the SSD as it answers"
|
|
+ (f" ({speed} measured on a 32 GB Mac mini)" if speed else ""))
|
|
else:
|
|
why = f"{ram:.0f} GB of unified memory: {model} stays in memory (the best size that does)"
|
|
ctx = mf.recommend_context(ram)
|
|
return {"family": "qwen", "model": model, "title": families["qwen"]["title"] + " " + model, "context": ctx,
|
|
"backend": "metal", "gpu": {k: usable[0].get(k) for k in ("index", "name", "vram_gb")},
|
|
"download_gb": models[model]["download_gb"], "why": why, "notes": notes,
|
|
"setup_command": f"./setup.sh --setup --yes --family qwen --model {model} --context {ctx}"}
|
|
|
|
def _mac_model_table(self, hw: dict) -> list:
|
|
models, families, _ctx, _src = self.tables()
|
|
ram = float(hw.get("ram_gb") or 0)
|
|
have = {c.stem[len("strata-"):] for c in self.configs()}
|
|
out = []
|
|
for family in (self.mac().APP_FAMILIES if self.mac() else ("qwen", "gyro")): # Qwen, Unsloth's sets, then Gyro: never the default
|
|
fd = families.get(family)
|
|
if fd is None:
|
|
continue
|
|
sizes = []
|
|
for m in self._mac_sizes(models, family):
|
|
d = models[m]
|
|
tag = (fd.get("tag", "") + m).lower()
|
|
sizes.append({"model": m, "about": d["about"], "download_gb": d["download_gb"], "experimental": False,
|
|
"images": d.get("vision", fd.get("vision", True)) is not False, # the Mac engine carries the picture reader (Metal)
|
|
"ram_needed_gb": d["ram_gb"], "experts_gb": d.get("arena_gb"), "mem_total": False,
|
|
"memory_gb": round(float(d.get("arena_gb") or d["ram_gb"]) + 1.0, 1),
|
|
"on_this_pc": self._mac_verdict(m, ram) if ram else None, "installed": tag in have, "id": tag})
|
|
if sizes:
|
|
out.append({"family": family, "title": fd["title"], "about": fd.get("about"), "experimental": False,
|
|
"images": any(x["images"] for x in sizes), "sizes": sizes})
|
|
ds = self._mac_deepseek(hw, have) # DeepSeek-V4.1-Flash on the Mac (0.2.7.1): after Qwen, never the default
|
|
if ds is not None:
|
|
out.append(ds)
|
|
return out
|
|
|
|
def dsv41_engine_exe(self) -> Path | None:
|
|
"""The installed strata-dsv41 (STRATA_DSV41_EXE, else the newest package in the engine-dsv41 folders of this install), None when there is none; only looked at, never started."""
|
|
from strata_control import dsv41_engine
|
|
own = (os.environ.get("STRATA_DSV41_EXE") or "").strip()
|
|
if own:
|
|
return Path(own) if Path(own).is_file() else None
|
|
roots = [self.root, self.src]
|
|
try:
|
|
S = self.setup_module()
|
|
roots += [Path(S.ROOT), Path(S.home())]
|
|
except Exception: # noqa: BLE001 - the roots above are enough
|
|
pass
|
|
for r in roots:
|
|
found = dsv41_engine.find(Path(r) / dsv41_engine.DIR)
|
|
if found is not None:
|
|
return found
|
|
return None
|
|
|
|
def mac_deepseek_pictures(self) -> bool:
|
|
"""DeepSeek reads pictures on a Mac when the installed engine has the Metal picture tower, or when no engine is installed yet (the first start fetches the newest and setup
|
|
looks again); False for an engine without it."""
|
|
from strata_control import dsv41_engine
|
|
return dsv41_engine.mac_vision_state(self.dsv41_engine_exe()) != "no"
|
|
|
|
def _mac_deepseek(self, hw: dict, have: set) -> dict | None:
|
|
"""DeepSeek-V4.1-Flash for a Mac: the official sets that were run on one (strata_control/dsv41_fit.MAC_SET_PACK), with the Mac fit words.
|
|
None when setup.py has no DeepSeek family (an older Terminal)."""
|
|
big = self.multi_family("deepseek41")
|
|
if not big:
|
|
return None
|
|
from strata_control import dsv41_fit
|
|
mf, ram = self.mac(), float(hw.get("ram_gb") or 0)
|
|
mac_pics = self.mac_deepseek_pictures()
|
|
sizes = []
|
|
for m in big["models"]:
|
|
if m not in dsv41_fit.MAC_SET_PACK:
|
|
continue
|
|
d = self.own_size("deepseek41", m)
|
|
if d is None:
|
|
continue
|
|
tag = (big.get("tag", "") + m).lower()
|
|
sizes.append({"model": m, "about": d["about"], "download_gb": d["download_gb"], "experimental": False,
|
|
"images": mac_pics, "images_reader": "gpu" if mac_pics else "none", # the engine's own picture tower, when the installed engine has it (dsv41_engine.MARK_MAC_VISION): docs/DEEPSEEK_MAC.md
|
|
"ram_needed_gb": d["ram_gb"], "experts_gb": d.get("arena_gb"), "mem_total": True, "multi_file": True,
|
|
"disk_gb": self.dsv41_disk_gb(d), "engine_min": None,
|
|
"memory_gb": round(float(d["ram_gb"]), 1),
|
|
"on_this_pc": ("does not fit: this Mac has too little memory" if ram and ram < mf.MIN_STREAM_RAM_GB
|
|
else self.dsv41_words(hw, m, installed=tag in have)),
|
|
"installed": tag in have, "id": tag, "gpu_options": None})
|
|
if not sizes:
|
|
return None
|
|
return {"family": "deepseek41", "title": big["title"], "about": big.get("about"), "experimental": False,
|
|
"images": False, "sizes": sizes}
|
|
|
|
def recommend(self, hw: dict) -> dict:
|
|
"""setup.py's own rules: the size it preselects (IQ3_XXS from 60 GB of RAM, else the first, Q2_0), the
|
|
Coder below the full model's RAM (setup's RAM table), the low-RAM mode when the GPU makes up for the RAM, and
|
|
the context it preselects for the card's VRAM (32K under 14 GB, 64K under 20 GB, else 128K)."""
|
|
models, families, _ctx, _src = self.tables()
|
|
if hw.get("mac"):
|
|
return self._mac_recommend(hw, models, families)
|
|
S = self.setup_module()
|
|
usable = self.engine_gpus(hw)
|
|
ram = hw.get("ram_gb") or 0
|
|
if not usable:
|
|
why = ("no NVIDIA RTX 20-series-or-newer GPU found (nvidia-smi did not list one)"
|
|
+ ("; the AMD graphics in this PC is not a card Strata supports (an RX 7000 or 9000 series card, or a Ryzen AI Max, is needed)" if WIN and any(
|
|
"amd" in str(a.get("name", "")).lower() or "radeon" in str(a.get("name", "")).lower()
|
|
for a in hw.get("display_adapters", [])) else ""))
|
|
return {"family": None, "model": None, "why": why}
|
|
best = max(usable, key=lambda g: (round(g["vram_gb"]), -g.get("index", 0)))
|
|
vram = best["vram_gb"]
|
|
backend = "hip" if best.get("vendor") == "amd" else "cuda"
|
|
|
|
def low_fits(m):
|
|
try:
|
|
return S.low_ram_fits(m, ram, vram) if S else ram - 6 + max(0.0, vram - 5 - 1.0) >= models[m]["arena_gb"]
|
|
except Exception: # noqa: BLE001
|
|
return False
|
|
notes = []
|
|
# 0.2.0, the owner's memory tiers (README "The other sizes"; setup.py MEMORY_TIERS holds the same floors): RAM + VRAM
|
|
# together. An APU's shared memory is the RAM itself, so only its BIOS carve-out counts as VRAM.
|
|
tvram = float(best.get("dedicated_gb", 0.0)) if best.get("uma") else float(vram)
|
|
tier = next(((f, n) for lo, f, n in MEMORY_TIERS if ram + tvram - TIER_SYSTEM_RESERVE_GB >= lo), None)
|
|
if tier and tier[1] in models and tier[0] in families:
|
|
fam, model = tier
|
|
total = f"{ram:.0f} GB of RAM + {tvram:.0f} GB of VRAM = {ram + tvram:.0f} GB, {TIER_SYSTEM_RESERVE_GB:.0f} GB of it kept for Windows and the system"
|
|
if model == "IQ2_XS":
|
|
why = (f"{total}, the 40 GB tier: Swift 1.5's IQ2_XS (thinks for a shorter time)"
|
|
+ ("; Gyro-M (35 GB) is the other choice there, close to IQ3_XXS in quality but 16-21% slower at "
|
|
"writing answers than Q2_0" if "Gyro-M" in models and best.get("vendor") in ("nvidia", "amd", "intel") else ""))
|
|
elif model == "IQ3_XXS":
|
|
why = f"{total}, the 48 GB tier: IQ3_XXS (better quality, slower)"
|
|
else:
|
|
why = (f"{total}, the 64 GB tier: IQ3_S (matches the full model, the slowest); Swift 1.5's IQ3_S is an "
|
|
"equal choice")
|
|
elif ram >= 60:
|
|
fam, model, why = "qwen", "IQ3_XXS", f"{ram:.0f} GB of RAM: setup's own pick from 60 GB (better quality)"
|
|
elif ram >= models["Q2_0"]["ram_gb"] - 4:
|
|
fam, model, why = "qwen", "Q2_0", f"{ram:.0f} GB of RAM: the full model's fastest size fits"
|
|
elif ram >= models["IQ1_M"]["ram_gb"] - 4:
|
|
fam, model, why = "coder", "IQ1_M", (f"{ram:.0f} GB of RAM: the full model needs ~48 GB; the Coder "
|
|
"(half the experts, best for code) needs ~32 GB")
|
|
elif low_fits("IQ1_M"):
|
|
fam, model, why = "coder", "IQ1_M", (f"{ram:.0f} GB of RAM is below the Coder's 32 GB, but the GPU's "
|
|
f"{vram:.0f} GB make up for it (setup's low-RAM mode: slower)")
|
|
elif "Gyro-S" in models and ram + vram >= models["Gyro-S"]["ram_gb"] + GYRO_ROOM_GB:
|
|
# Gyro (agentionai): only where the sizes above do not fit. Their memory figure (Gyro-S 27.6 GB, Gyro-M 35 GB)
|
|
# is VRAM + RAM together and the system needs room beside it, so GYRO_ROOM_GB more is asked for here.
|
|
# Gyro-M when this PC has that much, else Gyro-S.
|
|
model = "Gyro-M" if ram + vram >= models["Gyro-M"]["ram_gb"] + GYRO_ROOM_GB else "Gyro-S"
|
|
fam, why = "gyro", (f"{ram:.0f} GB of RAM is below the Coder's 32 GB, but RAM + VRAM = {ram + vram:.0f} GB "
|
|
f"holds {model} ({models[model]['ram_gb']} GB of memory in total; the quality is lower "
|
|
"than the 2-3-bit sizes, and it needs the whole download on a fast SSD)")
|
|
else:
|
|
return {"family": None, "model": None, "why": f"{ram:.0f} GB of RAM: Strata needs 32 GB or more "
|
|
"(the smallest model, the Coder, keeps ~23 GB of experts in "
|
|
"RAM)"}
|
|
ctx = 131072 # 128K on every card (owner, 2026-11-01)
|
|
# #3425: the tiers count RAM + VRAM together, but the desktop keeps about 10 GB of the RAM and the card keeps ~6 GB for
|
|
# its dense weights, so a PC at the floor of its tier reads part of the experts from the SSD at every answer. Said
|
|
# plainly beside the pick (the same arithmetic as the app's "This computer" card); the pick itself stays the owner's tier.
|
|
arena = float(models[model].get("arena_gb") or 0)
|
|
spill = arena - max(0.0, tvram - 6.0) - max(0.0, ram - 10.0)
|
|
if arena and spill >= 4.0:
|
|
notes.append(f"about {spill:.0f} GB of {model} would be read from the SSD while answering (this PC's RAM leaves about 10 GB "
|
|
"for the system and the card holds the rest): expect a few tokens per second on a SATA or USB drive, more "
|
|
"on a fast NVMe. A smaller size fits better; it is a recommendation, every size stays a choice")
|
|
if vram < 11:
|
|
notes.append("less than 12 GB of VRAM: it runs, but slowly (most experts stay on the CPU)")
|
|
if hw.get("cpu", {}).get("avx2") is False: # #623: a warning, not a stop (setup compiles for it)
|
|
notes.append("this CPU has no AVX2: EXPERIMENTAL and slow - setup compiles the engine on this PC for the "
|
|
"older CPU (10-20 minutes), and the CPU's share of the experts runs a few times slower")
|
|
if backend == "hip":
|
|
notes.append("AMD (experimental): pictures are on; the reader runs on the CPU under 24 GB of VRAM and on the GPU from 24 GB")
|
|
cmd = (("START-HERE.bat --setup" if WIN else "./setup.sh --setup") +
|
|
f" --yes --family {fam} --model {model} --context {ctx}" + (" --backend hip" if backend == "hip" else ""))
|
|
return {"family": fam, "model": model, "title": families[fam]["title"] + " " + model, "context": ctx,
|
|
"backend": backend, "gpu": {k: best.get(k) for k in ("index", "name", "vram_gb")},
|
|
"download_gb": models[model]["download_gb"], "why": why, "notes": notes, "setup_command": cmd}
|
|
|
|
def model_table(self, hw: dict | None) -> list:
|
|
if (hw or {}).get("mac"):
|
|
return self._mac_model_table(hw)
|
|
models, families, _ctx, _src = self.tables()
|
|
S = self.setup_module()
|
|
ram = (hw or {}).get("ram_gb")
|
|
usable = self.engine_gpus(hw)
|
|
vram = max((g["vram_gb"] for g in usable), default=0.0)
|
|
have = {c.stem[len("strata-"):] for c in self.configs()}
|
|
out = []
|
|
for f, fd in families.items():
|
|
sizes = []
|
|
for m in self.sizes_of(models, f):
|
|
if f == "deepseek41" and self.dsv41_not_here(m): # the Unsloth 1-bit sets on a DGX Spark: not offered until a Spark run passes
|
|
continue
|
|
own = self.own_size(f, m)
|
|
d = own or models[m]
|
|
tag = (fd.get("tag", "") + m).lower()
|
|
verdict = None
|
|
if ram is not None:
|
|
have_gb = ram + vram if d.get("mem_total") else ram # Gyro: VRAM and RAM together
|
|
verdict = "fits" if have_gb >= d["ram_gb"] else "tight" if have_gb >= d["ram_gb"] - 8 else "does not fit"
|
|
try:
|
|
if own and own.get("multi_file"): # DeepSeek-V4.1-Flash: strata_control/dsv41_fit.py (cards + RAM, the n-gram table on the SSD)
|
|
verdict = self.dsv41_words(hw, m, installed=tag in have)
|
|
elif own: # its own engine path: no budget or low-RAM mode of Flash-Next's to judge by
|
|
pass
|
|
elif d.get("budget") and S and hasattr(S, "unsloth_budget") and not S.unsloth_budget(m, ram):
|
|
verdict = ("experimental: " if d.get("experimental") else "") + "fits (all of its experts in RAM)"
|
|
elif d.get("budget"):
|
|
verdict = (("experimental: " if d.get("experimental") else "")
|
|
+ "part of its experts in RAM, the rest read from the SSD"
|
|
if ram >= d["ram_gb"] else
|
|
"slow here: part of its experts in RAM, the rest read from the SSD (setup "
|
|
"asks first)" if ram >= 48 else "does not fit")
|
|
elif not d.get("mem_total") and S and S.low_ram_needed(m, ram) and S.low_ram_fits(m, ram, vram):
|
|
verdict = "fits in the low-RAM mode (slower: the GPU holds part of the experts)"
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
sizes.append({"model": m, "about": d["about"], "download_gb": d["download_gb"],
|
|
"experimental": bool(d.get("experimental")),
|
|
"images": d.get("vision", fd.get("vision", True)) is not False,
|
|
# which picture reader "pictures on" gets on this PC (strata_control/vision_reader.py, the one rule: the CPU reader
|
|
# under 24 GB of graphics memory, the GPU reader from 24 GB; the person can switch it per model)
|
|
"images_reader": self.images_reader(vram),
|
|
"ram_needed_gb": d["ram_gb"], "experts_gb": d.get("arena_gb"),
|
|
# RAM + VRAM the weights need together, WITHOUT the n-gram table (that stays on the SSD): Gyro states it
|
|
# (mem_total), for the rest the experts plus ~1 GB for the dense part (Swift IQ2_XS: 35.5 + 1 = 36.5)
|
|
"mem_total": bool(d.get("mem_total")), "multi_file": bool(own and own.get("multi_file")),
|
|
# DeepSeek only: the free disk the set needs, from the set itself (the download: model files, n-gram table, drafter and
|
|
# metadata, plus the pack folder and room to work, strata_control/dsv41_fit.DISK_HEADROOM_GB): 2.00 and 2.25 differ
|
|
"disk_gb": self.dsv41_disk_gb(d) if own and own.get("multi_file") else None,
|
|
# the first engine that reads this size's files (setup.py "engine"); the app says "needs a newer engine"
|
|
# with the update step when the installed one is older. None: any engine that setup accepts.
|
|
"engine_min": ".".join(map(str, d["engine"])) if d.get("engine") else None,
|
|
"memory_gb": round(float(d["ram_gb"]) if d.get("mem_total") else float(d.get("arena_gb") or d["ram_gb"]) + 1.0, 1),
|
|
"on_this_pc": verdict, "installed": tag in have, "id": tag,
|
|
# DeepSeek only: does the engine's `--experts auto` give 5 here? (strata_control/dsv41_fit.py rdna4_faster: one RDNA4 card, or
|
|
# several holding half of the experts); the app's Speed label says so. None: the app's own rules (16 GiB, integrated) decide.
|
|
"rdna4_faster": self.dsv41_rdna4_faster(hw, m) if own and own.get("multi_file") else None,
|
|
# which graphics cards to use for it (strata_control/gpu_fit.py, the one rule app / Terminal / setup share):
|
|
# only with two or more cards that count; "recommended" names the pre-selected option
|
|
"gpu_options": (self.dsv41_options(hw, m) if own and own.get("multi_file")
|
|
else None if own else self.gpu_options(d, ram, hw))}) # Qwen3.6 runs on one card so far
|
|
out.append({"family": f, "title": fd["title"], "about": fd.get("about"),
|
|
"experimental": bool(fd.get("experimental")),
|
|
"images": any(x["images"] for x in sizes) if sizes else fd.get("vision", True) is not False,
|
|
"sizes": sizes})
|
|
return out
|
|
|
|
@staticmethod
|
|
def dsv41_not_on_mac() -> tuple:
|
|
"""The DeepSeek sets the Metal engine cannot run yet (IQ1_S experts): strata_control/dsv41_fit.MAC_NOT_YET."""
|
|
from strata_control import dsv41_fit
|
|
return dsv41_fit.MAC_NOT_YET
|
|
|
|
@staticmethod
|
|
def dsv41_not_here(model: str) -> str | None:
|
|
"""The plain line when this computer (a Mac, a DGX Spark) is not offered `model`, else None: strata_control/dsv41_fit.not_here."""
|
|
from strata_control import dsv41_fit
|
|
return dsv41_fit.not_here(model)
|
|
|
|
@staticmethod
|
|
def dsv41_not_on_mac_line() -> str:
|
|
from strata_control import dsv41_fit
|
|
return dsv41_fit.MAC_NOT_YET_LINE
|
|
|
|
@staticmethod
|
|
def dsv41_disk_gb(d: dict) -> float:
|
|
"""The free disk a DeepSeek set needs: its whole download plus the pack and room to work (the card text says "about")."""
|
|
from strata_control import dsv41_fit
|
|
return round(float(d["download_gb"]) + dsv41_fit.DISK_HEADROOM_GB, 1)
|
|
|
|
def dsv41_plan(self, hw, disk_free=None, only=None, model=None) -> dict:
|
|
"""strata_control/dsv41_fit.plan for this computer; `only`: the card numbers a launch names (else every card that counts)."""
|
|
from strata_control import dsv41_fit
|
|
cards = list((hw or {}).get("gpus", []))
|
|
if only:
|
|
cards = [c for c in cards if int(c.get("index", -1)) in {int(i) for i in only}]
|
|
return dsv41_fit.plan(cards, (hw or {}).get("ram_gb"), disk_free, vendor=(hw or {}).get("engine_vendor"), model=model, named=bool(only))
|
|
|
|
def dsv41_words(self, hw, model=None, installed: bool = False) -> str:
|
|
"""The catalog's `on_this_pc` text for DeepSeek-V4.1-Flash, with the disk it needs (download and preparation) beside the memory: a set that
|
|
fits in memory but not on the disk is "tight", never refused (strata_cli/launch/catalog.verdict reads its first words)."""
|
|
words = self._dsv41_memory_words(hw, model)
|
|
free = (hw or {}).get("disk_free_gb")
|
|
if installed or free is None or words.startswith("does not fit"):
|
|
return words
|
|
pl = self.dsv41_plan(hw, free, model=model)
|
|
from strata_control import dsv41_fit
|
|
left = self.dsv41_left_gb(model, pl["download_gb"]) # only what still has to be downloaded (files already here count as done)
|
|
need = left + dsv41_fit.DISK_HEADROOM_GB # + the pack (MBs) and room to work
|
|
if left > 0.05 and free < need:
|
|
return (f"tight: it needs about {need:.0f} GB of free disk space (the rest of the download and its preparation) and this computer has "
|
|
f"{free:.0f} GB free; free some space or choose another drive when you install it ({words})")
|
|
return words
|
|
|
|
def dsv41_left_gb(self, model, default: float) -> float:
|
|
"""GB of this DeepSeek set's files still to be downloaded: the files already in Strata's models folder (the right size; the first release's
|
|
names count too) are done, so the disk check asks only for the rest. Files the person keeps elsewhere (--model-path) are handled by the plan.
|
|
`default`: what to answer when the tables cannot be read (the whole download)."""
|
|
S = self.setup_module()
|
|
try:
|
|
fam = S.MULTI_FILE_FAMILIES["deepseek41"]
|
|
md = fam["models"][model]
|
|
models = self.data_dir() / "models"
|
|
folder, shared = models / (fam["tag"] + model), models / (fam["tag"] + "shared")
|
|
left = 0
|
|
for where, names in ((folder, md["shards"]), (shared, md.get("shared", []))):
|
|
for n in names:
|
|
for cand in (n, getattr(S, "DSV41_LEGACY_NAMES", {}).get(n)):
|
|
if cand and (where / cand).is_file() and (where / cand).stat().st_size == S.DSV41_ALL_FILES[cand][0]:
|
|
break
|
|
else:
|
|
left += S.DSV41_ALL_FILES[n][0]
|
|
return left / 1e9
|
|
except Exception: # noqa: BLE001 - a hint for the menu: when unsure, count the whole download
|
|
return default
|
|
|
|
def _dsv41_memory_words(self, hw, model=None) -> str:
|
|
pl = self.dsv41_plan(hw, model=model)
|
|
v = pl["verdict"]
|
|
if v == "no_card":
|
|
return "does not fit: it needs one or more graphics cards"
|
|
if pl.get("mac"): # an Apple Silicon Mac (0.2.7.1): one pool of unified memory, the rest of the experts streams from the SSD
|
|
streamed, kept = pl["streamed_gb"], pl["vram_experts_gb"]
|
|
if pl["mac_verdict"] == "fits":
|
|
return f"fits: every expert stays in this Mac's memory ({pl['experts_gb']:.0f} GB)"
|
|
if pl["mac_verdict"] == "small":
|
|
return (f"slow here: this Mac's memory holds about {kept:.0f} GB of the {pl['experts_gb']:.0f} GB of experts and the other "
|
|
f"{streamed:.0f} GB are read from the SSD for every word; it can still be installed (nothing this small was measured, "
|
|
"a Mac with 64 GB or more suits it better)")
|
|
return (f"runs on this Mac's memory: about {kept:.0f} GB of the {pl['experts_gb']:.0f} GB of experts stay in memory and the other "
|
|
f"{streamed:.0f} GB are read from the SSD while it answers; measured 12-19 tokens a second on an M5 Max with 128 GB")
|
|
if pl.get("uma"): # Ryzen AI Max (Strix Halo, Windows): the graphics memory is the RAM, counted once
|
|
return (f"runs on this PC's shared memory: about {pl['vram_experts_gb']:.0f} GB of the {pl['experts_gb']:.0f} GB of experts stay "
|
|
f"in memory and the other {pl['streamed_gb']:.0f} GB are read from the SSD; measured 14-16 tokens a second on code and "
|
|
"10-11 on chat")
|
|
if pl.get("variant"): # an official ISTA-DAS set (strata_control/dsv41_fit.py): measured on 46-64 GB, never a refusal
|
|
own = ((__import__("strata_control.dsv41_fit", fromlist=["x"]).variant(pl["variant"]) or {}))
|
|
if v == "spills" and own.get("spill_short"):
|
|
return own["spill_short"].format(ram=pl["ram_gb"])
|
|
if v == "tight" and own.get("tight_short"):
|
|
return own["tight_short"]
|
|
if v == "spills":
|
|
return (f"slow here: this computer has {pl['ram_gb']:.0f} GB of RAM and the set was measured with 46-64 GB, so most of its "
|
|
"experts are read from the SSD; it can still be installed"
|
|
+ ("" if pl["variant"] == "IST-DAS-1.75bpw" else " (the smaller 1.75 bpw version suits this computer better)"))
|
|
if v == "tight":
|
|
return ("tight: runs, with the experts RAM cannot hold read from the SSD (about 5-7 tokens a second measured on 46-64 GB "
|
|
"of RAM and a 12 GB card)")
|
|
if v == "fits":
|
|
return (f"fits: {pl['experts_in_vram']:,} of {pl['experts_total']:,} experts in graphics memory, the rest cached in RAM "
|
|
f"({pl['ram_needed_gb']:.0f} GB)")
|
|
return "not checked (this computer's memory could not be read)"
|
|
if v == "spills" and pl["spill_gb"] >= 100: # most of the CPU's experts would come from the disk every word
|
|
return (f"does not fit: about {pl['spill_gb']:.0f} GB of its experts would be read from the SSD while it answers "
|
|
f"(slow: it needs ~{pl['ram_needed_gb']:.0f} GB of RAM beside {pl['vram_experts_gb']:.0f} GB of graphics memory; "
|
|
"it can still be installed)")
|
|
if v == "spills":
|
|
return (f"part of its experts are read from the SSD (slow here: needs ~{pl['ram_needed_gb']:.0f} GB of RAM beside "
|
|
f"{pl['vram_experts_gb']:.0f} GB of graphics memory; it can still be installed)")
|
|
if v == "tight":
|
|
return f"tight: RAM is close to the {pl['ram_needed_gb']:.0f} GB the CPU's share of the experts needs"
|
|
if v == "fits":
|
|
return (f"fits: {pl['experts_in_vram']:,} of {pl['experts_total']:,} experts in graphics memory, the rest on the CPU "
|
|
f"({pl['ram_needed_gb']:.0f} GB of RAM)")
|
|
return "not checked (this computer's memory could not be read)"
|
|
|
|
@staticmethod
|
|
def dsv41_rdna4_faster(hw, model) -> bool | None:
|
|
from strata_control import dsv41_fit
|
|
try:
|
|
return dsv41_fit.rdna4_faster(list((hw or {}).get("gpus", [])), model, (hw or {}).get("engine_vendor"))
|
|
except Exception: # noqa: BLE001 - a convenience for the label: never break the model list
|
|
return None
|
|
|
|
def dsv41_options(self, hw, model=None) -> dict | None:
|
|
"""gpu_fit-shaped card choices for DeepSeek-V4.1-Flash: every card (recommended: the model is bigger than any one card), or the
|
|
largest alone; each with the placement words."""
|
|
from strata_control import dsv41_fit
|
|
try:
|
|
return dsv41_fit.options(list((hw or {}).get("gpus", [])), (hw or {}).get("ram_gb"), (hw or {}).get("engine_vendor"), model=model)
|
|
except Exception: # noqa: BLE001 - the choice is a convenience: never break the model list
|
|
return None
|
|
|
|
@staticmethod
|
|
def images_reader(vram) -> str:
|
|
try:
|
|
from strata_control import vision_reader
|
|
return vision_reader.default_mode(vram) if vram else "cpu"
|
|
except Exception: # noqa: BLE001
|
|
return "cpu"
|
|
|
|
@staticmethod
|
|
def gpu_options(d: dict, ram, hw) -> dict | None:
|
|
try:
|
|
from strata_control import gpu_fit
|
|
mem = float(d["ram_gb"]) if d.get("mem_total") else float(d.get("arena_gb") or d["ram_gb"]) + 1.0
|
|
res = gpu_fit.options(round(mem, 1), ram, (hw or {}).get("gpus", []), (hw or {}).get("engine_vendor"))
|
|
if len(res["options"]) >= 2:
|
|
return res
|
|
# one card the engine can drive: no choice to offer, but a bigger card of the other make is still worth one plain line
|
|
return {"options": [], "recommended": None, "other_vendor_note": res.get("other_vendor_note")} if res.get("other_vendor_note") else None
|
|
except Exception: # noqa: BLE001 - the choice is a convenience: never break the model list
|
|
return None
|
|
|
|
# ---- the running server
|
|
def ports(self, port: int | None) -> list[int]:
|
|
if port:
|
|
return [port]
|
|
ps = [self.state("server").get("port")] + [self.describe_config(c)["port"] for c in self.configs()]
|
|
return list(dict.fromkeys(int(p) for p in [*ps, DEFAULT_PORT] if isinstance(p, int)))
|
|
|
|
def api_key_for(self, port: int) -> str | None:
|
|
"""The key a config of this folder set for that port (used for our own requests; never shown)."""
|
|
def real(cfg): # "api_key": "none" = served without a key on purpose
|
|
k = cfg.get("api_key")
|
|
return k if k and str(k).strip().lower() != "none" else None
|
|
for c in self.configs():
|
|
cfg = self.read_config(c)
|
|
if real(cfg) and int(cfg.get("port", DEFAULT_PORT)) == port:
|
|
return cfg["api_key"]
|
|
for c in self.configs():
|
|
if real(self.read_config(c)):
|
|
return self.read_config(c)["api_key"]
|
|
return None
|
|
|
|
def probe(self, port: int, deep=True) -> dict | None:
|
|
"""What answers on 127.0.0.1:port: a Strata server's facts, {"other": True} for something else, None for
|
|
nothing."""
|
|
base = f"http://127.0.0.1:{port}"
|
|
st, health = http_json(base + "/health", timeout=self.health_timeout)
|
|
if st == 0:
|
|
return None
|
|
if st != 200 or not isinstance(health, dict) or health.get("service") != "strata":
|
|
return {"port": port, "other": True, "note": "something else answers on this port (not a Strata server)"}
|
|
info = {"port": port, "url": base, "model": health.get("model"), "context": health.get("max_context"),
|
|
"images": health.get("images"), "loaded": health.get("loaded"),
|
|
"api_key_required": bool(health.get("api_key"))}
|
|
if not deep:
|
|
return info
|
|
key = self.api_key_for(port)
|
|
st, v1 = http_json(base + "/v1/status", api_key=key, timeout=self.health_timeout)
|
|
if st == 200 and isinstance(v1, dict):
|
|
info["engine_version"] = v1.get("engine")
|
|
info["uptime_s"] = v1.get("uptime_s")
|
|
info["requests_served"] = (v1.get("activity") or {}).get("requests")
|
|
m = v1.get("machine") or {}
|
|
if m.get("gpu"):
|
|
info["gpu"] = m["gpu"]
|
|
if m.get("ram"):
|
|
info["ram"] = m["ram"]
|
|
if v1.get("last_timings"):
|
|
lt = v1["last_timings"]
|
|
info["last_request"] = {k: lt.get(k) for k in ("prompt_n", "prompt_per_second", "predicted_n",
|
|
"predicted_per_second") if k in lt}
|
|
elif st == 401:
|
|
info["note"] = "the server wants an API key this folder's configs do not have"
|
|
st, s = http_json(base + "/status", api_key=key, timeout=self.health_timeout)
|
|
if st == 200 and isinstance(s, dict):
|
|
info["busy"] = bool(s.get("busy"))
|
|
info["activity"] = {k: s.get(k) for k in ("phase", "generated", "tokens_per_s", "queued", "elapsed_s")
|
|
if s.get(k) is not None}
|
|
if "gpu" not in info: # older servers: the Monitor's numbers
|
|
st, met = http_json(base + "/metrics", api_key=key, timeout=self.health_timeout)
|
|
if st == 200 and isinstance(met, dict):
|
|
hwn = met.get("hardware") or {}
|
|
if hwn.get("gpu_mem_total"):
|
|
info["gpu"] = {"used_mib": round(hwn.get("gpu_mem_used", 0) / 2**20),
|
|
"total_mib": round(hwn["gpu_mem_total"] / 2**20)}
|
|
if hwn.get("ram_total"):
|
|
info["ram"] = {"used_gib": round(hwn.get("ram_used", 0) / 2**30, 1),
|
|
"total_gib": round(hwn["ram_total"] / 2**30, 1)}
|
|
info["engine_version"] = (met.get("engine") or {}).get("version")
|
|
return info
|
|
|
|
def tracked_server(self) -> dict | None:
|
|
"""The server this MCP server started, while it still runs (else its record is cleared)."""
|
|
s = self.state("server")
|
|
if not s:
|
|
return None
|
|
if proc_alive(s.get("pid"), s.get("ident")):
|
|
return s
|
|
return {**s, "ended": True}
|
|
|
|
# ---- install job
|
|
def install_job(self) -> dict | None:
|
|
j = self.state("install")
|
|
if not j:
|
|
return None
|
|
res = self.state("install-result")
|
|
running = proc_alive(j.get("pid"), j.get("ident"))
|
|
log_path = Path(j.get("log", ""))
|
|
tail = read_tail(log_path, 1500) if log_path.name else [] # a step's heading must stay inside it: the pack tools print hundreds of lines
|
|
prog = install_progress(tail)
|
|
prog = combined_progress(prog, j)
|
|
out = {"running": running, "started": j.get("started"), "model": j.get("model"), "family": j.get("family"),
|
|
"log": str(log_path), **prog}
|
|
if not running:
|
|
if res.get("job") == j.get("job") and "exit_code" in res:
|
|
out["exit_code"] = res["exit_code"]
|
|
out["finished"] = res.get("ended")
|
|
out["result"] = "installed" if res["exit_code"] == 0 else "failed"
|
|
else:
|
|
out["result"] = "ended unexpectedly (no exit code recorded)"
|
|
else:
|
|
out["result"] = "running"
|
|
out["last_lines"] = tail[-12:]
|
|
return out
|
|
|
|
|
|
SHARD_NAME = re.compile(r"^(.*)-(\d{5})-of-(\d{5})\.gguf$")
|
|
DEFAULT_MMPROJ_BYTES = 907_543_008 # the Flash-Next picture reader (mmproj BF16), when a family does not pin its size
|
|
|
|
|
|
def shard_gaps(ggufs) -> list:
|
|
"""The parts of a multi-part GGUF (name-00001-of-00004.gguf) that are not there whole: a missing part, or one still being
|
|
downloaded (<part>.part). A config that names only the first part used to look finished with one part of four on disk."""
|
|
gaps = []
|
|
for g in ggufs:
|
|
m = SHARD_NAME.match(str(g))
|
|
if not m:
|
|
continue
|
|
for i in range(1, int(m.group(3)) + 1):
|
|
sib = Path(f"{m.group(1)}-{i:05d}-of-{m.group(3)}.gguf")
|
|
if not sib.is_file() or sib.with_name(sib.name + ".part").exists():
|
|
gaps.append(str(sib))
|
|
return gaps
|
|
|
|
|
|
def expected_files(S, family: str, model: str, models_dir: Path, vision: bool) -> list:
|
|
"""The files setup fetches for one install, with the sizes it can know before the download starts:
|
|
[{path, bytes|None, role}] where role is "model" (the shards or the pack's files) or "vision" (the picture reader).
|
|
Empty when the setup module cannot say (the caller then falls back to the folder scan)."""
|
|
out = []
|
|
try:
|
|
big = (getattr(S, "MULTI_FILE_FAMILIES", None) or {}).get(family)
|
|
if big: # DeepSeek-V4.1-Flash: its nine shards in the model's folder
|
|
folder = Path(models_dir) / (big["tag"] + model)
|
|
shared = Path(models_dir) / (big["tag"] + "shared") # the Engram, the drafter and the metadata shard of the official sets
|
|
return ([{"path": str(folder / n), "bytes": big["sha256"][n][0], "role": "model"} for n in big["models"][model]["shards"]]
|
|
+ [{"path": str(shared / n), "bytes": big["sha256"][n][0], "role": "model"} for n in big["models"][model].get("shared", [])])
|
|
own = (getattr(S, "OWN_PATH_FAMILIES", None) or {}).get(family)
|
|
if own: # Qwen3.6: the GGUF and the picture reader in the model's folder
|
|
folder = Path(models_dir) / (own["tag"] + model)
|
|
name = own["file"].format(q=model)
|
|
out.append({"path": str(folder / name), "bytes": own["sha256"][name][0], "role": "model"})
|
|
if vision:
|
|
out.append({"path": str(folder / own["mmproj"]), "bytes": own["sha256"][own["mmproj"]][0], "role": "vision"})
|
|
return out
|
|
fam, d = S.FAMILIES[family], S.MODELS[model]
|
|
if d.get("repo"): # NVFP4: a ready-made pack, many files with pinned sizes
|
|
for f in S.ready_needed(model, Path(models_dir)):
|
|
out.append({"path": str(S.ready_where(Path(models_dir), f)), "bytes": f[4], "role": "model"})
|
|
else:
|
|
folder = Path(models_dir) / (fam.get("tag", "") + model)
|
|
for i in range(1, S.model_shards(fam, model) + 1):
|
|
out.append({"path": str(folder / S.model_file(fam, model, i)), "bytes": None, "role": "model"})
|
|
if vision: # beside the model folders, not inside them (setup step 5)
|
|
size = (fam.get("mmproj_sha256") or (None,))[0] or DEFAULT_MMPROJ_BYTES
|
|
out.append({"path": str(Path(models_dir) / fam.get("mmproj", S.MMPROJ)), "bytes": size, "role": "vision"})
|
|
except Exception: # noqa: BLE001 - progress is a convenience: never break the install
|
|
return []
|
|
return out
|
|
|
|
|
|
def files_progress(prog: dict, job: dict, files: list) -> dict:
|
|
"""One honest download figure for a model in parts: every part counted (finished, partial, waiting), the picture reader as a part
|
|
of its own, and a part list the screen can name ("part 2 of 4"). The finished state needs EVERY part: a first shard that is whole
|
|
does not make the model downloaded."""
|
|
log = prog.get("download") or {}
|
|
cur_gb = float(log.get("total_gb") or 0)
|
|
rows = []
|
|
for f in files:
|
|
p = Path(f["path"])
|
|
part = p.with_name(p.name + ".part")
|
|
try:
|
|
if part.is_file():
|
|
state, have = "downloading", part.stat().st_size
|
|
elif p.is_file():
|
|
state, have = "done", p.stat().st_size
|
|
else:
|
|
state, have = "waiting", 0
|
|
except OSError:
|
|
state, have = "waiting", 0
|
|
total = f.get("bytes") or (have if state == "done" else 0)
|
|
if state == "downloading" and cur_gb and p.name in str(log.get("file", "")): # the log line names this file
|
|
# The .part file is as long as the furthest byte written, which the parallel pieces push to the full size well before the
|
|
# download is done: the pieces setup counts are the truth.
|
|
have = min(have, int(float(log.get("done_gb") or 0) * 1e9)) if have else have
|
|
if not total:
|
|
total = int(cur_gb * 1e9)
|
|
rows.append({"role": f.get("role", "model"), "state": state, "have": have, "total": max(total, have)})
|
|
plan_total = float(job.get("total_gb") or 0) * 1e9
|
|
parts = []
|
|
for role, name in (("model", "Model files"), ("vision", "Picture reader")):
|
|
mine = [r for r in rows if r["role"] == role]
|
|
if not mine:
|
|
continue
|
|
have = sum(r["have"] for r in mine)
|
|
known = sum(r["total"] for r in mine)
|
|
total = known if all(r["total"] for r in mine) else max(plan_total if role == "model" else 0, known, have)
|
|
k = sum(1 for r in mine if r["state"] == "done")
|
|
state = "done" if k == len(mine) else "downloading" if any(r["state"] == "downloading" for r in mine) or k else "waiting"
|
|
parts.append({"kind": role, "name": name, "state": state, "done_gb": round(min(have, total) / 1e9, 3),
|
|
"total_gb": round(total / 1e9, 3), "files_done": k, "files_total": len(mine)})
|
|
total_gb = sum(x["total_gb"] for x in parts)
|
|
done_gb = min(sum(x["done_gb"] for x in parts), total_gb)
|
|
active = next((x for x in parts if x["state"] != "done"), parts[-1] if parts else None)
|
|
if active and active["state"] == "waiting" and any(x["state"] == "done" for x in parts):
|
|
active = dict(active, state="downloading")
|
|
prog["download"] = {"file": "picture reader" if active and active["kind"] == "vision" else "model files",
|
|
"done_gb": round(done_gb, 3), "total_gb": round(total_gb, 3),
|
|
"remaining_gb": round(max(0.0, total_gb - done_gb), 3),
|
|
"percent": min(99, int(100 * done_gb / total_gb)) if total_gb else 0}
|
|
prog["parts"] = parts
|
|
return prog
|
|
|
|
|
|
def draft_progress(prog: dict, job: dict, dl: dict) -> dict:
|
|
"""Setup step 6 downloads the ~5 GB of MTP tensors (tools/mtp_fetch.py prints `MTP draft layer: 1.2 / 5.0 GB (24%)`). The figure
|
|
the app shows is the whole job's: the model files (done by now) plus this part, so the bar does not fall back to 0 (#3257)."""
|
|
done_gb, total_gb = float(dl.get("done_gb") or 0), float(dl.get("total_gb") or 0)
|
|
parts = []
|
|
if job.get("files"):
|
|
base = files_progress({}, job, job["files"])
|
|
parts = [dict(p, state="done", done_gb=p["total_gb"]) for p in base.get("parts", []) if p.get("state") == "done"]
|
|
parts.append({"kind": "draft", "name": "Draft layer", "state": "done" if int(dl.get("percent") or 0) >= 100 else "downloading",
|
|
"done_gb": round(done_gb, 3), "total_gb": round(total_gb, 3)})
|
|
total = sum(p["total_gb"] for p in parts)
|
|
done = min(sum(p["done_gb"] for p in parts), total)
|
|
prog["parts"] = parts
|
|
prog["download"] = {"file": "Draft layer", "done_gb": round(done, 3), "total_gb": round(total, 3),
|
|
"remaining_gb": round(max(0.0, total - done), 3),
|
|
"percent": min(99, int(100 * done / total)) if total else 0}
|
|
return prog
|
|
|
|
|
|
def combined_progress(prog: dict, job: dict) -> dict:
|
|
"""One total for the whole model: the bytes of all its files (finished, partial and the one in flight, read from the
|
|
model's folder) over the download size, instead of each file's own 0-100% (a two-file model went 70 GB -> 0 -> up
|
|
again). Only while the download step runs; later steps are not a download."""
|
|
folder, total = job.get("folder"), job.get("total_gb")
|
|
step = str(prog.get("step") or "")
|
|
dl = prog.get("download") or {}
|
|
if step.startswith("6/") and str(dl.get("file", "")).startswith("MTP draft layer"):
|
|
return draft_progress(prog, job, dl)
|
|
if step.startswith("5/") and str(dl.get("file", "")).startswith("checking "):
|
|
# setup is hashing files that are all on disk: the figure is the check's own (not a download at "99%")
|
|
if job.get("files"):
|
|
prog = files_progress(prog, job, job["files"])
|
|
prog["download"] = {"file": "checking files", "done_gb": dl["done_gb"], "total_gb": dl["total_gb"],
|
|
"remaining_gb": round(max(0.0, dl["total_gb"] - dl["done_gb"]), 3), "percent": int(dl["percent"])}
|
|
return prog
|
|
if job.get("files") and step.startswith("5/"): # the install knows its files: count each of them
|
|
return files_progress(prog, job, job["files"])
|
|
if not folder or not total or not step.startswith("5/"):
|
|
prog.pop("download", None)
|
|
return prog
|
|
done = 0
|
|
try:
|
|
for entry in os.scandir(folder):
|
|
if entry.is_file() and (entry.name.endswith(".gguf") or entry.name.endswith(".part")):
|
|
done += entry.stat().st_size
|
|
except OSError:
|
|
pass
|
|
if not done: # nothing on disk yet: the log's own line is all there is
|
|
return prog
|
|
done_gb = min(done / 1e9, float(total))
|
|
prog["download"] = {"file": "model files", "done_gb": round(done_gb, 3), "total_gb": float(total),
|
|
"remaining_gb": round(max(0.0, float(total) - done_gb), 3),
|
|
"percent": min(99, int(100 * done_gb / float(total))) if total else 0}
|
|
# The parts of the whole job, for a progress bar that sums them (the job that fetches the engine adds its own part):
|
|
# {kind, name, done_gb, total_gb, state}
|
|
prog["parts"] = [{"kind": "model", "name": "Model files", "done_gb": prog["download"]["done_gb"],
|
|
"total_gb": float(total), "state": "downloading"}]
|
|
return prog
|
|
|
|
|
|
def install_progress(lines: list[str]) -> dict:
|
|
"""setup's progress from its output: the step, a download's percentage, a failure."""
|
|
step, title, dl, err = None, None, None, None
|
|
for i, line in enumerate(lines):
|
|
m = re.search(r"=== Step (\d+): (.+?) ===", line)
|
|
if m:
|
|
step, title, err, dl = int(m.group(1)), m.group(2), None, None # a download figure belongs to its own step
|
|
m = re.match(r"\s*(\S.*?): +([\d.]+) / ([\d.]+) GB \((\d+)%\)", line)
|
|
if m:
|
|
dl = {"file": m.group(1), "done_gb": float(m.group(2)), "total_gb": float(m.group(3)),
|
|
"percent": int(m.group(4))}
|
|
if line.strip().startswith("[X]"):
|
|
err = line.strip()[3:].strip()
|
|
if i + 1 < len(lines) and lines[i + 1].startswith(" "):
|
|
err += " - " + lines[i + 1].strip()
|
|
out = {"step": f"{step}/7: {title}" if step else None}
|
|
if dl:
|
|
out["download"] = dl
|
|
if err:
|
|
out["error"] = err
|
|
words = progress_words_for(step, lines, dl)
|
|
if words:
|
|
out["label"], out["phase"] = words
|
|
return out
|
|
|
|
|
|
def physical_cores() -> int | None:
|
|
"""The CPU's physical cores (os.cpu_count() counts logical threads: 16 on an 8-core SMT chip, and the app's Work profile took
|
|
80% of that for --pool-workers, more than the cores, #3417). Falls back to the thread count when the machine cannot say."""
|
|
try:
|
|
from strata_control import tune_machine
|
|
n = tune_machine.cpu().get("physical")
|
|
if isinstance(n, int) and n > 0:
|
|
return n
|
|
except Exception: # noqa: BLE001 - a hardware report must never fail on this
|
|
pass
|
|
return os.cpu_count()
|
|
|
|
|
|
def progress_words_for(step, lines, download):
|
|
"""(the plain sentence, its phase) for what setup is doing now (strata_control/progress_words.py, the words the Terminal uses too);
|
|
None when this Strata folder does not carry the module."""
|
|
try:
|
|
from strata_control import progress_words
|
|
except ImportError:
|
|
return None
|
|
w = progress_words.describe(step, lines, download)
|
|
return w.label, w.phase
|
|
|
|
|
|
def own_ram_gb() -> float:
|
|
if WIN:
|
|
class MS(ctypes.Structure):
|
|
_fields_ = [("dwLength", ctypes.c_ulong), ("dwMemoryLoad", ctypes.c_ulong),
|
|
*((n, ctypes.c_ulonglong) for n in ("tp", "ap", "tpf", "apf", "tv", "av", "aev"))]
|
|
m = MS()
|
|
m.dwLength = ctypes.sizeof(MS)
|
|
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m))
|
|
return m.tp / 2**30
|
|
try:
|
|
for line in open("/proc/meminfo"):
|
|
if line.startswith("MemTotal"):
|
|
return int(line.split()[1]) * 1024 / 2**30
|
|
except OSError:
|
|
pass
|
|
return 0.0
|
|
|
|
|
|
def own_nvidia_gpus() -> list:
|
|
s = run_quiet(["nvidia-smi", "--query-gpu=index,name,memory.total,compute_cap,driver_version",
|
|
"--format=csv,noheader,nounits"])
|
|
found = []
|
|
for line in s.strip().splitlines():
|
|
try:
|
|
idx, name, mem, cc, drv = [x.strip() for x in line.split(",")]
|
|
try:
|
|
mem_gb = float(mem) / 1024.0
|
|
except ValueError:
|
|
if platform.machine().lower() not in ("aarch64", "arm64"):
|
|
raise # as before on x86: a card whose memory nvidia-smi does not report is not listed
|
|
# a DGX Spark (GB10): one memory for the CPU and the GPU, memory.total is [N/A]; counted as setup.py's gpus() counts it
|
|
# (the RAM less the OS's 6 GB, none of it room beside the RAM)
|
|
shared = max(0.0, own_ram_gb() - 6)
|
|
found.append({"index": int(idx), "name": name, "vram_gb": shared, "arch": cc.replace(".", ""), "driver": drv,
|
|
"uma": True, "dedicated_gb": 0.0, "shared_gb": shared})
|
|
continue
|
|
found.append({"index": int(idx), "name": name, "vram_gb": mem_gb,
|
|
"arch": cc.replace(".", ""), "driver": drv})
|
|
except ValueError:
|
|
continue
|
|
return found
|
|
|
|
|
|
def nvidia_display_active() -> dict:
|
|
"""{GPU index: True when a display is attached to the card} from nvidia-smi (display_active Enabled); {} when it says nothing."""
|
|
found = {}
|
|
for line in run_quiet(["nvidia-smi", "--query-gpu=index,display_active", "--format=csv,noheader"]).strip().splitlines():
|
|
idx, _, state = line.partition(",")
|
|
if idx.strip().isdigit() and state.strip().lower() in ("enabled", "disabled"):
|
|
found[int(idx)] = state.strip().lower() == "enabled"
|
|
return found
|
|
|
|
|
|
def windows_desktop_adapters() -> list:
|
|
"""The PNPDeviceIDs of the Windows display adapters that have a desktop resolution (a monitor is attached to them)."""
|
|
out = []
|
|
for line in run_quiet(["powershell", "-NoProfile", "-NonInteractive", "-Command",
|
|
"Get-CimInstance Win32_VideoController | ForEach-Object { $_.PNPDeviceID + '|' + $_.CurrentHorizontalResolution }"]
|
|
).splitlines():
|
|
pnp, _, res = line.strip().rpartition("|")
|
|
if pnp and res.strip().isdigit() and int(res) > 0:
|
|
out.append(pnp)
|
|
return out
|
|
|
|
|
|
def mark_display(gpus: list, nvidia_active: dict, desktop_pnp: list) -> None:
|
|
"""Sets g["drives_display"] (True / False) on the cards that can be told, from nvidia-smi (NVIDIA) and the Windows adapters that have a desktop
|
|
resolution (AMD and Intel, matched by PCI device id; two cards of one model are not told apart: left unknown). A card left without the key is
|
|
unknown, and the app treats an unknown card as one that drives a display."""
|
|
desk = [str(p).upper() for p in desktop_pnp]
|
|
for g in gpus:
|
|
if g.get("vendor") == "nvidia" and g.get("index") in nvidia_active:
|
|
g["drives_display"] = nvidia_active[g["index"]]
|
|
elif g.get("vendor") == "amd" and isinstance(g.get("pci_id"), int) and desktop_pnp is not None and desktop_pnp:
|
|
tag = f"DEV_{g['pci_id']:04X}"
|
|
twins = [x for x in gpus if x.get("vendor") == "amd" and x.get("pci_id") == g["pci_id"]]
|
|
if len(twins) == 1:
|
|
g["drives_display"] = any(tag in p and "VEN_1002" in p for p in desk)
|
|
|
|
|
|
def windows_video_controllers() -> list:
|
|
s = run_quiet(["powershell", "-NoProfile", "-NonInteractive", "-Command",
|
|
"Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion | ConvertTo-Json -Compress"])
|
|
try:
|
|
d = json.loads(s) if s.strip() else []
|
|
except ValueError:
|
|
return []
|
|
d = [d] if isinstance(d, dict) else d
|
|
return [{"name": x.get("Name"), "driver": x.get("DriverVersion")} for x in d if isinstance(x, dict)]
|
|
|
|
|
|
KFD_SUPPORTED = ("gfx1100", "gfx1101", "gfx1102", "gfx1200", "gfx1201", "gfx1030", "gfx1031", "gfx1151")
|
|
|
|
|
|
def kfd_gpus(sysfs: str = "/sys") -> list:
|
|
"""AMD cards from the kernel's KFD topology and the amdgpu driver's files, with no ROCm tool and no setup module: a PC that has only the
|
|
driver plus the engine's own runtime has no rocm-smi, amd-smi or rocminfo, and the cards were then not counted at all (a 4-card PC ran on one).
|
|
Numbered as HIP numbers them (the GPU nodes in order, CPU nodes skipped)."""
|
|
base = Path(sysfs) / "class/kfd/kfd/topology/nodes"
|
|
out = []
|
|
try:
|
|
nodes = sorted((p for p in base.iterdir() if p.name.isdigit()), key=lambda p: int(p.name))
|
|
except OSError:
|
|
return out
|
|
for node in nodes:
|
|
try:
|
|
props = dict(line.partition(" ")[::2] for line in (node / "properties").read_text().splitlines())
|
|
ver, simd = int(props.get("gfx_target_version") or 0), int(props.get("simd_count") or 0)
|
|
except (OSError, ValueError):
|
|
continue
|
|
if ver == 0 or simd == 0:
|
|
continue
|
|
arch = f"gfx{ver // 10000}{(ver // 100) % 100:x}{ver % 100:x}"
|
|
dev = Path(sysfs) / f"class/drm/renderD{props.get('drm_render_minor', '').strip()}/device"
|
|
try:
|
|
vram = int((dev / "mem_info_vram_total").read_text()) / 2 ** 30
|
|
except (OSError, ValueError):
|
|
vram = 0.0
|
|
try:
|
|
name = (dev / "product_name").read_text().strip() or f"AMD Radeon ({arch})"
|
|
except OSError:
|
|
name = f"AMD Radeon ({arch})"
|
|
ok = arch in KFD_SUPPORTED
|
|
out.append({"index": len(out), "name": name, "vram_gb": round(vram, 1), "arch": arch, "driver": "amdgpu", "vendor": "amd",
|
|
"usable": ok, "problem": None if ok else f"not supported - Strata's AMD backend does not list {arch}"})
|
|
return out
|
|
|
|
|
|
def rocm_smi_gpus() -> list:
|
|
s = run_quiet(["rocm-smi", "--showproductname", "--showmeminfo", "vram", "--json"])
|
|
try:
|
|
d = json.loads(s)
|
|
except ValueError:
|
|
return []
|
|
out = []
|
|
for i, (card, v) in enumerate(sorted(d.items())):
|
|
if not isinstance(v, dict) or not card.lower().startswith("card"):
|
|
continue
|
|
total = next((v[k] for k in v if "total" in k.lower() and "vram" in k.lower()), 0)
|
|
try:
|
|
vram = int(total) / 2**30
|
|
except (TypeError, ValueError):
|
|
vram = 0.0
|
|
out.append({"index": i, "name": v.get("Card Series") or v.get("Card SKU") or card, "vram_gb": round(vram, 1),
|
|
"vendor": "amd", "usable": None, "problem": "listed by rocm-smi (setup checks the card itself)"})
|
|
return out
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------ the tools
|
|
GPUS_PATTERN = r"all|\d{1,2}(,\d{1,2}){1,7}"
|
|
MODEL_ID_PATTERN = r"[A-Za-z0-9][A-Za-z0-9_.-]{0,48}"
|
|
|
|
|
|
def tool_defs(models: dict, families: dict, contexts: list) -> list:
|
|
port = {"type": "integer", "minimum": 1024, "maximum": 65535,
|
|
"description": "the server's port (default: the installed model's port, usually 8080)"}
|
|
installed_model = {"type": "string", "pattern": MODEL_ID_PATTERN, "maxLength": 49,
|
|
"description": "an installed model's id as strata_status lists it (e.g. 'q2_0', "
|
|
"'coder-iq1_m', 'swift-iq3_xxs'); default: the most recently used one"}
|
|
ro = {"readOnlyHint": True, "destructiveHint": False, "openWorldHint": False}
|
|
return [
|
|
{"name": "strata_status", "title": "Strata status",
|
|
"description": "Is Strata (the local AI model server) running, and what is installed? Reports the running "
|
|
"server (model, engine version, context, busy or idle, VRAM/RAM in use), the installed models "
|
|
"and data folder, an install in progress, this PC's hardware (OS, CPU, RAM, GPUs, free disk) "
|
|
"and the model size setup recommends for this PC. Read-only; call it first.",
|
|
"inputSchema": {"type": "object", "additionalProperties": False, "properties": {
|
|
"port": port,
|
|
"hardware": {"type": "boolean", "description": "include the hardware check and the recommendation "
|
|
"(default true; takes a second or two)"}}},
|
|
"annotations": ro},
|
|
{"name": "strata_models", "title": "Strata models",
|
|
"description": "The models and sizes Strata's setup offers (families: qwen = Qwen3.8-Flash-Next, swift = "
|
|
"Swift 1.5, coder = the Coder, unsloth = Unsloth's ~4-bit), with download size, RAM needed, "
|
|
"whether each fits this PC, which are installed, the context lengths, and the recommendation. "
|
|
"Read-only.",
|
|
"inputSchema": {"type": "object", "additionalProperties": False, "properties": {}},
|
|
"annotations": ro},
|
|
{"name": "strata_remove", "title": "Delete an installed model",
|
|
"description": "Delete an installed model's own files (its model GGUFs, its pack folder, its run config) and nothing "
|
|
"another installed model still uses (a shared MTP draft or PLE file). Without confirm=true it only "
|
|
"returns the plan (what would be removed and how big, what is kept). Stop the model first.",
|
|
"inputSchema": {"type": "object", "additionalProperties": False, "required": ["model"],
|
|
"properties": {"model": installed_model, "confirm": {"type": "boolean"}}},
|
|
"annotations": {"readOnlyHint": False, "destructiveHint": True}},
|
|
{"name": "strata_install", "title": "Install Strata",
|
|
"description": "Install a Strata model on this PC by running Strata's setup non-interactively in the "
|
|
"background (Python packages, the engine, a 58-111 GB model download, preparation, start "
|
|
"script). Without confirm=true it only returns the plan (what would be downloaded, disk "
|
|
"space): show it to the user and call again with confirm=true once they agree. Returns at "
|
|
"once; call it again (no arguments needed) to see the progress, or with cancel=true to stop "
|
|
"it (it resumes later). Defaults: the recommended model and context for this PC, pictures on where the model can read them. "
|
|
"After it finishes, start the model with strata_start.",
|
|
"inputSchema": {"type": "object", "additionalProperties": False, "properties": {
|
|
"family": {"type": "string", "enum": list(families),
|
|
"description": "qwen = Qwen3.8-Flash-Next (the original), swift = Swift 1.5 (thinks "
|
|
"shorter), coder = the Coder (half the experts, ~32 GB RAM, for code), "
|
|
"unsloth = Unsloth's 1- to 8-bit files (UD-IQ4_XS, UD-Q4_K_XL, UD-Q5_K_XL, "
|
|
"UD-Q6_K_XL, Q8_0): every expert in RAM where they fit, else part of them read "
|
|
"from the SSD"},
|
|
"model": {"type": "string", "enum": list(models),
|
|
"description": "the size (quantization): see strata_models"},
|
|
"context": {"type": "integer", "minimum": 1024, "maximum": 2097152, "examples": contexts,
|
|
"description": "context length in tokens (more needs more VRAM). Any whole number works, "
|
|
"for example one typed into a model's run config by hand; the usual sizes are "
|
|
f"{', '.join(str(c) for c in contexts)}"},
|
|
"vision": {"type": "string", "enum": ["no", "yes", "gpu", "cpu"],
|
|
"description": "let the model read images (~0.9 GB more download). Default yes where the model can "
|
|
"read them (not on a Mac): yes = the best reader for this card (the CPU "
|
|
"under 24 GB of VRAM, the GPU from 24 GB, any make), gpu / cpu = that "
|
|
"reader, no = text only"},
|
|
"kv": {"type": "string", "enum": ["int8", "q4_0", "k8v4"],
|
|
"description": "KV cache precision above 8K context (default int8)"},
|
|
"gpu": {"type": "integer", "minimum": 0, "maximum": 63,
|
|
"description": "one GPU, as nvidia-smi numbers them (default: the one with the most VRAM)"},
|
|
"gpus": {"type": "string", "pattern": GPUS_PATTERN, "maxLength": 40,
|
|
"description": "several GPUs sharing the model: '0,2' or 'all' (experimental)"},
|
|
"backend": {"type": "string", "enum": ["auto", "cuda", "hip"],
|
|
"description": "auto (default), cuda = NVIDIA, hip = AMD on Linux (experimental)"},
|
|
"low_ram": {"type": "string", "enum": ["auto", "on", "off", "resident", "mmap"],
|
|
"description": "setup's low-RAM mode (default auto)"},
|
|
"port": {**port, "description": "the port the model will listen on (default 8080)"},
|
|
"data_dir": {"type": "string", "maxLength": 260,
|
|
"description": "where the model files go (default: the Strata-data folder next to the "
|
|
"Strata folder). Only the current data folder, a folder inside the Strata "
|
|
"folder, or a new absolute path whose last folder is named Strata... "
|
|
"(e.g. D:\\Strata-data) outside system folders"},
|
|
"drafter": {"type": "string", "enum": ["mtp", "dflash2"],
|
|
"description": "which drafter this model will use: dflash2 downloads only DFlash2's drafter (a separate step), not the MTP draft layer's ~5 GB; "
|
|
"mtp (the default) downloads the draft layer"},
|
|
"gguf_dir": {"type": "string", "maxLength": 260,
|
|
"description": "a folder that already holds this model's GGUF files under their original names: setup "
|
|
"checks them (size and SHA-256) and does not download them again"},
|
|
"confirm": {"type": "boolean", "description": "true = really start the install (after the user agreed "
|
|
"to the plan and the download size)"},
|
|
"cancel": {"type": "boolean", "description": "true = stop the install in progress"}}},
|
|
"annotations": {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True,
|
|
"openWorldHint": True}},
|
|
{"name": "strata_start", "title": "Start Strata",
|
|
"description": "Start an installed Strata model in the background (as its start script does, without "
|
|
"opening a browser) and wait until it answers. Loading takes 1-3 minutes and the PC can be "
|
|
"slow meanwhile; if it is not ready within wait_seconds this returns 'loading' - check again "
|
|
"with strata_status. The model keeps running until strata_stop.",
|
|
"inputSchema": {"type": "object", "additionalProperties": False, "properties": {
|
|
"model": installed_model, "port": port,
|
|
"gpu": {"type": "integer", "minimum": 0, "maximum": 63,
|
|
"description": "run on this GPU for this start (as nvidia-smi numbers them)"},
|
|
"wait_seconds": {"type": "integer", "minimum": 0, "maximum": 600,
|
|
"description": "how long to wait for it to be ready (default 50: some AI apps end "
|
|
"a tool call after 60 s)"}}},
|
|
"annotations": {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True,
|
|
"openWorldHint": False}},
|
|
{"name": "strata_stop", "title": "Stop Strata",
|
|
"description": "Stop the Strata model: the engine is unloaded through the server's own API (it frees its "
|
|
"VRAM and RAM itself), then the server this tool started is ended. A Strata started "
|
|
"elsewhere (e.g. its own window) is only unloaded - close its window to end it. Refuses while "
|
|
"a request is running unless force=true.",
|
|
"inputSchema": {"type": "object", "additionalProperties": False, "properties": {
|
|
"port": port,
|
|
"force": {"type": "boolean", "description": "stop even while a request is running"}}},
|
|
"annotations": {"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True,
|
|
"openWorldHint": False}},
|
|
{"name": "strata_logs", "title": "Strata logs",
|
|
"description": "The last lines of a Strata log: 'server' (the model server this tool started: loading, "
|
|
"requests, errors), 'engine' (the engine's own log for a model), 'setup' (the install). "
|
|
"Default: the setup log while an install runs, else the server log. Read-only.",
|
|
"inputSchema": {"type": "object", "additionalProperties": False, "properties": {
|
|
"source": {"type": "string", "enum": ["server", "engine", "setup"]},
|
|
"model": installed_model,
|
|
"lines": {"type": "integer", "minimum": 1, "maximum": 500, "description": "default 80"}}},
|
|
"annotations": ro},
|
|
{"name": "strata_benchmark", "title": "Strata speed test",
|
|
"description": "A short fixed request (greedy, thinking off) through the running Strata server; reports "
|
|
"the output speed (tokens/s) and prompt speed. Takes 10-60 s. Refuses while the model is "
|
|
"busy.",
|
|
"inputSchema": {"type": "object", "additionalProperties": False, "properties": {
|
|
"port": port,
|
|
"max_tokens": {"type": "integer", "minimum": 16, "maximum": 512, "description": "default 200"}}},
|
|
"annotations": {"readOnlyHint": True, "destructiveHint": False, "openWorldHint": False}},
|
|
{"name": "strata_connect_info", "title": "Connect to Strata",
|
|
"description": "How to point other apps at the local Strata server: the OpenAI and Anthropic base URLs, "
|
|
"the model id, whether an API key is needed, and ready-to-paste settings for Claude Code, "
|
|
"Codex CLI, the OpenAI SDK, curl, Cursor/Continue/Open WebUI. Read-only.",
|
|
"inputSchema": {"type": "object", "additionalProperties": False, "properties": {"port": port}},
|
|
"annotations": ro},
|
|
]
|
|
|
|
|
|
class Tools:
|
|
def __init__(self, strata: Strata):
|
|
self.s = strata
|
|
|
|
def defs(self) -> list:
|
|
models, families, contexts = self.s.tables()[:3]
|
|
return tool_defs({**{name: {} for name in self.s.own_size_names() if name not in models}, **models}, families, contexts)
|
|
|
|
def call(self, name: str, args) -> dict:
|
|
defs = {d["name"]: d for d in self.defs()}
|
|
if name not in defs:
|
|
raise KeyError(name)
|
|
args = check_schema(defs[name]["inputSchema"], args)
|
|
return getattr(self, name)(**args)
|
|
|
|
# ---- strata_status
|
|
def strata_status(self, port=None, hardware=True) -> dict:
|
|
s = self.s
|
|
s.reap()
|
|
servers = []
|
|
for p in s.ports(port):
|
|
info = s.probe(p)
|
|
if info is not None:
|
|
servers.append(info)
|
|
tracked = s.tracked_server()
|
|
out = {}
|
|
running = [x for x in servers if not x.get("other")]
|
|
if tracked and not tracked.get("ended"):
|
|
mine = next((x for x in running if x["port"] == tracked["port"]), None)
|
|
if mine:
|
|
mine["started_by"] = f"this MCP server (pid {tracked['pid']})"
|
|
else:
|
|
out["loading"] = {"model": tracked.get("model"), "port": tracked["port"], "pid": tracked["pid"],
|
|
"since": tracked.get("started"),
|
|
"last_log_line": (read_tail(Path(tracked.get("log", "")), 1) or [None])[-1]}
|
|
elif tracked and tracked.get("ended"):
|
|
out["last_start"] = {"model": tracked.get("model"), "ended": True,
|
|
"last_log_lines": read_tail(Path(tracked.get("log", "")), 6)}
|
|
for x in running:
|
|
x.setdefault("started_by", "outside this MCP server (its own window, a start script or a service)")
|
|
out["running"] = running
|
|
others = [x for x in servers if x.get("other")]
|
|
if others:
|
|
out["ports_used_by_other_programs"] = others
|
|
if running:
|
|
r = running[0]
|
|
summary = (f"Strata is running on port {r['port']}: {r.get('model')}"
|
|
+ (", busy" if r.get("busy") else ", idle")
|
|
+ ("" if r.get("loaded", True) else " (model unloaded; the next request loads it)"))
|
|
elif "loading" in out:
|
|
summary = f"Strata is loading {out['loading']['model']} (port {out['loading']['port']})"
|
|
else:
|
|
summary = "Strata is not running"
|
|
inst = s.installed()
|
|
job = s.install_job()
|
|
if job:
|
|
out["install"] = job
|
|
if job["running"]:
|
|
summary += f"; an install is running ({job.get('step') or 'starting'})"
|
|
if not inst["models"]:
|
|
summary += "; no model is installed yet"
|
|
else:
|
|
summary += "; installed: " + ", ".join(m["model"] for m in inst["models"])
|
|
out["installed"] = inst
|
|
if hardware:
|
|
hw = s.hardware()
|
|
out["hardware"] = hw
|
|
out["recommendation"] = s.recommend(hw)
|
|
return {"summary": summary, **out}
|
|
|
|
# ---- strata_models
|
|
def strata_models(self) -> dict:
|
|
s = self.s
|
|
hw = s.hardware()
|
|
_m, _f, contexts, src = s.tables()
|
|
rec = s.recommend(hw)
|
|
return {"summary": "Model families and sizes setup offers" + (
|
|
f"; recommended for this PC: {rec['title']}, {rec['context'] // 1024}K context"
|
|
if rec.get("model") else f"; no model fits this PC: {rec['why']}"),
|
|
"families": s.model_table(hw), "contexts": contexts,
|
|
"context_note": "longer contexts need more VRAM (fewer experts fit on the GPU); past 262144 setup adds "
|
|
"rope scaling (experimental)",
|
|
"recommendation": rec, "source": src,
|
|
"this_pc": {"ram_gb": hw.get("ram_gb"),
|
|
"gpus": [{k: g.get(k) for k in ("index", "name", "vram_gb", "usable", "vendor")}
|
|
for g in hw.get("gpus", [])],
|
|
**({"engine_vendor": hw["engine_vendor"]} if hw.get("engine_vendor") else {}),
|
|
**({"gpu_note": hw["gpu_note"]} if hw.get("gpu_note") else {})}}
|
|
|
|
# ---- strata_remove
|
|
def strata_remove(self, model=None, confirm=False) -> dict:
|
|
s = self.s
|
|
config = s.find_config(model)
|
|
plan = s.removal_plan(config)
|
|
if not confirm:
|
|
return {"summary": f"Plan: delete {plan['model']} ({plan['gb']:.1f} GB). Nothing was done yet.", "plan": plan}
|
|
with s.lock:
|
|
job = s.install_job()
|
|
if job and job["running"]:
|
|
raise ToolError("an install is running: wait for it to finish or stop it first")
|
|
tracked = s.tracked_server()
|
|
if tracked and not tracked.get("ended") and tracked.get("model") == plan["model"]:
|
|
raise ToolError("this model is running: stop it first")
|
|
failed = []
|
|
# The plan can list a folder and files inside it, or a file that is already gone (an earlier delete, a file moved by hand):
|
|
# a path that is not there is already removed, not an error. Only what is still there afterwards counts as a failure.
|
|
for item in sorted(plan["remove"], key=lambda x: (x["path"] == str(config), x["path"])):
|
|
p = Path(item["path"])
|
|
try:
|
|
if p.is_dir() and not p.is_symlink():
|
|
shutil.rmtree(p, onerror=_rmtree_ignore_missing)
|
|
else:
|
|
p.unlink(missing_ok=True)
|
|
except FileNotFoundError:
|
|
pass
|
|
except OSError as e:
|
|
if p.exists() or p.is_symlink():
|
|
failed.append(f"{p.name}: {e}")
|
|
files, dirs = s.model_paths(s.read_config(config)) if config.exists() else ([], [])
|
|
for p in {x.parent for x in [Path(i["path"]) for i in plan["remove"]] if x.suffix == ".gguf"}:
|
|
try:
|
|
p.rmdir() # the model's own folder, when nothing else is in it
|
|
except OSError:
|
|
pass
|
|
return {"summary": f"Deleted {plan['model']} ({plan['gb']:.1f} GB)" + (f"; could not remove {len(failed)} item(s)" if failed else ""),
|
|
"plan": plan, "failed": failed}
|
|
|
|
# ---- strata_install
|
|
def check_data_dir(self, raw: str) -> Path:
|
|
s = self.s
|
|
if raw.startswith("\\\\") or raw.startswith("//"):
|
|
raise ToolError("data_dir: network (UNC) paths are not allowed; use a local drive")
|
|
p = Path(os.path.expandvars(raw)).expanduser()
|
|
if not p.is_absolute():
|
|
raise ToolError("data_dir must be an absolute path (e.g. D:\\Strata-data or /mnt/big/Strata-data)")
|
|
if ".." in p.parts:
|
|
raise ToolError("data_dir must not contain '..'")
|
|
n = norm(p)
|
|
current = norm(s.data_dir())
|
|
if n == current or is_inside(n, norm(s.root)) or n == norm(s.root.parent / "Strata-data"):
|
|
pass
|
|
else:
|
|
if not p.name.lower().startswith("strata"):
|
|
raise ToolError(f"data_dir {raw!r}: a new data folder must be named Strata... (e.g. "
|
|
f"{Path(p.anchor or '/') / 'Strata-data'}), so that no other folder is filled with "
|
|
"model files")
|
|
if not p.parent.is_dir():
|
|
raise ToolError(f"data_dir {raw!r}: its parent folder {p.parent} does not exist")
|
|
if any(n == f or is_inside(n, f) for f in system_folders()):
|
|
raise ToolError(f"data_dir {raw!r} is inside a system folder")
|
|
if p.exists() and not p.is_dir():
|
|
raise ToolError(f"data_dir {raw!r} is a file")
|
|
return p
|
|
|
|
def strata_install(self, family=None, model=None, context=None, vision=None, kv=None, gpu=None, gpus=None,
|
|
backend=None, low_ram=None, port=None, data_dir=None, confirm=False, cancel=False,
|
|
gguf_dir=None, drafter=None) -> dict:
|
|
s = self.s
|
|
with s.lock:
|
|
job = s.install_job()
|
|
if cancel:
|
|
if not job or not job["running"]:
|
|
return {"summary": "no install is running", "install": job}
|
|
j = s.state("install")
|
|
res = s.state("install-result")
|
|
child = res.get("child_pid") if res.get("job") == j.get("job") else None
|
|
tree = proc_tree_snapshot(j["pid"]) if proc_alive(j["pid"], j["ident"]) else [] # setup.py and what it runs (#3257)
|
|
if child:
|
|
proc_kill_tree(child, res.get("child_ident"))
|
|
proc_kill_tree(j["pid"], j["ident"])
|
|
kill_snapshot(tree)
|
|
wait_gone(j["pid"], j["ident"], 15)
|
|
return {"summary": "the install was stopped; calling strata_install again resumes it (downloads "
|
|
"continue where they stopped)", "install": s.install_job()}
|
|
if job and job["running"]:
|
|
return {"summary": f"an install is running: {job.get('step') or 'starting'}"
|
|
+ (f", downloading {job['download']['file']} {job['download']['percent']}%"
|
|
if job.get("download") else ""),
|
|
"install": job, "follow": "call strata_install again for progress, or strata_logs "
|
|
"source=setup for the full output"}
|
|
given = [x for x in (family, model, context, vision, kv, gpu, gpus, backend, low_ram, port, data_dir)
|
|
if x is not None]
|
|
if job and not given and not confirm:
|
|
return {"summary": f"the last install {job['result']}" + (f": {job['error']}" if job.get("error") else ""),
|
|
"install": job,
|
|
"next": "strata_start" if job.get("exit_code") == 0 else
|
|
"fix the problem above and call strata_install again with confirm=true (finished "
|
|
"steps are skipped)"}
|
|
plan, args = self.install_plan(family, model, context, vision, kv, gpu, gpus, backend, low_ram, port,
|
|
data_dir, gguf_dir, drafter)
|
|
tracked = s.tracked_server()
|
|
if tracked and not tracked.get("ended"):
|
|
raise ToolError("Strata is running (started by strata_start): stop it first with strata_stop - setup "
|
|
"may update the engine files it uses")
|
|
if not confirm:
|
|
return {"summary": f"Plan: install {plan['title']} ({plan['download_gb']:.0f} GB download"
|
|
f"{' at most; part of it is already here' if plan['partly_downloaded'] else ''}). "
|
|
"Nothing was done yet: ask the user, then call strata_install again with the "
|
|
"same arguments and confirm=true.", "plan": plan}
|
|
if plan["disk_short"]:
|
|
raise ToolError(plan["disk_short"])
|
|
return self.launch_install(plan, args)
|
|
|
|
def install_plan(self, family, model, context, vision, kv, gpu, gpus, backend, low_ram, port, data_dir, gguf_dir=None, drafter=None):
|
|
s = self.s
|
|
models, families, contexts, _src = s.tables()
|
|
if gpu is not None and gpus is not None:
|
|
raise ToolError("give gpu (one card) or gpus (several), not both")
|
|
if sys.version_info < (3, 10) or (WIN and sys.maxsize <= 2**32):
|
|
raise ToolError("this Python is too old or 32-bit: Strata's setup needs 64-bit Python 3.10+; run "
|
|
+ ("START-HERE.bat" if WIN else "./setup.sh") + " once instead (it installs Python)")
|
|
hw = s.hardware()
|
|
rec = s.recommend(hw)
|
|
if model is None and family is None:
|
|
if not rec.get("model"):
|
|
raise ToolError("no model was given and none fits this PC: " + rec["why"])
|
|
family, model = rec["family"], rec["model"]
|
|
context = context or rec["context"]
|
|
elif model is None:
|
|
sizes = s.sizes_of(models, family)
|
|
model = rec["model"] if rec.get("family") == family else ("IQ3_XXS" if "IQ3_XXS" in sizes and
|
|
hw.get("ram_gb", 0) >= 60 else sizes[0])
|
|
elif family is None:
|
|
fams = [f for f in families if model in s.sizes_of(models, f)]
|
|
family = "qwen" if "qwen" in fams else fams[0]
|
|
if family not in families:
|
|
raise ToolError(f"unknown family {family}; the families: {', '.join(families)}")
|
|
sizes = s.sizes_of(models, family)
|
|
if model not in sizes:
|
|
raise ToolError(f"{families[family]['title']} has no {model}; its sizes: {', '.join(sizes)}")
|
|
md = s.own_size(family, model) or models[model] # Qwen3.6-35B-A3B: its sizes are not in setup's MODELS
|
|
mac = bool(hw.get("mac"))
|
|
if mac: # a Mac: no card to pick, no KV choice, no RAM mode: unified memory decides
|
|
if rec.get("model") is None and model is not None and s.mac() and hw.get("ram_gb", 0) < s.mac().MIN_STREAM_RAM_GB:
|
|
raise ToolError(f"{model} does not fit this Mac: {rec['why']}")
|
|
gpu = gpus = kv = low_ram = None
|
|
if md.get("multi_file"): # DeepSeek on a Mac (0.2.7.1): the sets that were run there, 8K like everywhere, text only
|
|
from strata_control import dsv41_fit
|
|
if model in dsv41_fit.MAC_NOT_YET:
|
|
raise ToolError(dsv41_fit.MAC_NOT_YET_LINE)
|
|
if model not in dsv41_fit.MAC_SET_PACK:
|
|
raise ToolError(f"{model} is not offered on a Mac: DeepSeek runs there as " + " or ".join(dsv41_fit.MAC_SET_PACK))
|
|
if not s.mac_deepseek_pictures():
|
|
if vision in ("yes", "gpu", "cpu"):
|
|
from strata_control import dsv41_engine
|
|
raise ToolError(dsv41_engine.MAC_TEXT_LINE + " Use vision=no.")
|
|
vision = "no"
|
|
context = context or getattr(s.setup_module(), "DSV41_MAC_CONTEXT", 8192)
|
|
context = context or (s.mac().recommend_context(hw.get("ram_gb") or 0) if s.mac() else 32768)
|
|
if context is None and md.get("multi_file"):
|
|
context = getattr(s.setup_module(), "DSV41_CONTEXT", 8192) # the context the engine was checked at
|
|
if md.get("multi_file"): # DeepSeek-V4.1-Flash reads up to 1,048,576 positions (Project Maya #442): a size past that cannot run
|
|
from strata_control import dsv41_context
|
|
if dsv41_context.problem(int(context)):
|
|
raise ToolError(dsv41_context.problem(int(context)))
|
|
if context is None:
|
|
usable = s.engine_gpus(hw)
|
|
vram = max((g["vram_gb"] for g in usable), default=12)
|
|
context = 131072 # 128K on every card (owner, 2026-11-01; strata_control/default_context.py)
|
|
if vision in ("yes", "gpu", "cpu") and md.get("vision", families[family].get("vision")) is False:
|
|
raise ToolError(f"images are not available with {families[family]['title']} {model} yet: use vision=no")
|
|
if vision is None:
|
|
# owner rule (2026-10-18): pictures are on by default wherever the model can read them and the engine has a reader; setup picks
|
|
# the reader (strata_control/vision_reader.py: the CPU under 24 GB, the GPU from 24 GB, every make of card; a Mac reads on its GPU).
|
|
can_read = md.get("vision", families[family].get("vision")) is not False
|
|
vision = "yes" if can_read else "no"
|
|
target = self.check_data_dir(data_dir) if data_dir else s.data_dir()
|
|
tag = (families[family].get("tag", "") + model)
|
|
have_dir = target / "models" / tag
|
|
partly = (have_dir.is_dir() and any(have_dir.glob("*.gguf*"))) or bool(gguf_dir) # a local folder: nothing to download
|
|
need = (8 if partly else md["download_gb"] + 8) + (1 if vision in ("yes", "gpu", "cpu") else 0) + (s.mac().repacked_gb(model) if mac and s.mac() else 0) # a Mac repacks Unsloth's shards into one file for the GPU (mac_fit.repacked_gb)
|
|
free = s.disk_free(target)
|
|
short = None
|
|
if free is not None and free < need:
|
|
short = (f"not enough free disk space for {model}: about {need:.0f} GB needed at {target}, "
|
|
f"{free:.0f} GB free; free some space or choose data_dir on a bigger drive (e.g. D:\\Strata-data)")
|
|
args = ["--yes", "--no-start", "--family", family, "--model", model, "--context", str(context),
|
|
"--vision", vision or "no"]
|
|
if family in ("qwen", "coder"):
|
|
args += ["--experimental-speed-projection", "off"]
|
|
own = bool(s.own_families().get(family))
|
|
for flag, v in (("--kv", kv), ("--gpu", gpu), ("--gpus", gpus), ("--low-ram", low_ram), ("--port", port),
|
|
("--data-dir", str(target) if data_dir else None)):
|
|
if own and flag in ("--kv", "--low-ram"): # Flash-Next settings: Qwen3.6's engine path does not use them (setup would warn)
|
|
continue
|
|
if v is not None:
|
|
args += [flag, str(v)]
|
|
if gguf_dir:
|
|
local = Path(os.path.expandvars(gguf_dir)).expanduser()
|
|
if not local.is_absolute() or not local.is_dir() or ".." in local.parts:
|
|
raise ToolError("gguf_dir must be an existing absolute folder")
|
|
args += ["--gguf-dir", str(local)]
|
|
if backend in ("cuda", "hip"):
|
|
args += ["--backend", backend]
|
|
if drafter in ("mtp", "dflash2"):
|
|
args += ["--drafter", drafter]
|
|
fit = None
|
|
ram = hw.get("ram_gb")
|
|
if ram is not None and md.get("multi_file"):
|
|
fit = s.dsv41_words(hw, model)
|
|
elif ram is not None:
|
|
need_ram = md["ram_gb"]
|
|
fit = "fits" if ram >= need_ram else f"needs ~{need_ram} GB of RAM, this PC has {ram:.0f} GB"
|
|
installed = (s.root / f"strata-{tag.lower()}.json").exists()
|
|
plan = {"title": f"{families[family]['title']} {model}", "family": family, "model": model,
|
|
"context": context, "images": vision or "no", "download_gb": md["download_gb"],
|
|
"partly_downloaded": partly, "ram": fit, "data_folder": str(target), "model_folder": str(have_dir),
|
|
"disk_free_gb": free,
|
|
"disk_needed_gb": round(need), "disk_short": short, "already_installed": installed,
|
|
"setup_command": ("START-HERE.bat " if WIN else "./setup.sh ") + " ".join(args),
|
|
"takes": "about 20-90 minutes, mostly the download; the PC stays usable",
|
|
"recommended_for_this_pc": rec if rec.get("model") else None}
|
|
if installed:
|
|
plan["note"] = ("this model is already installed: running setup again updates the engine and applies "
|
|
"the options (finished steps are skipped)")
|
|
if families[family].get("license"):
|
|
plan["license"] = families[family]["license"]
|
|
if families[family].get("experimental") or md.get("experimental"):
|
|
plan["experimental"] = True
|
|
return plan, args
|
|
|
|
def launch_install(self, plan: dict, args: list) -> dict:
|
|
s = self.s
|
|
s.state_dir.mkdir(parents=True, exist_ok=True)
|
|
job_id = f"{int(time.time())}-{os.getpid()}"
|
|
log_path = s.state_dir / "setup.log"
|
|
with open(log_path, "a", encoding="utf-8") as f:
|
|
f.write(f"\n===== strata-mcp: install {plan['title']} at {time.strftime('%Y-%m-%d %H:%M:%S')} =====\n")
|
|
spec = {"job": job_id, "root": str(s.root), "src": str(s.src), "control": CONTROL,
|
|
"python": s.run_python(), "base_python": sys.executable,
|
|
"create_venv": s.python_override is None and not s.venv_python().exists(),
|
|
"args": args, "result": str(s.state_dir / "install-result.json")}
|
|
spec_path = s.state_dir / "install-job.json"
|
|
spec_path.write_text(json.dumps(spec, indent=1), encoding="utf-8")
|
|
s.save_state("install-result", None)
|
|
env = dict(os.environ, PYTHONUNBUFFERED="1", PYTHONIOENCODING="utf-8")
|
|
p = spawn_detached([sys.executable, str(Path(__file__).resolve()), "--install-job", str(spec_path)],
|
|
s.root, log_path, env)
|
|
s.children.append(p)
|
|
time.sleep(0.2)
|
|
ident = proc_identity(p.pid)
|
|
if ident is None and p.poll() and s.state("install-result").get("job") != job_id:
|
|
raise ToolError("setup did not start: " + " | ".join(read_tail(log_path, 5)))
|
|
folder = plan.get("model_folder")
|
|
files = []
|
|
if s.setup_module() is not None and folder: # every file of this install, for a progress that counts all the parts
|
|
files = expected_files(s.setup_module(), plan["family"], plan["model"], Path(folder).parent,
|
|
plan.get("images") in ("yes", "gpu", "cpu"))
|
|
s.save_state("install", {"job": job_id, "pid": p.pid, "ident": ident, "log": str(log_path),
|
|
"model": plan["model"], "family": plan["family"], "folder": folder, "files": files,
|
|
"total_gb": plan.get("download_gb"),
|
|
"started": time.strftime("%Y-%m-%d %H:%M:%S"), "args": args})
|
|
return {"summary": f"Install of {plan['title']} started in the background (about 20-90 minutes, mostly the "
|
|
f"{plan['download_gb']:.0f} GB download). Call strata_install again to see the progress; "
|
|
"when it is done, start it with strata_start.",
|
|
"plan": plan, "log": str(log_path), "pid": p.pid}
|
|
|
|
# ---- strata_start
|
|
def strata_start(self, model=None, port=None, gpu=None, wait_seconds=50) -> dict:
|
|
s = self.s
|
|
with s.lock:
|
|
job = s.install_job()
|
|
if job and job["running"]:
|
|
raise ToolError("an install is still running (strata_install shows its progress); start the model "
|
|
"when it is done")
|
|
cfg_path = s.find_config(model)
|
|
desc = s.describe_config(cfg_path)
|
|
if not desc["ready"]:
|
|
raise ToolError(f"{cfg_path.name} refers to missing files ({', '.join(desc['missing_files'])}): run "
|
|
"the install again (strata_install with confirm=true) to repair it")
|
|
port = port or desc["port"] or DEFAULT_PORT
|
|
tracked = s.tracked_server()
|
|
if tracked and not tracked.get("ended"):
|
|
info = s.probe(tracked["port"])
|
|
return {"summary": f"Strata is already {'running' if info else 'loading'} ({tracked.get('model')}, "
|
|
f"port {tracked['port']}, started by this MCP server)"
|
|
+ ("" if tracked.get("model") == desc["model"] else
|
|
": stop it first (strata_stop) to start another model"),
|
|
"server": info}
|
|
info = s.probe(port, deep=False)
|
|
if info and not info.get("other"):
|
|
return {"summary": f"a Strata server already runs on port {port} ({info.get('model')}), started "
|
|
"outside this MCP server; stop it (close its window) or use another port",
|
|
"server": info}
|
|
if info or not port_free(port):
|
|
raise ToolError(f"port {port} is used by another program; choose another port (port=...)")
|
|
py = s.run_python()
|
|
if not Path(py).exists():
|
|
raise ToolError("Strata's Python environment (.venv) is missing: run the install again")
|
|
cmd = [py, str(s.root / "serve" / "server.py"), "--engine", "strata", "--config", str(cfg_path),
|
|
"--port", str(port)]
|
|
if gpu is not None:
|
|
cmd += ["--gpu", str(gpu)]
|
|
log_path = s.state_dir / "server.log"
|
|
s.state_dir.mkdir(parents=True, exist_ok=True)
|
|
with open(log_path, "a", encoding="utf-8") as f:
|
|
f.write(f"\n===== strata-mcp: start {desc['model']} on port {port} at "
|
|
f"{time.strftime('%Y-%m-%d %H:%M:%S')} =====\n")
|
|
env = dict(os.environ, PYTHONUNBUFFERED="1", PYTHONIOENCODING="utf-8")
|
|
try:
|
|
os.utime(cfg_path) # the most recently used model, as setup's start marks it
|
|
except OSError:
|
|
pass
|
|
p = spawn_detached(cmd, s.root, log_path, env)
|
|
s.children.append(p)
|
|
time.sleep(0.1)
|
|
ident = proc_identity(p.pid)
|
|
s.save_state("server", {"pid": p.pid, "ident": ident, "port": port, "model": desc["model"],
|
|
"config": cfg_path.name, "log": str(log_path),
|
|
"started": time.strftime("%Y-%m-%d %H:%M:%S")})
|
|
return self.wait_ready(p, ident, port, desc, wait_seconds, log_path)
|
|
|
|
def wait_ready(self, p, ident, port, desc, wait_seconds, log_path) -> dict:
|
|
s = self.s
|
|
end = time.time() + wait_seconds
|
|
while True:
|
|
if p.poll() is not None or not proc_alive(p.pid, ident):
|
|
tail = read_tail(log_path, 15)
|
|
end_group_leftovers(p.pid)
|
|
s.save_state("server", None)
|
|
raise ToolError(f"Strata stopped while starting (exit code {p.poll()}). Its last output:\n"
|
|
+ "\n".join(tail))
|
|
info = s.probe(port)
|
|
if info and not info.get("other"):
|
|
return {"summary": f"Strata is running: {info.get('model')} on http://127.0.0.1:{port}/v1 "
|
|
"(OpenAI and Anthropic APIs; chat page at http://127.0.0.1:" + str(port) + "/)",
|
|
"server": {**info, "pid": p.pid}}
|
|
if time.time() >= end:
|
|
return {"summary": f"Strata is loading {desc['model']} (this takes 1-3 minutes; the PC may be slow "
|
|
"meanwhile). Check with strata_status in a minute.",
|
|
"loading": {"pid": p.pid, "port": port,
|
|
"last_log_line": (read_tail(log_path, 1) or [None])[-1]}}
|
|
time.sleep(1.0)
|
|
|
|
# ---- strata_stop
|
|
def strata_stop(self, port=None, force=False) -> dict:
|
|
s = self.s
|
|
with s.lock:
|
|
tracked = s.tracked_server()
|
|
if tracked and tracked.get("ended"):
|
|
s.save_state("server", None)
|
|
tracked = None
|
|
if tracked:
|
|
port = tracked["port"]
|
|
key = s.api_key_for(port)
|
|
st, body = http_json(f"http://127.0.0.1:{port}/status", api_key=key, timeout=3)
|
|
if st == 200 and isinstance(body, dict) and body.get("busy") and not force:
|
|
raise ToolError("a request is running on Strata right now; wait for it, or call strata_stop "
|
|
"with force=true to stop anyway")
|
|
st, body = http_json(f"http://127.0.0.1:{port}/unload", "POST", api_key=key, timeout=120)
|
|
unloaded = (body or {}).get("status") if isinstance(body, dict) else None
|
|
if st == 409 and not force:
|
|
raise ToolError("Strata is busy (a request is running or queued); call again with force=true "
|
|
"to stop anyway")
|
|
proc_terminate(tracked["pid"], tracked["ident"]) # POSIX: SIGTERM = the server's own Ctrl+C path
|
|
clean = wait_gone(tracked["pid"], tracked["ident"], 30)
|
|
if not clean:
|
|
proc_kill_tree(tracked["pid"], tracked["ident"])
|
|
wait_gone(tracked["pid"], tracked["ident"], 15)
|
|
s.reap()
|
|
gone = not proc_alive(tracked["pid"], tracked["ident"])
|
|
if gone:
|
|
end_group_leftovers(tracked["pid"])
|
|
s.save_state("server", None)
|
|
return {"summary": "Strata stopped" + ("" if gone else " - but its process still runs; see strata_logs")
|
|
+ (" (the engine unloaded itself first)" if unloaded == "unloaded" else ""),
|
|
"model": tracked.get("model"), "port": port, "engine_unloaded": unloaded,
|
|
"ended_cleanly": clean}
|
|
for p in s.ports(port):
|
|
info = s.probe(p, deep=False)
|
|
if info and not info.get("other"):
|
|
key = s.api_key_for(p)
|
|
st, body = http_json(f"http://127.0.0.1:{p}/unload", "POST", api_key=key, timeout=120)
|
|
r = body.get("status") if isinstance(body, dict) else None
|
|
if st == 409:
|
|
raise ToolError("Strata is busy (a request is running); try again when it is done")
|
|
if st == 401:
|
|
raise ToolError("Strata (started outside this MCP server) wants an API key this folder's "
|
|
"configs do not have; close its window to stop it")
|
|
return {"summary": f"Strata on port {p} was started outside this MCP server (its own window or a "
|
|
f"service), so only its model was unloaded ({r}): its VRAM and RAM are free, "
|
|
"but the server still listens and loads the model again on the next request. "
|
|
"Close its window to end it completely.",
|
|
"port": p, "engine_unloaded": r}
|
|
return {"summary": "Strata is not running"}
|
|
|
|
# ---- strata_logs
|
|
def strata_logs(self, source=None, model=None, lines=80) -> dict:
|
|
s = self.s
|
|
if source is None:
|
|
job = s.install_job()
|
|
source = "setup" if job and job["running"] else "server"
|
|
if source == "setup":
|
|
path = s.state_dir / "setup.log"
|
|
elif source == "server":
|
|
path = s.state_dir / "server.log"
|
|
else:
|
|
cfg = s.find_config(model or (s.state("server").get("model")))
|
|
raw = s.read_config(cfg).get("log")
|
|
if not raw:
|
|
raise ToolError(f"{cfg.name} names no engine log")
|
|
path = Path(raw)
|
|
if not s.allowed(path):
|
|
raise ToolError(f"{cfg.name}'s log {raw} is outside the Strata folder and its data folder: not shown")
|
|
if not path.exists():
|
|
return {"summary": f"no {source} log yet ({path})", "log": str(path), "lines": []}
|
|
tail = read_tail(path, lines)
|
|
return {"summary": f"last {len(tail)} lines of the {source} log", "log": str(path), "lines": tail}
|
|
|
|
# ---- strata_benchmark
|
|
def strata_benchmark(self, port=None, max_tokens=200) -> dict:
|
|
s = self.s
|
|
target = None
|
|
for p in s.ports(port):
|
|
info = s.probe(p, deep=False)
|
|
if info and not info.get("other"):
|
|
target = info
|
|
break
|
|
if target is None:
|
|
raise ToolError("Strata is not running: start it first (strata_start)")
|
|
p = target["port"]
|
|
key = s.api_key_for(p)
|
|
st, stat = http_json(f"http://127.0.0.1:{p}/status", api_key=key, timeout=3)
|
|
if st == 200 and isinstance(stat, dict) and (stat.get("busy") or stat.get("queued")):
|
|
raise ToolError("Strata is busy with a request; run the speed test when it is idle")
|
|
body = {"model": target.get("model") or "strata", "max_tokens": max_tokens, "temperature": 0,
|
|
"reasoning_effort": "none", "stream": False,
|
|
"messages": [{"role": "user", "content": BENCH_PROMPT}]}
|
|
t0 = time.time()
|
|
st, r = http_json(f"http://127.0.0.1:{p}/v1/chat/completions", "POST", body, api_key=key, timeout=600)
|
|
wall = time.time() - t0
|
|
if st != 200 or not isinstance(r, dict):
|
|
msg = (r or {}).get("error", {}).get("message") if isinstance(r, dict) else None
|
|
raise ToolError(f"the request failed (HTTP {st}): {msg or 'no answer'}")
|
|
usage, tm = r.get("usage") or {}, r.get("timings") or {}
|
|
n = usage.get("completion_tokens") or tm.get("predicted_n") or 0
|
|
out = {"model": r.get("model"), "port": p, "prompt_tokens": usage.get("prompt_tokens"),
|
|
"output_tokens": n, "wall_s": round(wall, 2),
|
|
"output_tok_s": round(tm["predicted_per_second"], 1) if tm.get("predicted_per_second") else None,
|
|
"prompt_tok_s": round(tm["prompt_per_second"], 1) if tm.get("prompt_per_second") else None,
|
|
"wall_tok_s": round(n / wall, 1) if wall > 0 and n else None,
|
|
"finish_reason": ((r.get("choices") or [{}])[0]).get("finish_reason"),
|
|
"settings": f"greedy (temperature 0), thinking off, max_tokens {max_tokens}, a fixed story prompt"}
|
|
speed = out["output_tok_s"] or out["wall_tok_s"]
|
|
out["summary"] = (f"{n} tokens at {speed} tokens/s" + (f" (prompt {out['prompt_tok_s']} tokens/s)"
|
|
if out["prompt_tok_s"] else "")
|
|
+ f", {wall:.1f} s in total")
|
|
out["note"] = ("output speed depends on the context length, the GPU's free VRAM and what else the PC does; "
|
|
"the first request after a start is slower")
|
|
return out
|
|
|
|
# ---- strata_connect_info
|
|
def strata_connect_info(self, port=None) -> dict:
|
|
s = self.s
|
|
target = None
|
|
for p in s.ports(port):
|
|
info = s.probe(p, deep=False)
|
|
if info and not info.get("other"):
|
|
target = info
|
|
break
|
|
if port is None:
|
|
cfgs = s.configs()
|
|
port = target["port"] if target else (s.describe_config(cfgs[0])["port"] if cfgs else DEFAULT_PORT)
|
|
model = (target or {}).get("model") or "strata"
|
|
key_needed = (target or {}).get("api_key_required")
|
|
if key_needed is None:
|
|
key_needed = any(s.describe_config(c)["api_key_set"] for c in s.configs())
|
|
key = "<your api_key from strata-<model>.json>" if key_needed else "none"
|
|
base = f"http://127.0.0.1:{port}"
|
|
return {
|
|
"summary": (f"Strata {'runs' if target else 'will run'} at {base}: OpenAI base URL {base}/v1, Anthropic "
|
|
f"base URL {base}" + (" (an API key is required)" if key_needed else " (any API key works)")),
|
|
"running": bool(target), "openai_base_url": f"{base}/v1", "anthropic_base_url": base,
|
|
"anthropic_messages_endpoint": f"{base}/v1/messages", "chat_page": base + "/", "model": model,
|
|
"api_key": ("required: the \"api_key\" value in the Strata folder's strata-<model>.json"
|
|
if key_needed else "not required (send any value)"),
|
|
"examples": {
|
|
"claude_code": {"env": {"ANTHROPIC_BASE_URL": base, "ANTHROPIC_AUTH_TOKEN": key,
|
|
"ANTHROPIC_MODEL": "claude-sonnet-4-5"},
|
|
"note": "Claude Code needs a Claude model name it knows; Strata ignores the name"},
|
|
"codex_cli": {"file": "~/.codex/config.toml",
|
|
"toml": f'model = "{model}"\nmodel_provider = "strata"\n\n[model_providers.strata]\n'
|
|
f'name = "Strata"\nbase_url = "{base}/v1"\nwire_api = "chat"'},
|
|
"openai_python": (f'from openai import OpenAI\nclient = OpenAI(base_url="{base}/v1", api_key="{key}")\n'
|
|
f'r = client.chat.completions.create(model="{model}", messages=[{{"role": "user", '
|
|
f'"content": "Hello!"}}])\nprint(r.choices[0].message.content)'),
|
|
"curl": (f"curl {base}/v1/chat/completions -H \"Content-Type: application/json\" "
|
|
+ (f"-H \"Authorization: Bearer {key}\" " if key_needed else "")
|
|
+ f"-d '{{\"model\": \"{model}\", \"messages\": [{{\"role\": \"user\", \"content\": "
|
|
"\"Hello!\"}]}'"),
|
|
"openai_compatible_apps": {"base_url": f"{base}/v1", "api_key": key, "model": model,
|
|
"apps": "Cursor (Settings > Models > OpenAI base URL override), "
|
|
"Continue, Open WebUI, LM Studio clients, aider "
|
|
f"(--openai-api-base {base}/v1)"}},
|
|
"other_devices": "the server listens on this PC only; to reach it from your network, run setup with "
|
|
"--host 0.0.0.0 --api-key <secret> (docs/DETAILS.md, 'Using it')"}
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------ MCP over stdio
|
|
INSTRUCTIONS = ("Strata runs a large AI model (Qwen3.8-Flash-Next, a mixture of experts) locally on this PC's GPU and "
|
|
"RAM, with an OpenAI- and Anthropic-compatible server on http://127.0.0.1:8080. Call strata_status "
|
|
"first. To install: strata_install (it returns a plan; get the user's OK for the 58-111 GB download, "
|
|
"then call it with confirm=true; it runs in the background - call it again for progress). Then "
|
|
"strata_start, strata_stop, strata_logs for problems, strata_connect_info to point other apps at it.")
|
|
|
|
|
|
class McpServer:
|
|
def __init__(self, strata: Strata, out=None):
|
|
self.strata = strata
|
|
self.tools = Tools(strata)
|
|
self.out = out
|
|
self.write_lock = threading.Lock()
|
|
self.protocol = PROTOCOL
|
|
|
|
def send(self, msg: dict) -> None:
|
|
data = (json.dumps(msg, ensure_ascii=False) + "\n").encode("utf-8")
|
|
with self.write_lock:
|
|
self.out.write(data)
|
|
self.out.flush()
|
|
|
|
@staticmethod
|
|
def result(rid, result):
|
|
return {"jsonrpc": "2.0", "id": rid, "result": result}
|
|
|
|
@staticmethod
|
|
def error(rid, code, message):
|
|
return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}
|
|
|
|
def handle(self, msg) -> dict | None:
|
|
"""One JSON-RPC message -> its response (None for a notification or a response)."""
|
|
if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0":
|
|
return self.error(msg.get("id") if isinstance(msg, dict) else None, -32600, "invalid request")
|
|
if "method" not in msg:
|
|
return None # a response to something we never ask: ignored
|
|
method, rid, params = msg["method"], msg.get("id"), msg.get("params") or {}
|
|
if "id" not in msg:
|
|
return None # notifications (initialized, cancelled ...): nothing to say
|
|
if not isinstance(params, dict):
|
|
return self.error(rid, -32602, "params must be an object")
|
|
if method == "initialize":
|
|
asked = str(params.get("protocolVersion") or PROTOCOL)
|
|
self.protocol = asked if asked in PROTOCOLS else PROTOCOL
|
|
return self.result(rid, {"protocolVersion": self.protocol,
|
|
"capabilities": {"tools": {"listChanged": False}},
|
|
"serverInfo": {"name": "strata", "title": "Strata", "version": VERSION},
|
|
"instructions": INSTRUCTIONS})
|
|
if method == "ping":
|
|
return self.result(rid, {})
|
|
if method == "tools/list":
|
|
return self.result(rid, {"tools": self.tools.defs()})
|
|
if method == "tools/call":
|
|
name = params.get("name")
|
|
try:
|
|
res = self.tools.call(name, params.get("arguments"))
|
|
return self.result(rid, self.content(res, error=False))
|
|
except KeyError:
|
|
return self.error(rid, -32602, f"unknown tool: {name}")
|
|
except ToolError as e:
|
|
return self.result(rid, self.content({"error": str(e)}, error=True))
|
|
except Exception as e: # noqa: BLE001 - a bug must not end the server
|
|
log("tool failed:\n" + traceback.format_exc())
|
|
return self.result(rid, self.content({"error": f"internal error in {name}: {e}"}, error=True))
|
|
if method in ("resources/list", "prompts/list"):
|
|
return self.result(rid, {method.split("/")[0]: []})
|
|
return self.error(rid, -32601, f"method not found: {method}")
|
|
|
|
def content(self, res: dict, error: bool) -> dict:
|
|
text = json.dumps(res, indent=1, ensure_ascii=False)
|
|
out = {"content": [{"type": "text", "text": text}], "isError": error}
|
|
if self.protocol >= "2025-06-18":
|
|
out["structuredContent"] = res
|
|
return out
|
|
|
|
def serve(self, inp) -> None:
|
|
"""Read JSON-RPC lines until stdin closes; tool calls run in their own threads (a start waits for the model
|
|
while pings and status calls are still answered)."""
|
|
threads = []
|
|
for raw in inp:
|
|
line = raw.decode("utf-8", "replace").strip() if isinstance(raw, bytes) else raw.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
msg = json.loads(line)
|
|
except ValueError:
|
|
self.send(self.error(None, -32700, "parse error"))
|
|
continue
|
|
batch = isinstance(msg, list)
|
|
if batch or (isinstance(msg, dict) and msg.get("method") == "tools/call"):
|
|
t = threading.Thread(target=self._run, args=(msg,), daemon=True)
|
|
t.start()
|
|
threads.append(t)
|
|
threads = [x for x in threads if x.is_alive()]
|
|
else:
|
|
self._run(msg)
|
|
for t in threads:
|
|
t.join(timeout=5)
|
|
|
|
def _run(self, msg) -> None:
|
|
if isinstance(msg, list):
|
|
out = [r for r in (self.handle(m) for m in msg) if r is not None]
|
|
if out:
|
|
data = (json.dumps(out, ensure_ascii=False) + "\n").encode("utf-8")
|
|
with self.write_lock:
|
|
self.out.write(data)
|
|
self.out.flush()
|
|
return
|
|
r = self.handle(msg)
|
|
if r is not None:
|
|
self.send(r)
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------ the install job
|
|
def install_job(spec_path: str) -> int:
|
|
"""The background half of strata_install (this file started again with --install-job): make .venv like
|
|
START-HERE.bat / setup.sh do when it is missing, run setup, record its exit code."""
|
|
spec = json.loads(Path(spec_path).read_text(encoding="utf-8"))
|
|
root = Path(spec["root"])
|
|
result = Path(spec["result"])
|
|
|
|
def record(**kw):
|
|
try:
|
|
old = json.loads(result.read_text(encoding="utf-8")) if result.exists() else {}
|
|
except (OSError, ValueError):
|
|
old = {}
|
|
tmp = result.with_suffix(".tmp")
|
|
tmp.write_text(json.dumps({**old, "job": spec["job"], **kw}, indent=1), encoding="utf-8")
|
|
os.replace(tmp, result)
|
|
|
|
py = Path(spec["python"])
|
|
src = Path(spec.get("src") or root)
|
|
try:
|
|
if spec.get("create_venv"):
|
|
venv = root / ".venv"
|
|
if py.exists() and subprocess.run([str(py), "-m", "pip", "--version"], capture_output=True).returncode:
|
|
print("strata-mcp: .venv has no pip (an earlier run stopped half-way): making it again", flush=True)
|
|
shutil.rmtree(venv, ignore_errors=True)
|
|
if not py.exists():
|
|
print(f"strata-mcp: creating Strata's Python environment in {venv} ...", flush=True)
|
|
r = subprocess.run([spec["base_python"], "-m", "venv", str(venv)])
|
|
if r.returncode or not py.exists():
|
|
print("\n [X] could not create the Python environment (.venv)", flush=True)
|
|
print(" Linux: install python3-venv (sudo apt install python3-venv) or run ./setup.sh "
|
|
"once in a terminal", flush=True)
|
|
record(exit_code=1, ended=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
return 1
|
|
cmd = [str(py), str(src / "setup.py"), *spec["args"]]
|
|
print("strata-mcp: running " + " ".join(cmd), flush=True)
|
|
env = dict(os.environ, PYTHONUNBUFFERED="1", PYTHONIOENCODING="utf-8")
|
|
if spec.get("control"): # the app: the installer's own mode, writing to `root`
|
|
env.update(STRATA_CONTROL_INSTALL="1", STRATA_INSTALL_DIR=str(root), PYTHONNOUSERSITE="1")
|
|
for key in ("PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV"):
|
|
env.pop(key, None)
|
|
# This job is the explicit install the user asked for (it downloads model files and the preparation sources). The
|
|
# server that started it, and every start or serve, keep STRATA_NO_ACQUIRE; setup.py's CONTROL mode still runs
|
|
# no pip, git, compiler or engine fetch.
|
|
env.pop("STRATA_NO_ACQUIRE", None)
|
|
p = subprocess.Popen(cmd, cwd=str(src if spec.get("control") else root), stdin=subprocess.DEVNULL, env=env)
|
|
record(child_pid=p.pid, child_ident=proc_identity(p.pid))
|
|
rc = p.wait()
|
|
except Exception as e: # noqa: BLE001
|
|
print(f"\n [X] strata-mcp could not run setup: {e}", flush=True)
|
|
rc = 1
|
|
print(f"strata-mcp: setup ended with exit code {rc}", flush=True)
|
|
record(exit_code=rc, ended=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
return rc
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
ap = argparse.ArgumentParser(description="Strata's MCP server (stdio): install, start, check and stop Strata "
|
|
"from an AI assistant. See docs/MCP_SERVER.md.")
|
|
ap.add_argument("--root", help="the Strata folder (default: the one this file is in)")
|
|
ap.add_argument("--src", help="where setup.py, tools/ and serve/ are, when that is not the Strata folder (the app)")
|
|
ap.add_argument("--install-job", help=argparse.SUPPRESS)
|
|
a = ap.parse_args(argv)
|
|
if a.install_job:
|
|
return install_job(a.install_job)
|
|
if sys.version_info < (3, 10):
|
|
log("Python 3.10 or newer is needed")
|
|
return 2
|
|
root = Path(a.root).expanduser().resolve() if a.root else DEFAULT_ROOT
|
|
src = Path(a.src).expanduser().resolve() if a.src else root
|
|
if not (src / "setup.py").is_file() or not (src / "serve" / "server.py").is_file():
|
|
log(f"{src} is not a Strata folder (no setup.py / serve/server.py)")
|
|
return 2
|
|
out = sys.stdout.buffer
|
|
sys.stdout = sys.stderr # stray prints must never corrupt the protocol stream
|
|
server = McpServer(Strata(root, src), out)
|
|
log(f"ready (Strata folder {root})")
|
|
try:
|
|
server.serve(sys.stdin.buffer)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|