mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 06:15:58 -10:00
192 lines
9.0 KiB
Python
192 lines
9.0 KiB
Python
"""serve/runconfig.py - #564: the web page's Settings view of the run config (strata-<model>.json).
|
|
|
|
A short list of documented keys can be read and changed from Strata's own page (GET / POST /config). Everything
|
|
else in the file - the keys setup writes, the network and security keys (host, api_key, cors_origins,
|
|
trusted_origins, allowed_hosts), the MCP servers and before_load (which run programs), sampling keys not listed
|
|
here - is kept as it is: a change touches only the keys it names (as setup run again keeps the user's keys, #629).
|
|
The earlier file is kept as strata-<model>.json.bak. The server reads the config when it starts, so a change is
|
|
used from the next start on.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from strata_control import user_args
|
|
from strata_control import atomic as _atomic_mod
|
|
|
|
# (key, kind, help). kind: "bool", "int>=0", "num>=0", ("enum", values), "names", or ("sampling", check) for a key
|
|
# of the "sampling" block, ("arg", flag) for an engine option kept in "args".
|
|
EDITABLE = [
|
|
("sampling.temperature", ("sampling", "num>=0"),
|
|
"Default temperature for requests that send none (0 = greedy; without it the model card's: 1.0 thinking, 0.7 not)"),
|
|
("sampling.top_p", ("sampling", "0<x<=1"), "Default top_p for requests that send none"),
|
|
("sampling.top_k", ("sampling", "1..64"), "Default top_k for requests that send none (1-64)"),
|
|
("sampling.min_p", ("sampling", "0<=x<=1"), "Default min_p for requests that send none"),
|
|
("reasoning_budget_tokens", "int>=0", "Cap the thinking of every request at this many tokens (0 or empty: no cap)"),
|
|
("fit_max_tokens", "bool", "Shorten a max_tokens that does not fit the context (on by default); off: answer 400 like llama.cpp"),
|
|
("anthropic_thinking", ("enum", ["model", "on_request"]),
|
|
"Anthropic requests that do not ask for thinking: think as the model does (model) or not (on_request)"),
|
|
("effort_position", ("enum", ["start", "end"]),
|
|
"Where a non-default reasoning effort goes: start (the default) or end (keeps the cache when it changes)"),
|
|
("aliases", "names", "Other model names the server lists and answers to (comma-separated)"),
|
|
("idle_unload_s", "num>=0", "Unload the model after this many seconds without requests (0 or empty: never)"),
|
|
("mtp", "bool", "Draft layer (MTP): off starts the engine without it (about 1 GB less graphics memory, slower words per second); "
|
|
"empty or on: as installed"),
|
|
("lazy_load", "bool", "Start without loading the model; the first request loads it (text only)"),
|
|
("engine_silence_s", "num>=0", "End a request when the engine says nothing for this long (default 300 s, 0 = wait)"),
|
|
("api_monitor", "bool", "Keep the last 100 requests' prompts and answers in memory for /api-monitor"),
|
|
("open_browser", "bool", "Open the chat page in the browser when the model is ready"),
|
|
("vram_reserve_mib", ("arg", "--vram-reserve-mib"),
|
|
"VRAM in MiB the engine leaves free for other programs (engine default 700)"),
|
|
]
|
|
SPEC = {k: kind for k, kind, _ in EDITABLE}
|
|
|
|
|
|
def _arg(cfg: dict, flag: str):
|
|
a = cfg.get("args") if isinstance(cfg.get("args"), list) else []
|
|
if flag in a[:-1]:
|
|
v = str(a[a.index(flag) + 1])
|
|
return int(v) if v.isdigit() else v
|
|
return None
|
|
|
|
|
|
def value_of(cfg: dict, key: str):
|
|
kind = SPEC[key]
|
|
if isinstance(kind, tuple) and kind[0] == "sampling":
|
|
s = cfg.get("sampling")
|
|
return s.get(key.split(".", 1)[1]) if isinstance(s, dict) else None
|
|
if isinstance(kind, tuple) and kind[0] == "arg":
|
|
return _arg(cfg, kind[1])
|
|
return cfg.get(key)
|
|
|
|
|
|
def view(cfg: dict, path: str | Path) -> dict:
|
|
"""GET /config: the editable keys with their values (None: not set, the default applies)."""
|
|
out = []
|
|
for key, kind, help_ in EDITABLE:
|
|
k = kind[0] if isinstance(kind, tuple) else kind
|
|
out.append({"key": key, "value": value_of(cfg, key), "help": help_,
|
|
"kind": "number" if k in ("sampling", "arg", "int>=0", "num>=0") else k,
|
|
**({"choices": kind[1]} if k == "enum" else {})})
|
|
return {"file": Path(path).name, "keys": out,
|
|
"note": "Saved to the run config; used from the next start of the model."}
|
|
|
|
|
|
def _number(key, v, whole=False, lo=0.0, hi=None, lo_open=False):
|
|
if isinstance(v, bool) or not isinstance(v, (int, float)) or (whole and v != int(v)) or \
|
|
(v <= lo if lo_open else v < lo) or (hi is not None and v > hi):
|
|
rng = f"{'more than' if lo_open else 'at least'} {lo:g}" + (f" and at most {hi:g}" if hi is not None else "")
|
|
raise ValueError(f"{key}: expected a {'whole ' if whole else ''}number, {rng}, not {v!r}")
|
|
return int(v) if whole else v
|
|
|
|
|
|
def check(key: str, v, cfg: dict):
|
|
"""The value to store for `key`, or a ValueError naming what is expected. None removes the key (its default)."""
|
|
if key not in SPEC:
|
|
raise ValueError(f"{key!r} cannot be changed here (only the keys the Settings view lists)")
|
|
if v is None:
|
|
return None
|
|
kind = SPEC[key]
|
|
k = kind[0] if isinstance(kind, tuple) else kind
|
|
if k == "bool":
|
|
if not isinstance(v, bool):
|
|
raise ValueError(f"{key}: expected true or false, not {v!r}")
|
|
if key == "lazy_load" and v and cfg.get("vision"):
|
|
raise ValueError("lazy_load: lazy loading is text-only, and this model reads images (\"vision\")")
|
|
return v
|
|
if k == "enum":
|
|
if v not in kind[1]:
|
|
raise ValueError(f"{key}: expected one of {', '.join(kind[1])}, not {v!r}")
|
|
return v
|
|
if k == "names":
|
|
if isinstance(v, str):
|
|
v = [x.strip() for x in v.split(",") if x.strip()]
|
|
if not isinstance(v, list) or not all(isinstance(x, str) and x.strip() and len(x) <= 128 for x in v):
|
|
raise ValueError(f"{key}: expected a list of model names, not {v!r}")
|
|
return v or None
|
|
if k == "int>=0":
|
|
return _number(key, v, whole=True)
|
|
if k == "num>=0":
|
|
return _number(key, v)
|
|
if k == "arg":
|
|
return _number(key, v, whole=True)
|
|
rule = kind[1] # a sampling key: the server's own rules
|
|
if rule == "num>=0":
|
|
return float(_number(key, v))
|
|
if rule == "0<x<=1":
|
|
return float(_number(key, v, hi=1, lo_open=True))
|
|
if rule == "0<=x<=1":
|
|
return float(_number(key, v, hi=1))
|
|
return _number(key, v, whole=True, lo=1, hi=64)
|
|
|
|
|
|
def apply(cfg: dict, changes: dict) -> tuple[dict, list[str]]:
|
|
"""The config with `changes` ({key: value or None}) made, and the keys that changed. Every value is checked
|
|
first, so a request with one bad value changes nothing (ValueError)."""
|
|
if not isinstance(changes, dict) or not changes:
|
|
raise ValueError('send {"set": {"<key>": <value or null>, ...}}')
|
|
checked = {k: check(k, v, cfg) for k, v in changes.items()}
|
|
new = json.loads(json.dumps(cfg)) # a deep copy: the caller's dict is left alone
|
|
changed = []
|
|
for key, v in checked.items():
|
|
if value_of(new, key) == v:
|
|
continue
|
|
kind = SPEC[key]
|
|
if isinstance(kind, tuple) and kind[0] == "sampling":
|
|
s = dict(new.get("sampling") or {}) if isinstance(new.get("sampling"), dict) else {}
|
|
sub = key.split(".", 1)[1]
|
|
if v is None:
|
|
s.pop(sub, None)
|
|
else:
|
|
s[sub] = v
|
|
if s:
|
|
new["sampling"] = s
|
|
else:
|
|
new.pop("sampling", None)
|
|
elif isinstance(kind, tuple) and kind[0] == "arg":
|
|
a = list(new.get("args") or [])
|
|
flag = kind[1]
|
|
if flag in a[:-1]:
|
|
i = a.index(flag)
|
|
if v is None:
|
|
del a[i:i + 2]
|
|
else:
|
|
a[i + 1] = str(v)
|
|
elif v is not None:
|
|
a += [flag, str(v)]
|
|
new["args"] = a
|
|
mine = user_args.set_flag(user_args.capture(new), flag, v) # a value set here is the person's: it survives setup and the app
|
|
if mine:
|
|
new[user_args.USER_KEY] = mine
|
|
else:
|
|
new.pop(user_args.USER_KEY, None)
|
|
if user_args.WRITTEN_KEY in new:
|
|
new[user_args.WRITTEN_KEY] = list(a)
|
|
elif v is None:
|
|
new.pop(key, None)
|
|
else:
|
|
new[key] = v
|
|
changed.append(key)
|
|
return new, changed
|
|
|
|
|
|
def save(path: str | Path, cfg: dict) -> Path:
|
|
"""The config written whole (a temporary file moved over the old one), the earlier one kept as <name>.bak."""
|
|
path = Path(path)
|
|
bak = path.with_name(path.name + ".bak")
|
|
shutil.copyfile(path, bak)
|
|
tmp = path.with_name(path.name + ".tmp")
|
|
tmp.write_text(json.dumps(cfg, indent=1), encoding="utf-8")
|
|
_atomic_mod.replace(tmp, path)
|
|
return bak
|
|
|
|
|
|
def load(path: str | Path) -> dict:
|
|
cfg = json.loads(Path(path).read_text(encoding="utf-8-sig"))
|
|
if not isinstance(cfg, dict):
|
|
raise ValueError(f"{Path(path).name} is not a JSON object")
|
|
return cfg
|