mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-10 17:33:53 -10:00
4374 lines
248 KiB
Python
4374 lines
248 KiB
Python
"""serve/server.py - plan v0.3 P8: OpenAI and Anthropic endpoints over any engine that maps token ids to tokens.
|
|
|
|
python -m serve.server --engine mock --port 8095 (a scripted engine, for clients and tests)
|
|
python -m serve.server --engine strata --config strata.json --port 8080 (the real engine, resident)
|
|
|
|
Endpoints: POST /v1/chat/completions (OpenAI, stream and non-stream), POST /v1/messages (Anthropic, stream and
|
|
non-stream), GET /v1/models, GET /models, GET /props, GET /slots, GET /health, GET /mcp. One sequence at a time behind a FIFO (plan: one resident sequence).
|
|
Tools from MCP servers (serve/mcp.py, `"mcp_servers"` in the config or --mcp-config) are offered only to requests that
|
|
ask for them with `"strata_mcp": true` - the web app does; other clients see exactly the API they always saw.
|
|
Images (optional, when the config has a "vision" entry): OpenAI image_url parts and Anthropic image blocks (base64
|
|
data, http(s) URLs or local file paths) go through `strata-vision` (the model's mmproj file) and reach the engine as
|
|
embeddings (`GENI`). JPEG/PNG/BMP/GIF go straight in; WebP, TIFF, AVIF, ... (agents like omp send WebP) are
|
|
converted to PNG first with Pillow.
|
|
Requests whose prompt plus max tokens exceed the engine's context are REJECTED with 400, never truncated.
|
|
An unset (or 0, or -1) max tokens means "unlimited": whatever the prompt leaves of the context.
|
|
|
|
The engine boundary is `Engine.generate(prompt_ids, max_new, sampling, cancel) -> iterator of token ids`.
|
|
`StrataEngine` keeps one `strata --serve` process resident (weights, expert arena and VRAM tier load once) and
|
|
talks to it over stdin/stdout; `MockEngine` is a scripted stand-in that makes every API path testable without a GPU.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import collections
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import codecs
|
|
import ctypes
|
|
import json
|
|
import os
|
|
import queue
|
|
import re
|
|
import select
|
|
import signal
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import urllib.request
|
|
import uuid
|
|
from http.client import HTTPException
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from typing import Iterator, Protocol
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / "tools"))
|
|
sys.path.insert(0, str(ROOT)) # run as a script (run-<model>.bat) as well as a module
|
|
from serve.frontend import (ChatTemplate, Event, OutputParser, anthropic_to_messages, # noqa: E402
|
|
images_of, mark_think_literals, openai_to_messages, unmark_think_literals)
|
|
from serve.mcp import McpCancelled, hub_from_config # noqa: E402
|
|
from serve import runconfig # noqa: E402
|
|
from serve.winjob import contain # noqa: E402
|
|
from serve.structured import StructuredOutputError, prepare_format, validated_json # noqa: E402
|
|
from serve import responses as responses_api # noqa: E402
|
|
from serve.responses import ResponsesError, error_body as responses_error_body # noqa: E402
|
|
|
|
IM_END = "<|im_end|>"
|
|
IMAGE_PAD = "<|image_pad|>"
|
|
# #458: the trailing effort turn ("effort_position": "end"); low is the template's own sentence, medium (which the
|
|
# template says nothing for: no xhigh sentence is medium there) says it, since the xhigh one stays at the top
|
|
EFFORT_TURN = "<|im_start|>system\n{}<|im_end|>\n"
|
|
EFFORT_TEXT = {"low": "Reasoning effort is set to low. Keep your thinking brief and focused, moving directly to the "
|
|
"conclusion without unnecessary elaboration.",
|
|
"medium": "Reasoning effort is set to medium. Think as much as the task needs, without unnecessary "
|
|
"elaboration."}
|
|
VISION_START = "<|vision_start|>"
|
|
# #606: a reply that repeats one token this many times in a row is ended there ("length"): a model in a loop, or a
|
|
# broken state that answers one token forever (an issue saw 36,689 tokens of "!"). The config's "repeat_stop_tokens"
|
|
# sets it; 0 turns it off.
|
|
REPEAT_STOP_TOKENS = 256
|
|
# #123: what closes the thinking when it reaches reasoning_budget_tokens (the model's own end-of-thinking tag after it)
|
|
REASONING_WRAP_UP = "\n\nI have thought about this long enough; time to give my answer.\n</think>\n\n"
|
|
LOOPBACK_NAMES = ("localhost", "127.0.0.1", "::1")
|
|
CTX_SLACK = 8 # `strata --serve` rejects prompt + max_new + 8 > context: keep the same margin here
|
|
# The live tok/s is a rate over a window, not a mean since the first token: a mean reads ~1/elapsed at the first
|
|
# token (the Monitor showed five-digit numbers) and then undershoots for the first second of every answer.
|
|
RATE_WINDOW_S = 2.0
|
|
RATE_MIN_SPAN_S = 0.25 # younger than this there is no rate yet: the mean so far, with the span floored here
|
|
# #481: a running request whose engine prints nothing (no T, PP or any other line) for this long has lost step with the
|
|
# server (the engine's main thread waits, untimed, for its next command): the engine is ended and the request fails;
|
|
# the next request starts it again. The config's "engine_silence_s" sets it (0: wait forever, as before).
|
|
ENGINE_SILENCE_S = 300.0
|
|
# ... except while a prompt is read: a PP line comes once per chunk (up to 32768 tokens with --prefill auto, issue
|
|
# #282), and the slowest PCs read ~100 tok/s, so a first chunk can take minutes before the first line. Until the first
|
|
# PP the wait adds the chunk's tokens at PP_FLOOR_TOK_S; after one, a chunk may take PP_SLACK x the last one's time.
|
|
PP_CHUNK_MAX = 32768
|
|
PP_FLOOR_TOK_S = 50.0
|
|
PP_SLACK = 3.0
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------ engines
|
|
class Engine(Protocol):
|
|
max_context: int
|
|
def generate(self, ids: list[int], max_new: int, sampling: dict, cancel: threading.Event) -> Iterator[int]: ...
|
|
|
|
|
|
class MockEngine:
|
|
"""Replays a scripted completion (text) as token ids, one per step, then the end-of-turn token. Given a list of
|
|
scripts, each request gets the next one and the last one repeats (a tool call, then the answer after it)."""
|
|
|
|
def __init__(self, tokenizer, script: str | list[str], max_context: int = 32768, delay_s: float = 0.0):
|
|
self.tok, self.max_context, self.delay = tokenizer, max_context, delay_s
|
|
end = tokenizer.encode(IM_END, parse_special=True)
|
|
self.scripts = [tokenizer.encode(x, parse_special=True) + end for x in ([script] if isinstance(script, str)
|
|
else script)]
|
|
self.script, self.turns = self.scripts[0], 0
|
|
self.last_prompt: list[int] = []
|
|
|
|
def generate(self, ids, max_new, sampling, cancel, embeddings=None):
|
|
self.last_prompt = list(ids)
|
|
self.last_embeddings = embeddings
|
|
if len(self.scripts) > 1:
|
|
self.script = self.scripts[min(self.turns, len(self.scripts) - 1)]
|
|
self.turns += 1
|
|
for t in self.script[:max_new]:
|
|
if cancel.is_set():
|
|
return
|
|
if self.delay:
|
|
time.sleep(self.delay)
|
|
yield t
|
|
|
|
|
|
class EngineDied(RuntimeError):
|
|
"""The engine process ended in the middle of a request (issue #27: on Linux, the out-of-memory killer)."""
|
|
|
|
|
|
class EngineStarting(RuntimeError):
|
|
"""The engine is (re)starting and has not said READY yet (#344): no context size to plan a request with - a 503,
|
|
not a 400 about the prompt."""
|
|
|
|
|
|
class ModelBusy(RuntimeError):
|
|
"""Explicit model controls must not interrupt active or queued requests."""
|
|
|
|
|
|
class EngineSilent(EngineDied):
|
|
"""#481: the engine said nothing for too long during a request (or never acknowledged a STOP): the two sides lost
|
|
step - the engine waiting for its next command, the server for this request's end - and the server ended it. An
|
|
EngineDied, so the request ends with an error and the next one starts the engine again."""
|
|
|
|
|
|
class EngineStuck(RuntimeError):
|
|
"""The engine process did not end after QUIT, terminate and kill: the server keeps it (and says so) rather than
|
|
reporting its GPU and RAM as given back."""
|
|
|
|
|
|
class GpuBusy(RuntimeError):
|
|
"""The model is unloaded and the GPU has less free VRAM than min_free_vram_mib: something else (a game, another
|
|
model server) is using it, so the engine is not started into the little that is left."""
|
|
|
|
|
|
ENGINE_REQUEST = re.compile(
|
|
# "+ 12288 of 98179 read": a request cancelled while its prompt was read (#471)
|
|
r"prompt (?P<prompt>\d+) tokens = (?P<reused>\d+) reused \+ \d+(?: of \d+)? read in (?P<read>[\d.]+) ms "
|
|
r"\((?P<pp>[\d.]+) tok/s\), "
|
|
r"(?P<gen>\d+) generated in (?P<gen_ms>[\d.]+) ms \((?P<tg>[\d.]+) tok/s\)")
|
|
|
|
|
|
_echoing: set[str] = set() # the logs echo_requests already follows (restart() runs StrataEngine.__init__ again)
|
|
|
|
DRAFT_HEAD_FAIL = "the draft head does not fit"
|
|
DRAFT_HEAD_HINT = ("a smaller draft vocabulary needs less VRAM: --draft-vocab cyrillic (English, code and the Cyrillic "
|
|
"script) or --draft-vocab en (English and code, ~215 MiB less than the default). Start once with "
|
|
"it - START-HERE.bat --draft-vocab en (Windows) or ./setup.sh --draft-vocab en - and the model "
|
|
"keeps it; or a smaller --context in setup.")
|
|
|
|
|
|
def start_failure_hint(log: str | None, offset: int) -> str:
|
|
"""#474: what to change when the engine stopped at the start because the MTP draft head did not fit the VRAM
|
|
left: the engine's own `strata mtp:` lines after that failure (0.1.36+: what it needs, what is free, the smaller
|
|
subsets), else the same advice in words for an older engine. "" for any other failure: the log says why."""
|
|
if not log:
|
|
return ""
|
|
try:
|
|
with open(log, "rb") as f:
|
|
f.seek(offset)
|
|
text = f.read().decode("utf-8", "replace")
|
|
except (OSError, ValueError):
|
|
return ""
|
|
if DRAFT_HEAD_FAIL not in text:
|
|
return ""
|
|
said = [x.strip()[len("strata mtp: "):] for x in text.splitlines()
|
|
if x.strip().startswith("strata mtp: ") and ("draft head over" in x or "hint:" in x)]
|
|
return ". mtp: " + DRAFT_HEAD_FAIL + ". " + (" ".join(said) if said else "Hint: " + DRAFT_HEAD_HINT)
|
|
|
|
|
|
def start_log_tail(log: str | None, offset: int, n: int = 20) -> str:
|
|
"""#496: the last n lines this start wrote to the engine log, for the error when the engine ended before READY -
|
|
whatever the failure, the engine's own reason is in them (people posted the traceback without the log). "" when
|
|
there is no log or nothing in it from this start."""
|
|
if not log:
|
|
return ""
|
|
try:
|
|
with open(log, "rb") as f:
|
|
f.seek(0, 2)
|
|
start = max(offset, f.tell() - 64 * 1024) # enough for 20 lines, never an earlier start's
|
|
f.seek(start)
|
|
text = f.read().decode("utf-8", "replace")
|
|
except (OSError, ValueError):
|
|
return ""
|
|
lines = text.splitlines()[1 if start > offset else 0:] # not a line cut in half
|
|
lines = [x.rstrip() for x in lines if x.strip()][-n:]
|
|
if not lines:
|
|
return ""
|
|
return "\nthe engine log's last lines:\n" + "\n".join(" " + x for x in lines)
|
|
|
|
|
|
def echo_requests(log_path: str, offset: int) -> None:
|
|
"""STRATA_REQUEST_LINES=1: one stdout line per finished request, from the engine's own summary in its log.
|
|
|
|
The engine's stderr goes to the log file (the start narrator reads it), so a supervisor that only sees this
|
|
process's output - a tray, llama-swap - has no per-request numbers. This re-states the engine's line with the
|
|
total the two times make: `request prompt P cached C output O prompt_read R ms total S ms prefill X tok/s decode Y
|
|
tok/s` (prompt_read: the time the engine spent reading the prompt's new tokens, not a time to first token).
|
|
"""
|
|
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
|
f.seek(offset)
|
|
while True:
|
|
line = f.readline()
|
|
if not line:
|
|
time.sleep(0.2)
|
|
continue
|
|
m = ENGINE_REQUEST.search(line)
|
|
if m:
|
|
read_ms, gen_ms = float(m["read"]), float(m["gen_ms"])
|
|
print("[strata] request prompt %s cached %s output %s prompt_read %.0f ms total %.0f ms prefill %s "
|
|
"tok/s decode %s tok/s" % (m["prompt"], m["reused"], m["gen"], read_ms, read_ms + gen_ms, m["pp"],
|
|
m["tg"]), flush=True)
|
|
|
|
|
|
def experts_loading_words(args: list, size: str) -> str:
|
|
"""#505: what the start does with the experts, by the engine's flags (generate.cpp's option parsing): a RAM budget
|
|
copies the hottest N GiB into RAM (--resident-budget-gib), the resident low-RAM mode the ones the GPU does not hold
|
|
(--resident-experts), plain --mmap-experts reads them from the model files through the OS file cache (nothing is
|
|
loaded into RAM up front); otherwise all of them go into RAM."""
|
|
if "--resident-budget-gib" in args:
|
|
try:
|
|
n = f"up to {float(args[args.index('--resident-budget-gib') + 1]):g} GiB"
|
|
except (IndexError, ValueError):
|
|
n = "a RAM budget"
|
|
return (f"loading the most-used experts into RAM ({n}; the rest are read from the model files as needed) "
|
|
"and locking part of them for the GPU.")
|
|
if "--resident-experts" in args:
|
|
return (f"loading the experts the GPU does not hold into RAM (of {size}) and locking part of them for the "
|
|
"GPU.")
|
|
if "--mmap-experts" in args:
|
|
return (f"mapping the experts from the model files ({size}, --mmap-experts): they are not loaded into RAM - "
|
|
"the OS file cache reads them as the GPU's expert cache fills and as requests need them.")
|
|
return f"loading the experts into RAM ({size}) and locking part of them for the GPU."
|
|
|
|
|
|
def narrate_start(log_path: str, offset: int, args: list, done: threading.Event, heartbeat=20.0) -> None:
|
|
"""While the engine starts, say in the server window what it is doing, from its log: the start reads tens of GB
|
|
into RAM and locks part of it for the GPU, and on many PCs everything is slow or frozen for a minute or more -
|
|
people closed the window thinking it had hung. The warning comes at that step, not after it."""
|
|
gb = 0.0
|
|
if "--native" in args: # about the size of the experts it will read
|
|
try:
|
|
gb = os.path.getsize(args[args.index("--native") + 1]) / 1e9
|
|
except (OSError, IndexError):
|
|
pass
|
|
size = f"about {gb:.0f} GB" if gb >= 1 else "tens of GB"
|
|
loading = experts_loading_words(args, size)
|
|
t0 = last = time.time()
|
|
said = set()
|
|
|
|
def say(key, text):
|
|
nonlocal last
|
|
if key not in said:
|
|
said.add(key)
|
|
last = time.time()
|
|
print(text, flush=True)
|
|
|
|
say("weights", "[strata] starting the engine: reading the model's weights ...")
|
|
pos = offset
|
|
while not done.wait(0.5):
|
|
try:
|
|
with open(log_path, "rb") as f:
|
|
f.seek(pos)
|
|
chunk = f.read()
|
|
except OSError:
|
|
chunk = b""
|
|
if chunk.count(b"\n"):
|
|
cut = chunk.rfind(b"\n") + 1
|
|
pos += cut
|
|
for line in chunk[:cut].decode("utf-8", "replace").splitlines():
|
|
if "PLE on" in line or "expert arena:" in line or "experts via mmap" in line: # #505: mapped
|
|
say("arena", f"[strata] {loading}\n"
|
|
" YOUR PC CAN BE SLOW OR STOP RESPONDING FOR 1-3 MINUTES NOW - this is normal.\n"
|
|
" Please wait and don't close this window; the browser opens when it is ready.")
|
|
elif " loaded " in line and "GiB at" in line:
|
|
say("loaded", "[strata] experts loaded: " + line.split(" loaded ", 1)[1].strip() +
|
|
f" ({time.time() - t0:.0f} s so far)")
|
|
elif "expert cache " in line and " slots, " in line and "auto" not in line:
|
|
n = line.split("expert cache ", 1)[1].split(";")[0].replace(" slots,", " experts,").strip()
|
|
say("cache", f"[strata] filling the GPU's expert cache ({n}) ...")
|
|
elif "session is up" in line:
|
|
say("up", "[strata] almost ready ...")
|
|
if time.time() - last > heartbeat:
|
|
last = time.time()
|
|
print(f"[strata] still starting ({time.time() - t0:.0f} s) - please wait ...", flush=True)
|
|
|
|
|
|
class ConvCacheLog:
|
|
"""#596: the engine's conversation cache as its log tells it (the engine writes "strata serve: conversation
|
|
cache: parked N tokens ...; parked=P bytes=B evictions=E" and "restored N tokens ...; parked=P bytes=B" to stderr,
|
|
which is the log): read on from where it was last read, from the start of the engine's current run."""
|
|
EVENT = re.compile(r"conversation cache: (parked|skipped|restored) (\d+) tokens.*?parked=(\d+) bytes=(\d+)"
|
|
r"(?: evictions=(\d+))?")
|
|
DROPPED = re.compile(r"conversation cache: dropped \d+ superseded .*?parked=(\d+)")
|
|
READ_MAX = 1 << 20 # at most the last MiB of new lines per read
|
|
|
|
def __init__(self):
|
|
self.key, self.pos = None, 0
|
|
self.reset()
|
|
|
|
def reset(self):
|
|
self.state = {"parked": 0, "bytes": 0, "evictions": 0, "parks": 0, "restores": 0, "last_event": None,
|
|
"last_tokens": None, "last_at": None}
|
|
|
|
def poll(self, path, start) -> dict:
|
|
"""The state after the log's new lines; `start` is where the engine's current run began in it."""
|
|
if not path or start is None:
|
|
return dict(self.state)
|
|
if self.key != (path, start): # another start of the engine: its cache starts empty
|
|
self.key, self.pos = (path, start), start
|
|
self.reset()
|
|
try:
|
|
size = os.path.getsize(path)
|
|
if size < self.pos: # the log was emptied or replaced
|
|
self.pos = 0
|
|
if size - self.pos > self.READ_MAX:
|
|
self.pos = size - self.READ_MAX
|
|
with open(path, "rb") as f:
|
|
f.seek(self.pos)
|
|
data = f.read(size - self.pos)
|
|
except OSError:
|
|
return dict(self.state)
|
|
end = data.rfind(b"\n") + 1 # whole lines only: the rest is read next time
|
|
self.pos += end
|
|
now = time.time()
|
|
for line in data[:end].decode("utf-8", "replace").splitlines():
|
|
if "conversation cache:" not in line:
|
|
continue
|
|
m = self.EVENT.search(line)
|
|
if m:
|
|
st = self.state
|
|
st["parked"], st["bytes"] = int(m.group(3)), int(m.group(4))
|
|
if m.group(5) is not None:
|
|
st["evictions"] = int(m.group(5))
|
|
if m.group(1) != "skipped":
|
|
st["parks" if m.group(1) == "parked" else "restores"] += 1
|
|
st["last_event"], st["last_tokens"], st["last_at"] = m.group(1), int(m.group(2)), now
|
|
continue
|
|
m = self.DROPPED.search(line)
|
|
if m:
|
|
self.state["parked"] = int(m.group(1))
|
|
return dict(self.state)
|
|
|
|
|
|
def conversation_cache_view(info: dict, hist: list, totals: dict, parked: dict) -> dict:
|
|
"""#596: the Monitor's Conversation cache card: the parked conversations (the engine's opt-in
|
|
--conversation-cache-mib: budget, slots, what its log says) and how much of the prompts the cache gave back."""
|
|
mib = info.get("conversation_cache_mib")
|
|
last = hist[-1] if hist else None
|
|
return {"enabled": isinstance(mib, int) and mib > 0, "budget_mib": mib if isinstance(mib, int) else None,
|
|
"slots": info.get("conversation_cache_slots"), **parked,
|
|
"requests": len(hist), "requests_reused": sum(1 for r in hist if (r.get("reused") or 0) > 0),
|
|
"reused_tokens": totals.get("reused", 0), "prompt_tokens": totals.get("prompt_tokens", 0),
|
|
"last_reused": last.get("reused") if last else None,
|
|
"last_prompt": last.get("prompt_tokens") if last else None}
|
|
|
|
|
|
_BTRACE = bool(os.environ.get("STRATA_BATCH_TRACE"))
|
|
|
|
|
|
def btrace(*a):
|
|
if _BTRACE:
|
|
print("[batch-trace]", threading.get_ident() % 10000, *a, file=sys.stderr, flush=True)
|
|
|
|
|
|
EOS_IDS = {248044, 248046} # <|endoftext|>, <|im_end|>: the engine's default --eos-ids
|
|
|
|
|
|
# The engine's own words for a verifier that cannot go on (its GPU waits were released, or an earlier window never
|
|
# finished, #267): the process may stay alive and even print DONE, but it must not take another request.
|
|
FATAL_PREFIXES = ("ERR verify: timed out at layer ", "ERR verify batch: timed out at layer ",
|
|
"ERR verify: an earlier window never finished", "ERR verify batch: an earlier window never finished")
|
|
|
|
|
|
class StrataEngine:
|
|
"""The resident engine: `strata --serve` reads `GEN <max_new> <ids>` lines and streams `T <id>` lines, then
|
|
`DONE ...`. Requests are serialized by the service's FIFO, so one pipe is enough.
|
|
|
|
Per-request sampling rides the same line as engine-side keys between max_new and the ids
|
|
(`temperature=F top_p=F top_k=N seed=N`, the engine's own spelling). An absent temperature keeps the
|
|
engine's default, which is greedy; `temperature=0` means the same thing, so it is not forwarded.
|
|
"""
|
|
silence_s = ENGINE_SILENCE_S # #481: main() sets the config's engine_silence_s (survives restart())
|
|
silent_note = None # #481: why the server ended a silent engine (death_note says it)
|
|
batch = 0 # --batch: the engine's batch slots (0: one request at a time)
|
|
|
|
# `last`: the figures of the engine's last DONE line. With batch slots several requests run at once, each on its
|
|
# own thread, so each one reads its own request's figures (Service.run records them); one at a time, the one dict.
|
|
@property
|
|
def last(self):
|
|
if self.batch:
|
|
tl = self.__dict__.setdefault("_tl", threading.local())
|
|
if not hasattr(tl, "last"):
|
|
tl.last = {}
|
|
return tl.last
|
|
return self.__dict__.get("_last", {})
|
|
|
|
@last.setter
|
|
def last(self, value):
|
|
self.__dict__["_last"] = value
|
|
if self.batch:
|
|
tl = self.__dict__.setdefault("_tl", threading.local())
|
|
tl.last = value
|
|
|
|
def __init__(self, exe: str, args: list[str], cwd: str | None = None, log: str | None = None,
|
|
env: dict | None = None, lazy: bool = False):
|
|
self.spawn = (exe, list(args), cwd, log, env) # to start it again after it died (issue #27)
|
|
paths = {k: v for k, v in zip(args, args[1:]) if k in ("--native", "--pack")}
|
|
self.model_path = paths.get("--native") or paths.get("--pack", "pack/full")
|
|
self.log_path = log
|
|
self.proc, self.pump, self.log = None, None, None
|
|
self.ended, self.unloaded = True, True
|
|
self.max_context = int(args[args.index("--max-context") + 1]) if "--max-context" in args else 4096
|
|
self.can_stop = False # the engine honours a STOP line mid-request (READY <ctx> stop)
|
|
self.last = {}
|
|
self.info = {} # INFO key=value facts (engine 0.1.8+): kv, expert slots, ... (Monitor tab)
|
|
self.prefill_tok_s_mean = None
|
|
self.progress = None # (read, total) prompt tokens while a prompt is read, from PP lines
|
|
self.silent_note = None
|
|
try: # a ready-made engine's BUILD.json says its version
|
|
self.info["version"] = json.loads((Path(exe).parent / "BUILD.json").read_text()).get("version")
|
|
except (OSError, ValueError):
|
|
self.info["version"] = None
|
|
if lazy:
|
|
return
|
|
self.unloaded = False # `ended` stays True until READY (below): not alive while starting (#344)
|
|
self.log = open(log, "a", encoding="utf-8") if log else subprocess.DEVNULL
|
|
loading = threading.Event() # set once READY: the narrator below stops
|
|
log_start = os.path.getsize(log) if log else 0 # where this start's lines begin (start_failure_hint)
|
|
self.log_start = log_start # #596: the Monitor's conversation cache reads from here
|
|
if log:
|
|
threading.Thread(target=narrate_start, args=(log, os.path.getsize(log), args, loading),
|
|
daemon=True).start()
|
|
# once per log: the follower keeps reading the same (appended) log across restarts and reloads
|
|
if os.environ.get("STRATA_REQUEST_LINES") and os.path.abspath(log) not in _echoing:
|
|
_echoing.add(os.path.abspath(log))
|
|
threading.Thread(target=echo_requests, args=(log, os.path.getsize(log)), daemon=True).start()
|
|
self.proc = subprocess.Popen([exe, "--serve", *args], cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
stderr=self.log, text=True, encoding="utf-8", bufsize=1, env=env)
|
|
contain(self.proc) # ends with the server, however it ends (Windows)
|
|
self.max_context = 0
|
|
for line in self.proc.stdout:
|
|
if line.startswith("INFO "):
|
|
for kv in line.split()[1:]:
|
|
k, _, v = kv.partition("=")
|
|
self.info[k] = int(v) if v.lstrip("-").isdigit() else v
|
|
if line.startswith("READY"):
|
|
f = line.split()
|
|
self.max_context = int(f[1])
|
|
self.can_stop = "stop" in f[2:]
|
|
break
|
|
loading.set()
|
|
if self.max_context <= 0:
|
|
try: # its pipes and our handle on its log (the log stays)
|
|
self.proc.wait(timeout=5)
|
|
self.proc.stdin.close()
|
|
self.proc.stdout.close()
|
|
if log:
|
|
self.log.close()
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
pass
|
|
raise RuntimeError("the engine exited before it was ready" + (f" (see {log})" if log else "") +
|
|
start_failure_hint(log, log_start) + start_log_tail(log, log_start))
|
|
self.known_ctx = self.max_context # survives a failed restart: requests keep their limit and restart it
|
|
# (from PR #41, midhatn) a locally built engine can sit next to another release's BUILD.json: engines that
|
|
# report their own version (INFO engine=, 0.1.8+) win, the manifest stays the fallback for older ones
|
|
if self.info.get("engine"):
|
|
self.info["version"] = str(self.info["engine"])
|
|
self.ended = False # READY: alive from here (restart() set it True, #344)
|
|
# the engine's stdout on a thread, so a request can wait with a timeout (heartbeats, cancel checks)
|
|
self.lines: queue.Queue = queue.Queue()
|
|
# --batch: concurrent requests in the engine's batch slots (see generate_batched). The engine says how many
|
|
# it runs (INFO batch_slots=N: it may have fewer than asked, or none, when they do not fit)
|
|
asked = next((int(args[args.index(k) + 1]) for k in ("--batch", "--slots") if k in args), 0)
|
|
self.batch = int(self.info.get("batch_slots") or 0)
|
|
if asked and self.batch != asked:
|
|
print(f"[strata] parallel requests: {asked} asked, the engine runs {self.batch or 'one at a time'} "
|
|
"(its log says why)", flush=True)
|
|
groups = int(args[args.index("--batch-groups") + 1]) if "--batch-groups" in args else 1
|
|
groups = groups if self.batch and groups > 0 and self.batch % groups == 0 else 1
|
|
gs = self.batch // groups if self.batch else 0
|
|
# slots in the order that spreads requests over the pipeline's groups first: 0, gs, 2gs, .., 1, gs+1, ..
|
|
self.slot_order = [g * gs + t for t in range(gs) for g in range(groups)]
|
|
self.slot_q = [queue.Queue() for _ in range(self.batch)]
|
|
self.slot_busy = [False] * self.batch
|
|
# what each slot's sessions hold (prompt + every token a window fed), so a conversation's next turn goes to
|
|
# the slot that has its start (the engine checks it again); when the slot was last used
|
|
self.slot_held: list[list[int]] = [[] for _ in range(self.batch)]
|
|
self.slot_used = [0.0] * self.batch
|
|
self.slot_live: list[dict | None] = [None] * self.batch # /metrics: the request in each slot
|
|
self.slot_cv = threading.Condition()
|
|
self.waiting = 0 # requests waiting for the control lines (ctl)
|
|
self.wait_lens: list[list[int]] = [] # ... their prompt lengths (a long read gives way to short ones)
|
|
self.ctl_epoch = 0 # how often the control lines were taken
|
|
self._yielded = None # (slot, tokens read): the last request on them gave way
|
|
self.ctl = threading.Lock() # one admission or solo request on the control lines at a time
|
|
self.wlock = threading.Lock() # stdin writes from several request threads
|
|
self.pump = threading.Thread(target=self._pump, daemon=True)
|
|
self.pump.start()
|
|
|
|
def _pump(self):
|
|
proc, lines = self.proc, self.lines # this process's: a restart replaces both (#344)
|
|
slot_q = self.slot_q
|
|
for line in proc.stdout:
|
|
# checked before batch routing: a fatal line is never a slot's own
|
|
if line.startswith(FATAL_PREFIXES):
|
|
# release_gpu_waits invalidates the verifier, even if the native
|
|
# process stays alive and prints DONE afterwards.
|
|
if self.proc is proc:
|
|
self.silent_note = "Unrecoverable native verification failure: " + line[4:].strip()
|
|
self.ended = True
|
|
try:
|
|
proc.terminate()
|
|
except OSError:
|
|
pass
|
|
break # publish EOF, never the trailing DONE
|
|
if line.startswith(("BT ", "BDONE ")) and slot_q: # --batch: a batch slot's own lines
|
|
try:
|
|
slot_q[int(line.split()[1])].put(line)
|
|
continue
|
|
except (IndexError, ValueError):
|
|
pass
|
|
lines.put(line)
|
|
if self.proc is proc: # a killed engine's pump must not mark its successor dead
|
|
self.ended = True # its output closed: it is gone, even before the OS says so
|
|
lines.put(None)
|
|
for q in slot_q:
|
|
q.put(None)
|
|
|
|
def death_note(self) -> str:
|
|
"""Why the engine most likely ended, from the end of its log: its own watchdog (issue #29), else RAM."""
|
|
if getattr(self, "silent_note", None): # #481: the server ended it, not the OS or the engine itself
|
|
return self.silent_note
|
|
tail = ""
|
|
try:
|
|
with open(self.log_path, "rb") as f:
|
|
f.seek(0, 2)
|
|
f.seek(max(0, f.tell() - 4096))
|
|
tail = f.read().decode("utf-8", "replace")
|
|
except (OSError, TypeError):
|
|
pass
|
|
for line in reversed(tail.splitlines()):
|
|
if "issue #29" in line:
|
|
return ("The engine stopped itself because it had stopped making progress - a hang it caught. Its log "
|
|
"line: " + line.strip() + " - please report it at github.com/Niko1221/Strata/issues.")
|
|
rc = self.proc.poll()
|
|
last = next((x.strip() for x in reversed(tail.splitlines()) if x.strip().startswith(("strata", "ERR"))), "")
|
|
if rc is not None and rc >= 0 and last: # it ended by itself: its own last words say why (#215)
|
|
return (f"The engine exited (code {rc}). Its last log line: {last} - if that does not explain it, please "
|
|
"report it at github.com/Niko1221/Strata/issues with the log.")
|
|
return ("The usual cause is running out of RAM: Linux then ends the biggest program (check: sudo dmesg | "
|
|
"grep -i -E 'killed process|out of memory'); Windows slows down instead. Close other programs or use a "
|
|
"smaller model (Q2_0 / IQ2_XS).")
|
|
|
|
def alive(self) -> bool:
|
|
return self.proc is not None and not getattr(self, "ended", False) and self.proc.poll() is None
|
|
|
|
def exit_code(self):
|
|
if self.proc is None:
|
|
return None
|
|
try:
|
|
return self.proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
return None
|
|
|
|
def unload(self):
|
|
"""Release GPU/RAM between requests, retaining the spawn config for automatic reloading."""
|
|
self.close()
|
|
self.unloaded = True
|
|
|
|
RESTART_RETRY_S = 15.0 # between the tries of restart(): a dying engine's VRAM may take a while to come back
|
|
|
|
def restart(self, tries: int = 3):
|
|
"""Start the engine again (the same command) after it died; the new process has its own line queue.
|
|
close() ends and waits for the old process first (EngineStuck when it cannot be ended). A start that still
|
|
exits before READY - a dead engine's VRAM can take a while to come back, notably on ROCm - is retried
|
|
(PR #637)."""
|
|
self.close()
|
|
info = dict(self.info)
|
|
self.starting = True # prepare() answers 503 "starting" meanwhile (#344)
|
|
try:
|
|
for i in range(tries):
|
|
# #344: not alive until READY - __init__ sets max_context to 0 and blocks until the engine says
|
|
# READY, and a request that saw alive() in that window skipped load() and failed with "context
|
|
# (0)". __init__ clears `ended` itself once READY (before its pump thread can set it again).
|
|
self.ended = True
|
|
try:
|
|
self.__init__(*self.spawn)
|
|
break
|
|
except RuntimeError:
|
|
try:
|
|
self.proc.wait(timeout=60)
|
|
except (subprocess.TimeoutExpired, OSError):
|
|
pass
|
|
if i == tries - 1:
|
|
raise
|
|
print(f"[strata] the engine did not start (try {i + 1} of {tries}); again in "
|
|
f"{self.RESTART_RETRY_S:g} s", flush=True)
|
|
time.sleep(self.RESTART_RETRY_S)
|
|
finally:
|
|
self.starting = False
|
|
self.info = {**info, **self.info}
|
|
|
|
def _parse_done(self, line):
|
|
f = line.split()
|
|
self.last = {"generated": int(f[1]), "prompt_tokens": int(f[2]), "prompt_ms": float(f[3]),
|
|
"decode_ms": float(f[4]), "finish": f[5]}
|
|
if len(f) >= 9: # the conversation cache's fields (engine 0.1.3+)
|
|
self.last.update(drafts_accepted=int(f[6]), drafts_offered=int(f[7]), reused=int(f[8]))
|
|
if len(f) >= 11: # decode hit rate fields
|
|
self.last.update(hits=int(f[9]), lookups=int(f[10]))
|
|
if len(f) >= 14: # the expert tiers (engine 0.1.31+): RAM / file blobs, file MB
|
|
self.last.update(ram_blobs=int(f[11]), file_blobs=int(f[12]), file_mb=float(f[13]))
|
|
if len(f) >= 15: # #471 (engine 0.1.36+): the prompt tokens actually read
|
|
self.last.update(prompt_read=int(f[14]))
|
|
if len(f) >= 16: # #588 (engine 0.1.39+): routed experts read over PCIe
|
|
self.last.update(offloaded=int(f[15]))
|
|
|
|
def vram(self, reserve_mib: int | None, timeout: float = 120.0) -> dict:
|
|
"""#533: `VRAM <reserve_mib>` between requests (the caller holds the service's FIFO): the engine shrinks its
|
|
expert cache until that much VRAM is free, or grows it back when more is free; None: back to the reserve it
|
|
started with. -> the engine's figures (expert_slots, expert_cache_mib, vram_free_mib, ...); raises ValueError
|
|
with the engine's reason (e.g. it was not started with --vram-elastic), EngineDied when it ended."""
|
|
if not self.alive():
|
|
raise EngineDied("the engine is not running")
|
|
self.proc.stdin.write("VRAM" + ("" if reserve_mib is None else f" {int(reserve_mib)}") + "\n")
|
|
self.proc.stdin.flush()
|
|
deadline = time.time() + timeout
|
|
while True:
|
|
try:
|
|
line = self.lines.get(timeout=max(0.1, deadline - time.time()))
|
|
except queue.Empty:
|
|
raise EngineDied("the engine did not answer the VRAM command") from None
|
|
if line is None:
|
|
raise EngineDied("the engine ended")
|
|
line = line.strip()
|
|
if line.startswith("ERR"):
|
|
raise ValueError(line[4:].strip() or "the engine refused the VRAM command")
|
|
if line.startswith("VRAM "):
|
|
out = {}
|
|
for kv in line.split()[1:]:
|
|
k, _, v = kv.partition("=")
|
|
out[k] = int(v) if v.lstrip("-").isdigit() else v
|
|
self.info.update({k: out[k] for k in ("expert_slots", "vram_free_mib") if k in out})
|
|
self.info["vram"] = out
|
|
return out
|
|
if time.time() > deadline:
|
|
raise EngineDied("the engine did not answer the VRAM command")
|
|
|
|
@staticmethod
|
|
def sampling_keys(sampling: dict) -> str:
|
|
keys = ""
|
|
t = sampling.get("temperature")
|
|
if isinstance(t, (int, float)) and float(t) > 0.0:
|
|
keys += f" temperature={float(t)!r}"
|
|
tp = sampling.get("top_p")
|
|
if isinstance(tp, (int, float)) and float(tp) < 1.0:
|
|
keys += f" top_p={float(tp)!r}"
|
|
tk = sampling.get("top_k")
|
|
if isinstance(tk, int) and not isinstance(tk, bool) and tk >= 0:
|
|
# the engine's sampled path keeps at most 64 candidates: 0 ("off") and wider lists get all 64
|
|
keys += f" top_k={tk if 1 <= tk <= 64 else 64}"
|
|
mp = sampling.get("min_p")
|
|
if isinstance(mp, (int, float)) and 0.0 < float(mp) <= 1.0:
|
|
keys += f" min_p={float(mp)!r}"
|
|
rp = sampling.get("repetition_penalty")
|
|
rp_on = isinstance(rp, (int, float)) and float(rp) != 1.0
|
|
pf = sampling.get("frequency_penalty")
|
|
pf_on = isinstance(pf, (int, float)) and float(pf) != 0.0
|
|
pp = sampling.get("presence_penalty")
|
|
pp_on = isinstance(pp, (int, float)) and float(pp) != 0.0
|
|
if rp_on:
|
|
keys += f" penalty_repeat={float(rp)!r}"
|
|
if pf_on:
|
|
keys += f" penalty_freq={float(pf)!r}"
|
|
if pp_on:
|
|
keys += f" penalty_present={float(pp)!r}"
|
|
if rp_on or pf_on or pp_on:
|
|
# a penalty without a window counts over nothing: the engine's default is the last 64 tokens
|
|
pln = sampling.get("penalty_last_n")
|
|
if isinstance(pln, int) and not isinstance(pln, bool) and pln > 0:
|
|
keys += f" penalty_last_n={pln}"
|
|
else:
|
|
keys += " penalty_last_n=64"
|
|
seed = sampling.get("seed")
|
|
if isinstance(seed, int) and seed > 0:
|
|
keys += f" seed={seed}"
|
|
# setup's calibration (tools/calibrate.py): engine settings for this request only, measured without a restart
|
|
tune = sampling.get("strata_tune")
|
|
if isinstance(tune, dict):
|
|
for k in ("pcie_frac", "spec_min_p"):
|
|
v = tune.get(k)
|
|
if isinstance(v, (int, float)) and not isinstance(v, bool) and 0.0 <= float(v) <= 1.0:
|
|
keys += f" {k}={float(v)!r}"
|
|
return keys + StrataEngine.projection_key(sampling)
|
|
|
|
@staticmethod
|
|
def projection_key(sampling: dict) -> str:
|
|
"""`cvec=0|1`: the experimental-speed-projection control vector for this request, when the engine was
|
|
started with one (--control-vector-scaled; an engine without one ignores the key). Absent = on."""
|
|
on = sampling.get("experimental_speed_projection")
|
|
return f" cvec={int(on)}" if isinstance(on, bool) else ""
|
|
|
|
def _send(self, text: str):
|
|
btrace("send>", text[:60])
|
|
try:
|
|
with self.wlock:
|
|
self.proc.stdin.write(text + "\n")
|
|
self.proc.stdin.flush()
|
|
except OSError: # the pipe is gone: the engine died (not the client)
|
|
raise EngineDied(f"the engine stopped unexpectedly (exit code {self.exit_code()})") from None
|
|
|
|
def _control(self, cancel, on_token, stop_when=None):
|
|
"""Reads the control lines of the request on them (GEN / BGEN), yielding None heartbeats. Calls on_token(id)
|
|
for each `T`; returns ("done", None) at DONE, or ("badm", continues) at BADM (after DONE). `stop_when()`
|
|
true sends STOP once (the request is then read to its DONE)."""
|
|
stopped = False
|
|
while True:
|
|
try:
|
|
line = self.lines.get(timeout=10.0)
|
|
except queue.Empty:
|
|
if cancel.is_set() and not stopped:
|
|
self._send("STOP")
|
|
stopped = True
|
|
yield None
|
|
continue
|
|
if line is None:
|
|
raise EngineDied(f"the engine stopped unexpectedly (exit code {self.exit_code()})")
|
|
if not line.startswith(("PP ", "INFO")):
|
|
btrace("ctl<", self._ctl_mode, line.strip()[:60])
|
|
if line.startswith("T "):
|
|
on_token(int(line[2:]))
|
|
if not stopped and (cancel.is_set() or (stop_when is not None and stop_when())):
|
|
self._send("STOP")
|
|
stopped = True
|
|
yield False # a token is pending (not a heartbeat)
|
|
elif line.startswith("PP "):
|
|
f = line.split()
|
|
if len(f) >= 3 and f[1].isdigit() and f[2].isdigit():
|
|
self.progress = (int(f[1]), int(f[2]))
|
|
self.prefill_tok_s_mean = float(f[4]) if len(f) >= 5 else None
|
|
if not stopped and cancel.is_set():
|
|
# #879: a Stop during the prompt read went out only after the first token (or after 10 s of
|
|
# silence, which a stream of PP lines never allows): the engine read the whole prompt first
|
|
self._send("STOP")
|
|
stopped = True
|
|
yield None
|
|
elif line.startswith("DONE"):
|
|
self._parse_done(line)
|
|
self._last_done = line
|
|
elif line.startswith("BADM "):
|
|
f = line.split()
|
|
self._ctl_result = ("badm", len(f) >= 3 and f[2] == "1")
|
|
return
|
|
elif line.startswith("YIELDED "): # the read gave way (BYIELD): <slot> <tokens read>
|
|
f = line.split()
|
|
if len(f) >= 3 and f[1].lstrip("-").isdigit() and f[2].isdigit():
|
|
self._yielded = (int(f[1]), int(f[2]))
|
|
elif line.startswith("ERR"):
|
|
raise ValueError(line[4:].strip())
|
|
if line.startswith("DONE") and self._ctl_mode == "solo":
|
|
self._ctl_result = ("done", None)
|
|
return
|
|
|
|
def _drain_control(self, until: str, timeout: float = 300.0):
|
|
"""After a consumer left early: read the control lines up to the next `until` line (DONE or BADM) so the next
|
|
request does not read this one's leftovers. Returns that line (None: the engine ended or never answered)."""
|
|
end = time.monotonic() + timeout
|
|
while time.monotonic() < end:
|
|
try:
|
|
line = self.lines.get(timeout=max(0.1, end - time.monotonic()))
|
|
except queue.Empty:
|
|
break
|
|
if line is None:
|
|
return None
|
|
if line.startswith("DONE"):
|
|
self._parse_done(line)
|
|
if line.startswith(until) or line.startswith("ERR"):
|
|
return line
|
|
return None
|
|
|
|
def _release_slot_when_done(self, slot: int, stream: list[int] | None = None):
|
|
"""A slot whose consumer left (a stop token, a stop string, a disconnect): BSTOP it and free it once the engine
|
|
says BDONE (in the background). `stream`: the prompt and every token of it so far - with the tokens still to
|
|
come before BDONE, all but the last are what the slot holds then (the next turn of its conversation)."""
|
|
try:
|
|
self._send(f"BSTOP {slot}")
|
|
except EngineDied:
|
|
pass
|
|
def wait():
|
|
end = time.monotonic() + 600.0
|
|
tail = list(stream or [])
|
|
while time.monotonic() < end:
|
|
try:
|
|
line = self.slot_q[slot].get(timeout=5.0)
|
|
except queue.Empty:
|
|
continue
|
|
if line is None:
|
|
tail = []
|
|
break
|
|
if line.startswith("BT "):
|
|
try:
|
|
tail.append(int(line.split()[2]))
|
|
except (IndexError, ValueError):
|
|
tail = []
|
|
if line.startswith("BDONE "):
|
|
break
|
|
self.slot_held[slot] = tail[:-1] if stream and tail else []
|
|
with self.slot_cv:
|
|
self.slot_busy[slot] = False
|
|
self.slot_cv.notify_all()
|
|
threading.Thread(target=wait, daemon=True).start()
|
|
|
|
YIELDS_MAX = 2 # #656: how often one request's prompt read gives way to a shorter waiting one
|
|
|
|
def _take_control(self, cancel, plen: int, after_epoch: int | None = None):
|
|
"""Waits for the control lines (one prompt read at a time), yielding None heartbeats; False when cancelled.
|
|
`after_epoch`: a request whose read gave way lets the requests waiting then go first."""
|
|
entry = [plen]
|
|
with self.slot_cv:
|
|
self.waiting += 1
|
|
self.wait_lens.append(entry)
|
|
beat = time.monotonic()
|
|
try:
|
|
while True:
|
|
with self.slot_cv:
|
|
turn = after_epoch is None or self.ctl_epoch > after_epoch or self.waiting <= 1
|
|
if turn and self.ctl.acquire(timeout=0.5):
|
|
break
|
|
if not turn:
|
|
with self.slot_cv:
|
|
self.slot_cv.wait(timeout=0.5)
|
|
if cancel.is_set():
|
|
return False
|
|
if time.monotonic() - beat >= 10.0: # a heartbeat every 10 s, as the engine's own wait
|
|
beat = time.monotonic()
|
|
yield None
|
|
finally:
|
|
with self.slot_cv:
|
|
self.waiting -= 1
|
|
self.wait_lens.remove(entry)
|
|
with self.slot_cv:
|
|
self.ctl_epoch += 1
|
|
self.slot_cv.notify_all()
|
|
return True
|
|
|
|
SOLO_AGAIN_MAX = 2 # how often a request left alone in a slot goes back to the solo path
|
|
SOLO_AGAIN_MIN_LEFT = 32 # ... only with at least this many tokens still allowed (max_tokens)
|
|
|
|
def _may_go_solo(self, left: int, times: int, embeddings) -> bool:
|
|
"""A request decoding in a slot that is alone now (no other slot busy, nobody waiting) goes back to the solo
|
|
path with its MTP drafts: the engine continues it from the slot's sessions (its slot cache; INFO
|
|
slot_cache=1). STRATA_PARALLEL_SOLO=0 keeps it in the slot."""
|
|
if (embeddings or times >= self.SOLO_AGAIN_MAX or left < self.SOLO_AGAIN_MIN_LEFT or
|
|
not (self.info or {}).get("slot_cache") or os.environ.get("STRATA_PARALLEL_SOLO") == "0"):
|
|
return False
|
|
with self.slot_cv:
|
|
return sum(1 for b in self.slot_busy if b) == 1 and self.waiting == 0
|
|
|
|
def _shorter_waiting(self, plen: int) -> bool:
|
|
"""#656: someone waits for the control lines with a prompt under half this one's: worth giving way to."""
|
|
with self.slot_cv:
|
|
return any(e[0] * 2 <= plen for e in self.wait_lens)
|
|
|
|
def generate_batched(self, ids, max_new, sampling, cancel, embeddings=None):
|
|
"""--batch. Alone (no slot busy, nobody waiting): the solo path (GEN, with drafts: the fastest stream)
|
|
- and when another request arrives meanwhile, this one is STOPped and continues in a batch slot (BGEN with
|
|
its prompt + what it generated: the engine reuses that prefix). Otherwise: BGEN into a free slot, then the
|
|
slot's own BT lines until BDONE. Several requests run at once; the control lines (prompt reading, admission)
|
|
are taken one request at a time - and a long prompt read gives way at a chunk boundary to a waiting request
|
|
with a much shorter prompt (#656: `BYIELD <slot>`; the part read waits in a slot and the read goes on after).
|
|
A consumer that stops early leaves the engine in step: the solo request is STOPped and read to its DONE, an
|
|
admission to its BADM, a slot is BSTOPped and freed at its BDONE."""
|
|
if not self.alive():
|
|
raise EngineDied("the engine is unavailable; this request was not sent")
|
|
self.progress = None
|
|
keys = self.sampling_keys(sampling or {})
|
|
out: list[int] = []
|
|
pending: list[int] = []
|
|
prompt, left = list(ids), int(max_new)
|
|
ok = yield from self._take_control(cancel, len(prompt))
|
|
if not ok:
|
|
return
|
|
holding = True
|
|
btrace("ctl acquired")
|
|
slot, gen0, reserved = None, None, None
|
|
phase = "none" # solo -> (admit -> slot) ; "done" once the engine has finished with this request
|
|
stop_sent = False
|
|
yields, solo_again = 0, 0
|
|
try:
|
|
while True: # a request in a slot that is left alone goes back to the solo path
|
|
if not holding:
|
|
ok = yield from self._take_control(cancel, len(prompt))
|
|
if not ok:
|
|
return
|
|
holding = True
|
|
with self.slot_cv:
|
|
alone = not any(self.slot_busy) and self.waiting == 0
|
|
if alone and left > 1:
|
|
head = f"GENI {left}{keys} {embeddings}" if embeddings else f"GEN {left}{keys}"
|
|
self._send(f"{head} {','.join(str(int(t)) for t in prompt)}")
|
|
phase = "solo"
|
|
self._ctl_mode, self._ctl_result, self._yielded = "solo", None, None
|
|
def others():
|
|
with self.slot_cv:
|
|
return self.waiting > 0
|
|
for x in self._control(cancel, pending.append, stop_when=others):
|
|
while pending:
|
|
t = pending.pop(0)
|
|
out.append(t)
|
|
yield t
|
|
if x is None: # a heartbeat (False: a token, flushed above)
|
|
if (reserved is None and not out and not embeddings and yields < self.YIELDS_MAX and
|
|
self._shorter_waiting(len(prompt))):
|
|
with self.slot_cv: # the slot the part read will wait in
|
|
reserved = self.pick_slot(prompt)
|
|
if reserved is not None:
|
|
self.slot_busy[reserved] = True
|
|
if reserved is not None:
|
|
self._send(f"BYIELD {reserved}")
|
|
yield None
|
|
phase = "none" # its DONE is read
|
|
while pending:
|
|
t = pending.pop(0)
|
|
out.append(t)
|
|
yield t
|
|
if self._yielded is not None and reserved is not None and self._yielded[0] == reserved:
|
|
slot, reserved = reserved, None # gave way: the read goes on in that slot (below)
|
|
elif reserved is not None: # it did not give way: the slot is free again
|
|
with self.slot_cv:
|
|
self.slot_busy[reserved] = False
|
|
self.slot_cv.notify_all()
|
|
reserved = None
|
|
if slot is None:
|
|
finish = (self.last or {}).get("finish") if isinstance(self.last, dict) else None
|
|
left = int(max_new) - len(out)
|
|
if cancel.is_set() or left <= 0 or finish in ("stop", "length") or (out and out[-1] in EOS_IDS):
|
|
return
|
|
prompt = list(ids) + out # promoted: it continues in a batch slot from here
|
|
while True:
|
|
if slot is None:
|
|
# a free slot (they free themselves at BDONE, which needs no control lines): the one that holds
|
|
# the start of this prompt (its conversation's last turn), else the one used longest ago
|
|
with self.slot_cv:
|
|
while True:
|
|
slot = self.pick_slot(prompt)
|
|
if slot is not None:
|
|
self.slot_busy[slot] = True
|
|
break
|
|
self.slot_cv.wait(timeout=10.0)
|
|
if cancel.is_set():
|
|
return
|
|
if self._yielded is not None: # it gave way: the others waiting then go first
|
|
self.slot_held[slot] = list(prompt[:self._yielded[1]])
|
|
self._yielded = None
|
|
yields += 1
|
|
self.ctl.release()
|
|
holding = False
|
|
with self.slot_cv:
|
|
epoch = self.ctl_epoch
|
|
ok = yield from self._take_control(cancel, len(prompt), after_epoch=epoch)
|
|
if not ok:
|
|
return
|
|
holding = True
|
|
while not self.slot_q[slot].empty():
|
|
self.slot_q[slot].get_nowait()
|
|
head = f"BGENI {slot} {left}{keys} {embeddings}" if embeddings else f"BGEN {slot} {left}{keys}"
|
|
live = {"slot": slot, "state": "reading", "prompt_tokens": len(prompt), "generated": 0,
|
|
"started": time.time(), "first_token": None}
|
|
self.slot_live[slot] = live
|
|
self._send(f"{head} {','.join(str(int(t)) for t in prompt)}")
|
|
self.slot_held[slot] = [] # the admission overwrites what the slot held
|
|
phase = "admit"
|
|
self._ctl_mode, self._ctl_result, self._yielded = "batch", None, None
|
|
asked = False
|
|
for x in self._control(cancel, pending.append):
|
|
while pending:
|
|
t = pending.pop(0)
|
|
out.append(t)
|
|
yield t
|
|
if x is None:
|
|
if (not asked and not embeddings and yields < self.YIELDS_MAX and
|
|
self._shorter_waiting(len(prompt))):
|
|
self._send(f"BYIELD {slot}")
|
|
asked = True
|
|
yield None
|
|
cont = bool(self._ctl_result and self._ctl_result[1])
|
|
phase = "slot" if cont else "none"
|
|
while pending:
|
|
t = pending.pop(0)
|
|
out.append(t)
|
|
yield t
|
|
if not cont and self._yielded is not None and self._yielded[0] == slot and not cancel.is_set():
|
|
continue # gave way: again once the shorter request is in
|
|
break
|
|
self.ctl.release()
|
|
holding = False
|
|
if not cont:
|
|
return
|
|
live.update(state="decoding", first_token=time.time(), generated=len(out))
|
|
gen0 = len(out) - 1 # the admission's own token: the slot feeds it first
|
|
going_solo = False
|
|
while True:
|
|
try:
|
|
line = self.slot_q[slot].get(timeout=10.0)
|
|
except queue.Empty:
|
|
if cancel.is_set() and not stop_sent:
|
|
self._send(f"BSTOP {slot}")
|
|
stop_sent = True
|
|
yield None
|
|
continue
|
|
if line is None:
|
|
phase = "none"
|
|
raise EngineDied(f"the engine stopped unexpectedly (exit code {self.exit_code()})")
|
|
if line.startswith("BT "):
|
|
t = int(line.split()[2])
|
|
out.append(t)
|
|
live["generated"] = len(out)
|
|
if cancel.is_set():
|
|
if not stop_sent:
|
|
self._send(f"BSTOP {slot}")
|
|
stop_sent = True
|
|
continue
|
|
yield t
|
|
if not going_solo and not stop_sent and self._may_go_solo(int(max_new) - len(out), solo_again,
|
|
embeddings):
|
|
self._send(f"BSTOP {slot}") # alone now: on with the drafts (below)
|
|
going_solo = True
|
|
elif line.startswith("BDONE "):
|
|
phase = "none"
|
|
f = line.split()
|
|
if len(f) >= 5 and isinstance(self.last, dict):
|
|
self.last = {**self.last, "finish": f[3], "decode_ms": float(f[4]),
|
|
"generated": int(f[2]) if f[2].isdigit() else self.last.get("generated")}
|
|
# what the slot's sessions hold now: the prompt and every token fed (all but the last one)
|
|
self.slot_held[slot] = list(prompt) + out[gen0:-1] if len(out) > gen0 else []
|
|
if (going_solo and f[3:4] == ["cancel"] and not cancel.is_set() and len(out) < int(max_new)
|
|
and not (out and out[-1] in EOS_IDS)):
|
|
# the solo path continues it: the engine copies the slot's sessions back (all but the
|
|
# last token are in them) and decodes with MTP drafts again
|
|
self.slot_live[slot] = None
|
|
self.slot_used[slot] = time.time()
|
|
with self.slot_cv:
|
|
self.slot_busy[slot] = False
|
|
self.slot_cv.notify_all()
|
|
slot, gen0 = None, None
|
|
prompt, left = list(ids) + out, int(max_new) - len(out)
|
|
solo_again += 1
|
|
btrace("back to the solo path")
|
|
break
|
|
return
|
|
finally:
|
|
# a consumer that left early (or an error): keep the engine and this server in step
|
|
btrace("finally phase", phase, "slot", slot, "holding", holding)
|
|
try:
|
|
if phase == "solo":
|
|
self._send("STOP")
|
|
self._drain_control("DONE")
|
|
elif phase == "admit":
|
|
line = self._drain_control("BADM")
|
|
if line and line.startswith("BADM ") and line.split()[2:3] == ["1"]:
|
|
phase = "slot"
|
|
except EngineDied:
|
|
pass
|
|
if holding:
|
|
self.ctl.release()
|
|
if reserved is not None:
|
|
with self.slot_cv:
|
|
self.slot_busy[reserved] = False
|
|
self.slot_cv.notify_all()
|
|
if slot is not None:
|
|
self.slot_live[slot] = None
|
|
self.slot_used[slot] = time.time()
|
|
if phase == "slot":
|
|
self.slot_held[slot] = []
|
|
stream = list(prompt) + out[gen0:] if gen0 is not None and len(out) > gen0 else None
|
|
self._release_slot_when_done(slot, stream) # freed at its BDONE
|
|
else:
|
|
with self.slot_cv:
|
|
self.slot_busy[slot] = False
|
|
self.slot_cv.notify_all()
|
|
|
|
def pick_slot(self, prompt: list[int]) -> int | None:
|
|
"""A free slot for `prompt` (the caller holds slot_cv): the one whose held tokens are the longest start of the
|
|
prompt (the engine then reads only the rest), else an empty one, else the one used longest ago - so the
|
|
conversations other slots hold stay for their next turns. None: every slot is busy."""
|
|
free = [b for b in self.slot_order if not self.slot_busy[b]]
|
|
if not free:
|
|
return None
|
|
def held_prefix(b):
|
|
h = self.slot_held[b]
|
|
return len(h) if h and len(h) < len(prompt) and prompt[:len(h)] == h else 0
|
|
best = max(free, key=held_prefix)
|
|
if held_prefix(best) > 0:
|
|
return best
|
|
return min(free, key=lambda b: (bool(self.slot_held[b]), self.slot_used[b]))
|
|
|
|
def slots_view(self) -> list[dict]:
|
|
"""/metrics: each batch slot - idle (with the tokens it holds for a next turn), reading or decoding."""
|
|
now, view = time.time(), []
|
|
for b in range(self.batch):
|
|
r = self.slot_live[b]
|
|
if r is None:
|
|
view.append({"slot": b, "state": "idle", "held_tokens": len(self.slot_held[b])})
|
|
continue
|
|
ft = r.get("first_token")
|
|
view.append({"slot": b, "state": r["state"], "prompt_tokens": r["prompt_tokens"],
|
|
"generated": r["generated"], "elapsed_s": round(now - r["started"], 1),
|
|
"tok_s": round(r["generated"] / max(1e-6, now - ft), 1) if ft else None})
|
|
return view
|
|
|
|
def generate(self, ids, max_new, sampling, cancel, embeddings=None):
|
|
"""Yields token ids, and None as a heartbeat every 10 s while the engine is quiet (reading a long prompt):
|
|
the HTTP layer turns it into an SSE comment, which keeps clients' watchdogs calm and notices a client that
|
|
has gone. A consumer that stops early (or `cancel`) makes the engine STOP, so it does not run to max_new."""
|
|
if not self.alive():
|
|
raise EngineDied("the engine is unavailable; this request was not sent")
|
|
if getattr(self, "batch", 0):
|
|
yield from self.generate_batched(ids, max_new, sampling, cancel, embeddings)
|
|
return
|
|
self.progress = None
|
|
self.prefill_tok_s_mean = None
|
|
# an image request takes the same sampling keys as text (#75: it used to decode greedily whatever was asked)
|
|
head = f"GENI {int(max_new)}{self.sampling_keys(sampling or {})} {embeddings}" if embeddings else \
|
|
f"GEN {int(max_new)}{self.sampling_keys(sampling or {})}"
|
|
try:
|
|
self.proc.stdin.write(f"{head} {','.join(str(int(t)) for t in ids)}\n")
|
|
self.proc.stdin.flush()
|
|
except OSError: # the pipe is gone: the engine died (not the client)
|
|
raise EngineDied(f"the engine stopped unexpectedly (exit code {self.exit_code()})") from None
|
|
done = False
|
|
# #481: how long the engine may stay silent from here. Until the first line: the request's first prompt
|
|
# chunk at the slowest prompt reading on top of silence_s; a PP line resets it to its own chunk's time.
|
|
silence = float(self.silence_s or 0)
|
|
allow = silence + min(len(ids), PP_CHUNK_MAX) / PP_FLOOR_TOK_S if silence > 0 else 0.0
|
|
heard, read_to = time.monotonic(), 0
|
|
try:
|
|
while True:
|
|
wait = 10.0
|
|
if allow > 0:
|
|
wait = min(wait, allow - (time.monotonic() - heard))
|
|
if wait <= 0:
|
|
done = True # no STOP and no drain: nothing is listening
|
|
raise self._silent(f"the engine said nothing for {time.monotonic() - heard:.0f} s during "
|
|
"the request")
|
|
try:
|
|
line = self.lines.get(timeout=wait)
|
|
except queue.Empty:
|
|
if cancel.is_set():
|
|
return
|
|
if wait >= 10.0:
|
|
yield None # the 10 s heartbeat (the deadline's short waits are not)
|
|
continue
|
|
if line is None:
|
|
done = True
|
|
raise EngineDied(f"the engine stopped unexpectedly (exit code {self.exit_code()})")
|
|
heard = time.monotonic() # any line is output: T, PP, RESUME, INFO ...
|
|
if line.startswith("T "):
|
|
allow = silence
|
|
if cancel.is_set():
|
|
return
|
|
yield int(line[2:])
|
|
elif line.startswith("PP "):
|
|
f = line.split()
|
|
if len(f) >= 3 and f[1].isdigit() and f[2].isdigit():
|
|
self.progress = (int(f[1]), int(f[2])) # prompt progress, one per chunk: also a heartbeat (the
|
|
self.prefill_tok_s_mean = float(f[4]) if len(f) >= 5 else None
|
|
rate, chunk = self.prefill_tok_s_mean or 0.0, int(f[1]) - read_to
|
|
read_to = int(f[1])
|
|
if silence > 0 and rate > 0 and chunk > 0: # #481: the next chunk, as long as this one
|
|
allow = max(silence, PP_SLACK * chunk / rate)
|
|
if cancel.is_set(): # lines reset the 10 s wait, so without this a long prompt
|
|
return # would send no keep-alives at all)
|
|
yield None
|
|
elif line.startswith("RESUME "): # the reused tokens: the first chunk starts after them
|
|
try:
|
|
read_to = int(line.split()[1])
|
|
except (IndexError, ValueError):
|
|
pass
|
|
elif line.startswith("DONE"):
|
|
self._parse_done(line)
|
|
done = True
|
|
return
|
|
elif line.startswith("ERR"):
|
|
done = True
|
|
raise ValueError(line[4:].strip())
|
|
finally:
|
|
if not done: # the consumer stopped early: stop the engine, drain to DONE
|
|
if self.can_stop:
|
|
try:
|
|
self.proc.stdin.write("STOP\n")
|
|
self.proc.stdin.flush()
|
|
except OSError:
|
|
pass
|
|
# #481: never an untimed wait here - it holds the request FIFO, and an engine that lost step never
|
|
# answers. An engine that honours STOP gets the current allowance in all (a STOP during a prompt
|
|
# chunk is seen after it); an older one runs on to max_new, so each line only has to come in time.
|
|
heard = time.monotonic()
|
|
while True:
|
|
left = allow - (time.monotonic() - heard) if allow > 0 else None
|
|
try:
|
|
if left is not None and left <= 0:
|
|
raise queue.Empty
|
|
line = self.lines.get(timeout=left)
|
|
except queue.Empty:
|
|
raise self._silent("the engine did not finish the request after it was stopped (STOP) "
|
|
f"within {allow:.0f} s") from None
|
|
if line is None or line.startswith("ERR"):
|
|
break
|
|
if line.startswith("DONE"):
|
|
self._parse_done(line)
|
|
break
|
|
if not self.can_stop:
|
|
heard = time.monotonic()
|
|
|
|
def _silent(self, what: str) -> EngineSilent:
|
|
"""#481: end an engine that lost step with the server (its main thread waits for a command the server never
|
|
sends), so the next request starts it again: killed now, its GPU and RAM go back with the process."""
|
|
self.silent_note = ("The engine and the server lost step (issue #481; a very slow PC can raise "
|
|
"\"engine_silence_s\" in the config, 0 = wait forever). If it happens again, please add "
|
|
"the end of the engine log to github.com/Niko1221/Strata/issues/481.")
|
|
self.ended = True # not alive from now: the next request restarts it
|
|
proc = self.proc
|
|
try:
|
|
proc.kill()
|
|
proc.wait(timeout=20) # restart() -> close() handles one that is still exiting
|
|
except (OSError, AttributeError, subprocess.TimeoutExpired):
|
|
pass
|
|
return EngineSilent(f"{what}; the server ended the engine")
|
|
|
|
def close(self):
|
|
"""End the engine process: QUIT first (the engine frees its memory itself - unpinning tens of GB can take
|
|
a while), then terminate, then kill, each given 20 s. Raises EngineStuck when it still runs after all three."""
|
|
if self.proc is None:
|
|
return
|
|
try:
|
|
if self.proc.poll() is None:
|
|
try:
|
|
self.proc.stdin.write("QUIT\n")
|
|
self.proc.stdin.flush()
|
|
self.proc.stdin.close() # Windows' detached stdin reader must see EOF before shutdown
|
|
self.proc.wait(timeout=20)
|
|
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
self.proc.terminate()
|
|
self.proc.wait(timeout=20)
|
|
except subprocess.TimeoutExpired:
|
|
self.proc.kill()
|
|
try:
|
|
self.proc.wait(timeout=20)
|
|
except subprocess.TimeoutExpired:
|
|
raise EngineStuck("Strata is still releasing GPU/RAM; retry unloading after it exits") from None
|
|
except OSError:
|
|
pass
|
|
finally:
|
|
if self.proc.poll() is not None:
|
|
if self.pump is not None:
|
|
self.pump.join(timeout=2)
|
|
self.proc.stdin.close()
|
|
self.proc.stdout.close()
|
|
if self.log not in (None, subprocess.DEVNULL):
|
|
self.log.close()
|
|
self.proc = None
|
|
self.ended = True
|
|
self.progress, self.last = None, {}
|
|
|
|
|
|
IMAGE_URL_MAX = 32 << 20 # an image URL is read up to this (the web app attaches pictures of up to 20 MB)
|
|
|
|
|
|
def network_path(path: str) -> bool:
|
|
r"""A path to another computer: \\host\share or //host/share (either slash), \\?\UNC\host\..., \\.\UNC\host\...
|
|
or the NT form \??\UNC\host\... Windows connects to that host at the first look at the path (os.path.isfile is
|
|
enough) and signs in with the user's NTLM credentials. \\?\ and \??\ name local files too; no client needs them."""
|
|
p = path.replace("/", "\\")
|
|
return p.startswith("\\\\") or p.startswith("\\??\\")
|
|
|
|
|
|
class Vision:
|
|
"""The resident image encoder: `strata-vision` (llama.cpp mtmd + the mmproj file) reads `ENC <image> <out>`
|
|
lines and writes each image's embeddings; results are cached by the image's hash, so a conversation that
|
|
sends the same picture again (every turn, with most clients) encodes it once."""
|
|
|
|
def __init__(self, cfg: dict, log=None, env: dict | None = None):
|
|
args = [cfg["exe"], "--mmproj", cfg["mmproj"], "--model", cfg["model"]]
|
|
if cfg.get("gpu"):
|
|
args.append("--gpu")
|
|
if cfg.get("threads"):
|
|
args += ["--threads", str(cfg["threads"])]
|
|
if cfg.get("max_tokens"):
|
|
args += ["--max-tokens", str(cfg["max_tokens"])]
|
|
self.dir = Path(tempfile.mkdtemp(prefix="strata-vision-"))
|
|
self.spawn = (args, log, env) # to start it again after an unload
|
|
self.stopped = False
|
|
self._start()
|
|
self.lock = threading.Lock()
|
|
self.cache: dict[str, tuple[Path, int]] = {}
|
|
|
|
def _start(self):
|
|
args, log, env = self.spawn
|
|
self.proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log or subprocess.DEVNULL,
|
|
text=True, encoding="utf-8", bufsize=1, env=env)
|
|
contain(self.proc)
|
|
line = self.proc.stdout.readline()
|
|
if not line.startswith("READY"):
|
|
raise RuntimeError("the vision encoder did not start: " + line.strip())
|
|
self.stopped = False
|
|
|
|
def alive(self) -> bool:
|
|
return not self.stopped and self.proc.poll() is None
|
|
|
|
def unload(self):
|
|
"""Stop the encoder process (its VRAM or RAM goes back); the encoded images stay cached on disk."""
|
|
self.close()
|
|
self.stopped = True
|
|
|
|
def restart(self):
|
|
"""Start the encoder again after an unload (or if it died); the cache of encoded images is kept."""
|
|
try:
|
|
self.proc.kill()
|
|
except OSError:
|
|
pass
|
|
self._start()
|
|
|
|
@staticmethod
|
|
def load(source: str) -> bytes:
|
|
if source.startswith("data:"):
|
|
try:
|
|
return base64.b64decode(source.split(",", 1)[1])
|
|
except (IndexError, ValueError) as e: # no comma, or not base64
|
|
raise ValueError(f"the image's data: URL could not be read ({e})") from None
|
|
if source.startswith(("http://", "https://")):
|
|
return Vision.download(source)
|
|
path = source[7:] if source.startswith("file://") else source
|
|
# refused before anything looks at the path: file://host/share/x.png is \\host\share\x.png ("file://C:/x.png",
|
|
# a drive, is a client's spelling of file:///C:/x.png)
|
|
host = re.split(r"[/\\]", path, maxsplit=1)[0] if source.startswith("file://") else ""
|
|
if network_path(path) or host and not re.fullmatch(r"[A-Za-z]:|localhost", host, re.IGNORECASE):
|
|
raise ValueError("an image file must be on this computer: network paths (\\\\host\\share, //host/share, "
|
|
"file://host/...) are not read")
|
|
if path and os.path.isfile(path):
|
|
try:
|
|
return Path(path).read_bytes()
|
|
except OSError as e:
|
|
raise ValueError(f"the image could not be read from {path} ({e})") from None
|
|
raise ValueError("an image must be a data: URL, an http(s) URL or a local file path")
|
|
|
|
@staticmethod
|
|
def download(url: str) -> bytes:
|
|
"""An image URL's bytes, at most IMAGE_URL_MAX of them: the whole response was read, so a huge or endless one
|
|
filled the memory. One that cannot be read is a ValueError (a 400 that says so), not a dropped connection."""
|
|
too_big = f"the image URL's file is over {IMAGE_URL_MAX >> 20} MiB"
|
|
try:
|
|
with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "strata"}), timeout=60) as r:
|
|
size = r.headers.get("Content-Length") or ""
|
|
if size.isdigit() and int(size) > IMAGE_URL_MAX:
|
|
raise ValueError(too_big)
|
|
data = r.read(IMAGE_URL_MAX + 1)
|
|
except (OSError, HTTPException) as e:
|
|
raise ValueError(f"the image URL could not be read: {e}") from None
|
|
if len(data) > IMAGE_URL_MAX:
|
|
raise ValueError(too_big)
|
|
return data
|
|
|
|
@staticmethod
|
|
def normalize(data: bytes) -> bytes:
|
|
"""The formats strata-vision's decoder (stb_image) reads pass through; anything else is converted to PNG."""
|
|
if data[:3] == b"\xff\xd8\xff" or data[:8] == b"\x89PNG\r\n\x1a\n" or data[:2] == b"BM" or \
|
|
data[:6] in (b"GIF87a", b"GIF89a"):
|
|
return data
|
|
try:
|
|
import io
|
|
from PIL import Image
|
|
except ImportError:
|
|
raise ValueError("this image format needs Pillow (python -m pip install pillow); JPEG, PNG, BMP and "
|
|
"GIF work without it") from None
|
|
try:
|
|
im = Image.open(io.BytesIO(data))
|
|
im.load()
|
|
except Exception as e:
|
|
raise ValueError(f"the image could not be read ({e})") from None
|
|
if im.mode in ("RGBA", "LA", "P") and "transparency" in im.info or im.mode in ("RGBA", "LA"):
|
|
im = im.convert("RGBA")
|
|
bg = Image.new("RGB", im.size, (255, 255, 255)) # transparent areas become white, not black
|
|
bg.paste(im, mask=im.split()[-1])
|
|
im = bg
|
|
elif im.mode != "RGB":
|
|
im = im.convert("RGB")
|
|
out = io.BytesIO()
|
|
im.save(out, format="PNG")
|
|
return out.getvalue()
|
|
|
|
def encode(self, source: str | bytes) -> tuple[Path, int]:
|
|
"""-> (embeddings file, number of image tokens). `source`: what load() reads, or the image's bytes."""
|
|
data = self.normalize(source if isinstance(source, bytes) else self.load(source))
|
|
key = hashlib.sha256(data).hexdigest()[:32]
|
|
with self.lock:
|
|
if key in self.cache:
|
|
return self.cache[key]
|
|
img, out = self.dir / f"{key}.img", self.dir / f"{key}.sve"
|
|
img.write_bytes(data)
|
|
try:
|
|
self.proc.stdin.write(f"ENC {img} {out}\n")
|
|
self.proc.stdin.flush()
|
|
line = self.proc.stdout.readline().strip()
|
|
finally: # #352: also when the encoder's pipe is gone
|
|
img.unlink(missing_ok=True)
|
|
if not line.startswith("OK"):
|
|
raise ValueError("the image could not be read: " + (line[4:] if line.startswith("ERR") else
|
|
"the vision encoder stopped"))
|
|
self.cache[key] = (out, int(line.split()[1]))
|
|
if len(self.cache) > 64: # oldest first
|
|
old = next(iter(self.cache))
|
|
self.cache.pop(old)[0].unlink(missing_ok=True)
|
|
return self.cache[key]
|
|
|
|
def close(self):
|
|
try:
|
|
self.proc.stdin.write("QUIT\n")
|
|
self.proc.stdin.flush()
|
|
self.proc.wait(timeout=10)
|
|
except Exception:
|
|
self.proc.kill()
|
|
|
|
|
|
def gpu_list(cfg: dict) -> list[int]:
|
|
"""The config's "gpu": one card (2), or several for a layer split ([0, 2] or "0,2"), numbered as nvidia-smi
|
|
numbers them; [] when it names none."""
|
|
g = cfg.get("gpu")
|
|
if g is None or g == "":
|
|
return []
|
|
items = g if isinstance(g, (list, tuple)) else str(g).split(",")
|
|
return [int(str(x).strip()) for x in items if str(x).strip() != ""]
|
|
|
|
|
|
def effort_end_args(cfg: dict, exe: str, tok) -> list[str] | None:
|
|
"""#458 (opt-in): the engine arguments for "effort_position": "end" - the id of "system" as --tail-role-token, so
|
|
the engine checkpoints in front of the trailing effort turn - or None when the config leaves it at the top (the
|
|
default) or the engine is too old for it (said once; the prompt stays the default one). ValueError for a value
|
|
other than "start" / "end"."""
|
|
pos = cfg.get("effort_position", "start")
|
|
if pos not in ("start", "end"):
|
|
raise ValueError(f'"effort_position" must be "start" (the default) or "end", not {pos!r}')
|
|
if pos == "start":
|
|
return None
|
|
try:
|
|
with open(exe, "rb") as f:
|
|
known = b"--tail-role-token" in f.read()
|
|
except OSError:
|
|
known = False
|
|
role = tok.encode("system", parse_special=True)
|
|
if not known or len(role) != 1:
|
|
print("[strata] effort_position \"end\" needs engine 0.1.39 or newer (--tail-role-token): the reasoning "
|
|
"effort stays at the top of the prompt", flush=True)
|
|
return None
|
|
print("[strata] effort_position end: a request's non-default reasoning effort goes right before the answer, so "
|
|
"changing it keeps the cached conversation", flush=True)
|
|
return ["--tail-role-token", str(role[0])]
|
|
|
|
|
|
def engine_silence_s(cfg: dict) -> float:
|
|
"""#481: the config's "engine_silence_s" - seconds an engine may print nothing during a request before the server
|
|
ends it (default ENGINE_SILENCE_S; 0 = wait forever). ValueError for anything but a number >= 0."""
|
|
v = cfg.get("engine_silence_s")
|
|
if v is None:
|
|
return ENGINE_SILENCE_S
|
|
if isinstance(v, bool) or not isinstance(v, (int, float)) or v < 0:
|
|
raise ValueError(f'"engine_silence_s" must be a number of seconds >= 0 (0 = no limit), not {v!r}')
|
|
return float(v)
|
|
|
|
|
|
def layer_split_value(cfg: dict) -> str:
|
|
"""#644: the config's "layer_split" as the engine's --layer-split value: "auto" (the default), or the FIRST LAYER of
|
|
each later GPU's share - one rising number per GPU after the first, e.g. "24,36,42" for 4 GPUs (layers 0-23 on the
|
|
first card, 24-35, 36-41, 42 to the end) - as a string or a JSON list ([24, 36, 42]). ValueError with the format
|
|
and an example for anything else, such as per-card layer counts ("24,16,12,12")."""
|
|
v = cfg.get("layer_split")
|
|
if v is None or (isinstance(v, str) and v.strip().lower() in ("", "auto")):
|
|
return "auto"
|
|
n = len(gpu_list(cfg))
|
|
items = v if isinstance(v, (list, tuple)) else str(v).split(",") if isinstance(v, (str, int)) else None
|
|
vals = []
|
|
for x in items or []:
|
|
try:
|
|
if isinstance(x, bool) or isinstance(x, float):
|
|
raise ValueError
|
|
vals.append(int(str(x).strip()))
|
|
except ValueError:
|
|
vals = None
|
|
break
|
|
want = max(n - 1, 1)
|
|
example = ",".join(str(round(48 * (i + 1) / (want + 1))) for i in range(want))
|
|
hint = (f'"layer_split" is the first layer of each later GPU, one rising number per GPU after the first '
|
|
f'({want} for {n} GPUs), not a count of layers per card - e.g. "{example}" (or [{example.replace(",", ", ")}])'
|
|
f' for an even share of 48 layers, or "auto" (the default) to place them by each card\'s free VRAM')
|
|
if not vals:
|
|
raise ValueError(f"{hint}; got {v!r}")
|
|
if n > 1 and len(vals) != n - 1:
|
|
raise ValueError(f"{hint}; got {len(vals)} number(s) ({v!r}) for {n} GPUs")
|
|
if vals[0] < 2 or any(b <= a for a, b in zip(vals, vals[1:])):
|
|
raise ValueError(f"{hint}; got {v!r}, which does not rise from 2 or more")
|
|
return ",".join(str(x) for x in vals)
|
|
|
|
|
|
def engine_args(cfg: dict) -> list[str]:
|
|
"""The engine's arguments: the config's, and with several GPUs the layer split across them ("layer_split" in the
|
|
config: "auto" by default, or the first layer of each later GPU's share, e.g. "18" or "16,32"; see
|
|
layer_split_value)."""
|
|
args = list(cfg["args"])
|
|
if len(gpu_list(cfg)) > 1 and "--layer-split" not in args:
|
|
args += ["--layer-split", layer_split_value(cfg)]
|
|
# opt-in: an auto split runs on the first card alone when it holds every profiled expert and the KV
|
|
if len(gpu_list(cfg)) > 1 and cfg.get("split_skip_if_fits") and "--split-skip-if-fits" not in args:
|
|
args.append("--split-skip-if-fits")
|
|
# #533 (opt-in): "vram_elastic": true - the expert cache in segments, so POST /v1/vram can give VRAM back to other
|
|
# programs and take it back; "vram_segment_mib" sets the segment size (the engine's default: 512)
|
|
if cfg.get("vram_elastic") is True and "--vram-elastic" not in args:
|
|
args.append("--vram-elastic")
|
|
seg = cfg.get("vram_segment_mib")
|
|
if isinstance(seg, int) and not isinstance(seg, bool) and seg > 0 and "--vram-segment-mib" not in args:
|
|
args += ["--vram-segment-mib", str(seg)]
|
|
args += parallel_args(cfg, args)
|
|
return learned_profile_args(cfg, args)
|
|
|
|
|
|
PARALLEL_MAX = 8 # the engine's batch window holds at most 8 rows (kVerifyMaxT)
|
|
|
|
|
|
def parallel_args(cfg: dict, args: list[str]) -> list[str]:
|
|
"""#465 (opt-in): "parallel": N in the config - up to N requests decode together in the engine's batch slots
|
|
(--batch N); more wait their turn. 1 or absent: one request at a time, as before. A value the engine cannot use
|
|
is said and passed on: the engine warns and adjusts (recommend, never force)."""
|
|
if "--batch" in args or "--slots" in args:
|
|
return []
|
|
n = cfg.get("parallel")
|
|
if n is None or n is False:
|
|
return []
|
|
if isinstance(n, bool) or not isinstance(n, int):
|
|
print(f'[strata] "parallel" must be a whole number of requests (2..{PARALLEL_MAX}), not {n!r}: ignored',
|
|
flush=True)
|
|
return []
|
|
if n <= 1:
|
|
return []
|
|
if n > PARALLEL_MAX:
|
|
print(f'[strata] "parallel": {n} - the engine runs at most {PARALLEL_MAX} requests together; it will use '
|
|
f"{PARALLEL_MAX}", flush=True)
|
|
return ["--batch", str(n)]
|
|
|
|
|
|
def profile_shape(path: str) -> tuple[int, int] | None:
|
|
"""An expert profile's (layers, experts per layer), from its header (tools/make_profile.py's format), or None
|
|
when the file is missing, is not one or is shorter than the pairs its header promises."""
|
|
try:
|
|
with open(path, "rb") as f:
|
|
head = f.read(24)
|
|
size = os.fstat(f.fileno()).st_size
|
|
except OSError:
|
|
return None
|
|
if len(head) < 24 or head[:4] != b"STRP":
|
|
return None
|
|
_, nl, ne, _, n = struct.unpack("<5I", head[4:])
|
|
return (nl, ne) if size >= 24 + 4 * n else None
|
|
|
|
|
|
def learned_profile_args(cfg: dict, args: list[str]) -> list[str]:
|
|
"""#477 (opt-in): "expert_profile_save": "<path>" in the config has the engine (0.1.36+) save what its adaptive
|
|
tier learned there - on QUIT and every "expert_profile_save_every" minutes (10 by default, 0 = at QUIT only) -
|
|
and the next start begins from it instead of the config's --expert-profile, when it is a profile of the same
|
|
model (its header's layers and experts match); otherwise from the config's own, as before. A relative path is
|
|
the engine's (the config's "cwd"). Without the key, the arguments are the config's, unchanged."""
|
|
save = cfg.get("expert_profile_save")
|
|
if not isinstance(save, str) or not save.strip() or "--expert-profile-save" in args:
|
|
return args
|
|
args = args + ["--expert-profile-save", save]
|
|
every = cfg.get("expert_profile_save_every")
|
|
if isinstance(every, (int, float)) and not isinstance(every, bool) and every >= 0:
|
|
args += ["--expert-profile-save-every", str(every)]
|
|
if "--expert-profile" in args[:-1]:
|
|
i = args.index("--expert-profile") + 1
|
|
here = cfg.get("cwd") or "."
|
|
learned = profile_shape(save if os.path.isabs(save) else os.path.join(here, save))
|
|
base = profile_shape(args[i] if os.path.isabs(args[i]) else os.path.join(here, args[i]))
|
|
if learned is not None and learned == base:
|
|
args[i] = save
|
|
return args
|
|
|
|
|
|
def hip_visible(cfg: dict) -> list[int]:
|
|
"""AMD: the devices the engine should see, as the HIP runtime numbers them (HIP_VISIBLE_DEVICES).
|
|
|
|
On Linux setup's KFD order is HIP's order, so the config's "gpu" is it. On Windows setup finds the cards in the
|
|
display-adapter order, and an integrated Radeon that HIP also enumerates takes ordinal 0 and pushes the discrete
|
|
card to 1 (#325): setup records the ordinal `strata-device --list-devices` gave the card as "hip_ordinal", which
|
|
wins for a one-card config. Without it (a config from before), the config's "gpu"."""
|
|
ordinal = cfg.get("hip_ordinal")
|
|
if ordinal is not None and str(ordinal).strip() != "" and len(gpu_list(cfg)) <= 1:
|
|
try:
|
|
return [int(str(ordinal).strip())]
|
|
except ValueError:
|
|
pass
|
|
return gpu_list(cfg)
|
|
|
|
|
|
def child_env(cfg: dict) -> dict:
|
|
"""The engine's environment: the CUDA libraries setup installed (pip's nvidia packages, or the toolkit that
|
|
compiled it) first on the library search path."""
|
|
env = dict(os.environ)
|
|
if hip_visible(cfg) and cfg.get("backend") == "hip": # AMD: numbered as HIP numbers them (hip_visible)
|
|
env["HIP_VISIBLE_DEVICES"] = ",".join(str(i) for i in hip_visible(cfg))
|
|
elif gpu_list(cfg): # issue #51: the GPU(s) to run on, numbered as nvidia-smi does; CUDA's
|
|
env["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID" # own default order (fastest first) can number the cards otherwise
|
|
env["CUDA_VISIBLE_DEVICES"] = ",".join(str(i) for i in gpu_list(cfg))
|
|
for k, v in (cfg.get("env") or {}).items(): # engine settings the config carries (AMD: the GEMM tuning table)
|
|
env[str(k)] = str(v)
|
|
dirs = [d for d in cfg.get("lib_dirs") or [] if Path(d).is_dir()]
|
|
if dirs:
|
|
var = "PATH" if os.name == "nt" else "LD_LIBRARY_PATH"
|
|
env[var] = os.pathsep.join(dirs + ([env[var]] if env.get(var) else []))
|
|
return env
|
|
|
|
|
|
def vision_env(cfg: dict, env: dict) -> dict:
|
|
"""The image encoder's environment: the engine's, unless the config's vision section names its own "cuda_device"
|
|
(numbered like nvidia-smi) - then the encoder runs on that card alone, so a spare GPU can hold it while the engine
|
|
keeps all of its own cards' VRAM (#408, Efs-O). Without it, nothing changes."""
|
|
dev = (cfg.get("vision") or {}).get("cuda_device")
|
|
if dev is None:
|
|
return env
|
|
env = dict(env)
|
|
if cfg.get("backend") == "hip":
|
|
env["HIP_VISIBLE_DEVICES"] = str(dev)
|
|
else:
|
|
env["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID"
|
|
env["CUDA_VISIBLE_DEVICES"] = str(dev)
|
|
return env
|
|
|
|
|
|
class ByteTokenizer:
|
|
"""Tiny stand-in tokenizer for tests without the pack: one id per UTF-8 byte, specials as ids >= 256."""
|
|
SPECIALS = ["<|im_start|>", "<|im_end|>", "<|endoftext|>", "<|vision_start|>", "<|image_pad|>", "<|vision_end|>"]
|
|
|
|
ALWAYS = () # specials matched without parse_special (type 4, as <think>)
|
|
|
|
def encode(self, text, parse_special=False, plain=()):
|
|
out, i = [], 0
|
|
while i < len(text):
|
|
for k, s in enumerate(self.SPECIALS):
|
|
if (parse_special or s in self.ALWAYS) and text.startswith(s, i) and not any(
|
|
a <= i < b for a, b in plain):
|
|
out.append(256 + k)
|
|
i += len(s)
|
|
break
|
|
else:
|
|
out.extend(text[i].encode("utf-8"))
|
|
i += 1
|
|
return out
|
|
|
|
def decode(self, ids, errors="replace"):
|
|
raw = bytearray()
|
|
for t in ids:
|
|
raw += self.SPECIALS[t - 256].encode() if t >= 256 else bytes([t])
|
|
return raw.decode("utf-8", errors=errors)
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------ core
|
|
class Detokenizer:
|
|
"""Incremental decode: each token's bytes go through an incremental UTF-8 decoder, which emits the complete
|
|
characters and holds a multi-byte character split across tokens until it is complete (invalid bytes become
|
|
U+FFFD, as a whole decode with errors="replace" makes them). Constant time per token - the old re-decode of
|
|
every generated id cost 2 ms per token after 8K tokens and 4 ms after 16K (perf-review F-1). A tokenizer
|
|
without `token_bytes` (the tests' byte tokenizer) keeps the re-decode."""
|
|
|
|
def __init__(self, tok):
|
|
self.tok, self.ids, self.sent = tok, [], 0
|
|
self.inc = codecs.getincrementaldecoder("utf-8")(errors="replace") if hasattr(tok, "token_bytes") else None
|
|
|
|
def pending(self) -> bool:
|
|
"""A character is split across the tokens so far: its first bytes are held."""
|
|
if self.inc is not None:
|
|
return bool(self.inc.getstate()[0])
|
|
return self.tok.decode(self.ids).endswith("\ufffd")
|
|
|
|
def push(self, t: int) -> str:
|
|
if self.inc is not None:
|
|
return self.inc.decode(self.tok.token_bytes(t))
|
|
self.ids.append(t)
|
|
text = self.tok.decode(self.ids)
|
|
if text.endswith("�"):
|
|
return ""
|
|
delta, self.sent = text[self.sent:], len(text)
|
|
return delta
|
|
|
|
|
|
OPENAI_MAX_STOP = 4 # OpenAI's limit on `stop`
|
|
|
|
|
|
def stop_strings(req: dict) -> list[str]:
|
|
"""The request's stop strings: OpenAI's `stop` (a string or a list of up to 4) or Anthropic's `stop_sequences`
|
|
(a list). Empty strings are left out. A bad value is a ValueError, so a 400 before anything is generated."""
|
|
req = req or {}
|
|
if req.get("stop") is not None:
|
|
stop = req["stop"]
|
|
stop = [stop] if isinstance(stop, str) else stop
|
|
if not isinstance(stop, list) or not all(isinstance(x, str) for x in stop):
|
|
raise ValueError("stop must be a string or a list of strings")
|
|
if len(stop) > OPENAI_MAX_STOP:
|
|
raise ValueError(f"stop takes at most {OPENAI_MAX_STOP} strings")
|
|
elif req.get("stop_sequences") is not None:
|
|
stop = req["stop_sequences"]
|
|
if not isinstance(stop, list) or not all(isinstance(x, str) for x in stop):
|
|
raise ValueError("stop_sequences must be a list of strings")
|
|
else:
|
|
return []
|
|
return [x for x in stop if x]
|
|
|
|
|
|
class StopMatcher:
|
|
"""Cuts the answer's text at the first stop string. Text that could still be the start of a stop string is held
|
|
back (only that tail, never more), so a stop string split across tokens or chunks is still found and never sent."""
|
|
|
|
def __init__(self, stops: list[str]):
|
|
self.stops, self.held, self.hit = stops, "", None
|
|
|
|
def push(self, text: str) -> str:
|
|
"""-> the text that is safe to send now; after a match, self.hit is the stop string and nothing more comes."""
|
|
if self.hit is not None:
|
|
return ""
|
|
buf = self.held + text
|
|
first = None
|
|
for s in self.stops:
|
|
i = buf.find(s)
|
|
if i >= 0 and (first is None or i < first[0]):
|
|
first = (i, s)
|
|
if first is not None:
|
|
self.held, self.hit = "", first[1]
|
|
return buf[:first[0]]
|
|
keep = 0 # the longest end of buf that a stop string starts with
|
|
for s in self.stops:
|
|
for k in range(min(len(s) - 1, len(buf)), keep, -1):
|
|
if buf.endswith(s[:k]):
|
|
keep = k
|
|
break
|
|
self.held = buf[len(buf) - keep:] if keep else ""
|
|
return buf[:len(buf) - keep]
|
|
|
|
def flush(self) -> str:
|
|
held, self.held = self.held, ""
|
|
return held
|
|
|
|
|
|
class Service:
|
|
def __init__(self, engine: Engine, tokenizer, template: ChatTemplate, model_name: str = "qwen3.8-flash-next",
|
|
vision: Vision | None = None, sampling_defaults: dict | None = None,
|
|
fit_max_tokens: bool = False):
|
|
self.engine, self.tok, self.template, self.model, self.vision = engine, tokenizer, template, model_name, vision
|
|
self.fit_max_tokens = fit_max_tokens # --fit-max-tokens: clamp the output cap instead of 400
|
|
self.aliases: list[str] = [] # #297: other names of the model (the config's `aliases`)
|
|
self.sampling_defaults = dict(sampling_defaults or {}) # the run config's `sampling` block
|
|
self.shared = {} # the web app's Chat settings for every client (POST /settings)
|
|
self.shared_path = None # where they are kept between starts (next to the config)
|
|
self.fifo = threading.Lock()
|
|
self.embeddings = threading.local() # the current request's image embeddings file (GENI)
|
|
self.api_key = "" # when set, /v1/* needs it (Bearer or x-api-key)
|
|
# #458 (opt-in, the config's "effort_position": "end"): a non-default reasoning effort goes in a short system
|
|
# turn before the answer instead of the top of the prompt, so switching it keeps the cached conversation
|
|
self.effort_end = False
|
|
self.conv_log = ConvCacheLog() # #596: the parked conversations, from the engine's log
|
|
self.config_path = None # #564: the run config the web page's Settings view edits
|
|
self.config_lock = threading.Lock()
|
|
# #321: browser pages of these origins may call /v1/* (CORS; "*" = any page - only with an api_key that
|
|
# matters); empty = no CORS headers at all, as before
|
|
self.cors_origins: list[str] = []
|
|
# #321: origins that count as Strata's own page for /settings and MCP tools, e.g. the web app reached through a
|
|
# reverse proxy or tunnel whose Host differs ("https://strata.example.com"); never a wildcard
|
|
self.trusted_origins: list[str] = []
|
|
# DNS rebinding: extra Host names this server answers to (the config's allowed_hosts, $STRATA_ALLOWED_HOSTS;
|
|
# "*" = any), and every name it answers to, which serve() works out from the address it listens on
|
|
self.allowed_hosts: list[str] = []
|
|
self.host_names: set[str] = set(LOOPBACK_NAMES)
|
|
self.status = {"busy": False, "queued": 0} # GET /status: what the model is doing right now
|
|
self.rate = collections.deque(maxlen=32) # (time, generated) samples for the live tok/s window
|
|
# "parallel" (the engine's batch slots): every running request's own status and rate window; self.status
|
|
# then says busy while any runs and shows the newest one
|
|
self.live_reqs: dict[int, tuple[dict, collections.deque]] = {}
|
|
# #332: the API request monitor (/api-monitor) keeps the last 100 requests' prompts and answers in memory,
|
|
# so it is off unless the config's "api_monitor" (or --api-monitor) turns it on
|
|
self.api_monitor = False
|
|
self.api_requests = collections.deque(maxlen=100) # bounded I/O in memory; no headers or API keys
|
|
self.request_trace = threading.local()
|
|
self.history = collections.deque(maxlen=500) # the last finished requests, newest last (GET /metrics)
|
|
# since the server started (the Monitor's totals, issue #35)
|
|
self.totals = {"since": time.time(), "requests": 0, "prompt_tokens": 0, "reused": 0, "output_tokens": 0,
|
|
"prompt_ms": 0.0, "decode_ms": 0.0,
|
|
"drafts_offered": 0, "drafts_accepted": 0} # #457: the MTP drafts, summed where reported
|
|
self.last_timings = None # the last finished request's, llama.cpp's names (/v1/status)
|
|
self.last_request_at = None # when a request last started or finished
|
|
self.started_at = time.time()
|
|
self.status_lock = threading.Lock()
|
|
self.mcp = None # serve/mcp.py's McpHub when MCP servers are configured
|
|
# sharing the GPU with other programs (all off by default): unload the engine after this many idle seconds,
|
|
# only start it again when this much VRAM is free, and run this command first (e.g. to unload another
|
|
# server's model); the next request after an unload starts the engine again
|
|
self.idle_unload_s = 0
|
|
self.min_free_vram_mib = 0
|
|
self.before_load = None
|
|
self.vram_reserve = None # #533: the last POST /v1/vram reserve (None: the start's)
|
|
self.reasoning_budget_tokens = 0 # #123: the config's default thinking budget (0: none)
|
|
self.repeat_stop_tokens = REPEAT_STOP_TOKENS # #606: one token this many times in a row ends a reply (0: off)
|
|
self.anthropic_think_unasked = True # #278: "anthropic_thinking": "on_request" -> False
|
|
self.stop_ids = set(tokenizer.encode(IM_END, parse_special=True) +
|
|
tokenizer.encode("<|endoftext|>", parse_special=True))
|
|
|
|
def loaded(self) -> bool:
|
|
return not hasattr(self.engine, "alive") or self.engine.alive()
|
|
|
|
def set_aliases(self, aliases) -> None:
|
|
"""#297: the config's `aliases` - a list of names (or one comma-separated string), like llama-server's --alias.
|
|
ValueError for anything else."""
|
|
if aliases is None:
|
|
aliases = []
|
|
if isinstance(aliases, str):
|
|
aliases = aliases.split(",")
|
|
if not isinstance(aliases, list) or not all(isinstance(x, str) for x in aliases):
|
|
raise ValueError(f"aliases={aliases!r}: expected a list of model names")
|
|
names = []
|
|
for x in (x.strip() for x in aliases):
|
|
if x and x != self.model and x not in names:
|
|
names.append(x)
|
|
self.aliases = names
|
|
|
|
def model_names(self) -> list[str]:
|
|
return [self.model, *self.aliases]
|
|
|
|
def model_for(self, req) -> str:
|
|
"""The name to answer with: the request's own when it is the model's name or an alias (#297), else the model's.
|
|
Other names are still served, as before."""
|
|
asked = req.get("model") if isinstance(req, dict) else None
|
|
return asked if isinstance(asked, str) and asked in self.aliases else self.model
|
|
|
|
def reasoning_budget(self, req) -> int | None:
|
|
"""#123: the most tokens this request may think, or None: the request's `reasoning_budget_tokens`, else the
|
|
config's. 0 (or less) means no budget, so a request can turn a configured one off. ValueError (a 400) for
|
|
anything that is not a whole number."""
|
|
value = (req or {}).get("reasoning_budget_tokens") if isinstance(req, dict) else None
|
|
if value is None:
|
|
value = self.reasoning_budget_tokens
|
|
if isinstance(value, float) and value.is_integer():
|
|
value = int(value)
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
raise ValueError(f"reasoning_budget_tokens={value!r}: expected a whole number of tokens (0: no budget)")
|
|
return value if value > 0 else None
|
|
|
|
def _vision_down(self) -> bool:
|
|
return self.vision is not None and hasattr(self.vision, "alive") and not self.vision.alive()
|
|
|
|
def free_vram_mib(self) -> int | None:
|
|
"""Free VRAM on the engine's (first) GPU, from NVML (AMD backend: amdgpu's sysfs files, #301); None when it can't
|
|
be read (then nothing is refused)."""
|
|
try:
|
|
if getattr(self, "backend", None) == "hip":
|
|
from serve.telemetry import free_vram_mib
|
|
return free_vram_mib(int(getattr(self, "gpu_index", 0) or 0), amd=True)
|
|
from serve.telemetry import _Nvml
|
|
nv = _Nvml(int(getattr(self, "gpu_index", 0) or 0))
|
|
if not nv.ok():
|
|
return None
|
|
m = nv.Mem()
|
|
if nv.lib.nvmlDeviceGetMemoryInfo(nv.dev, ctypes.byref(m)) != 0:
|
|
return None
|
|
return int(m.free >> 20)
|
|
except Exception:
|
|
return None
|
|
|
|
def ensure_loaded(self):
|
|
"""Start the engine if it is not running (unloaded, or it died - issue #27), after the before_load hook and
|
|
the free-VRAM check. The caller holds self.fifo."""
|
|
if self.loaded() and not self._vision_down():
|
|
return
|
|
if self.before_load:
|
|
cmd = self.before_load
|
|
print(f"[strata] before loading: {cmd if isinstance(cmd, str) else ' '.join(map(str, cmd))}", flush=True)
|
|
try:
|
|
subprocess.run(cmd, shell=isinstance(cmd, str), timeout=120, stdin=subprocess.DEVNULL)
|
|
except (OSError, subprocess.SubprocessError) as e:
|
|
print(f"[strata] the before_load command failed ({e}); loading anyway", flush=True)
|
|
if self.min_free_vram_mib:
|
|
free = self.free_vram_mib()
|
|
deadline = time.time() + 15 # memory another process just gave back can take a moment
|
|
while free is not None and free < self.min_free_vram_mib and time.time() < deadline:
|
|
time.sleep(0.5)
|
|
free = self.free_vram_mib()
|
|
if free is not None and free < self.min_free_vram_mib:
|
|
raise GpuBusy(f"the GPU is in use by another program: {free} MiB of VRAM free, the model needs "
|
|
f"{self.min_free_vram_mib} (min_free_vram_mib) - it stays unloaded until that is free")
|
|
if self._vision_down(): # first, as at a start: a GPU encoder takes its VRAM before
|
|
print("[strata] starting the vision encoder again ...", flush=True) # the engine sizes its cache
|
|
self.vision.restart()
|
|
if self.loaded():
|
|
return
|
|
if getattr(self.engine, "unloaded", False):
|
|
print("[strata] loading the model again (it was unloaded) ...", flush=True)
|
|
else:
|
|
code = self.engine.exit_code() if hasattr(self.engine, "exit_code") else None
|
|
print(f"[strata] the engine had stopped (exit code {code}); starting it again "
|
|
"(a minute or two) ...", flush=True)
|
|
self.engine.restart()
|
|
print("[strata] the engine is running again", flush=True)
|
|
if self.vram_reserve is not None and hasattr(self.engine, "vram"): # #533: the reserve asked for last
|
|
try:
|
|
self.engine.vram(self.vram_reserve)
|
|
except (ValueError, EngineDied) as e:
|
|
print(f"[strata] the VRAM reserve ({self.vram_reserve} MiB) was not applied: {e}", flush=True)
|
|
|
|
vram_wait_s = 300.0 # #533: how long POST /v1/vram waits for a running request
|
|
|
|
def vram(self, reserve_mib: int | None) -> dict:
|
|
"""#533, POST /v1/vram: keep `reserve_mib` of VRAM free for other programs (None: the reserve the engine
|
|
started with), applied between requests - a request that is running finishes first (up to wait_s). Only an
|
|
engine started with --vram-elastic (the config's "vram_elastic": true) can do it; it never resizes on its own.
|
|
An unloaded engine applies it when it loads. -> {"status": ..., and the engine's figures}."""
|
|
if not hasattr(self.engine, "vram"):
|
|
raise ValueError("this engine cannot resize its VRAM use")
|
|
if not self.fifo.acquire(timeout=self.vram_wait_s):
|
|
raise ModelBusy("a request is still running; try again when it has finished")
|
|
# parallel requests do not hold the fifo: the engine's control lines (a prompt being admitted) are taken too,
|
|
# and the engine refuses a resize while a slot decodes
|
|
ctl = getattr(self.engine, "ctl", None) if getattr(self.engine, "batch", 0) else None
|
|
if ctl is not None and not ctl.acquire(timeout=self.vram_wait_s):
|
|
self.fifo.release()
|
|
raise ModelBusy("a request is still running; try again when it has finished")
|
|
try:
|
|
if not self.loaded():
|
|
self.vram_reserve = reserve_mib
|
|
return {"status": "not loaded", "reserve_mib": reserve_mib,
|
|
"note": "applied when the model loads"}
|
|
out = self.engine.vram(reserve_mib)
|
|
self.vram_reserve = reserve_mib
|
|
print(f"[strata] VRAM: {out.get('vram_free_mib')} MiB free, expert cache {out.get('expert_cache_mib')} of "
|
|
f"{out.get('expert_cache_full_mib')} MiB ({out.get('expert_slots')} experts)", flush=True)
|
|
return {"status": "ok", **out}
|
|
finally:
|
|
if ctl is not None:
|
|
ctl.release()
|
|
self.fifo.release()
|
|
|
|
def _say_died(self, e: Exception) -> None:
|
|
"""The server window's line for an engine that died (or was ended, #481) in the middle of a request."""
|
|
note = self.engine.death_note() if hasattr(self.engine, "death_note") else ""
|
|
log = getattr(self.engine, "log_path", None)
|
|
print(f"[strata] {e}. {note} The next request starts the engine again."
|
|
f"{' Its log: ' + log if log else ''}", flush=True)
|
|
|
|
def load(self):
|
|
"""POST /load and every generation request: start the engine now if it is unloaded (raises GpuBusy)."""
|
|
# a request is on its way: the idle thread must not unload between this and the request's own start
|
|
self.last_request_at = time.time()
|
|
if self.loaded() and not self._vision_down():
|
|
return
|
|
trace = getattr(self.request_trace, "record", None)
|
|
waiting = time.perf_counter()
|
|
with self.fifo:
|
|
loading = time.perf_counter()
|
|
if trace is not None:
|
|
with self.status_lock:
|
|
trace["queue_s"] += round(loading - waiting, 3)
|
|
trace["state"] = "loading"
|
|
try:
|
|
self.ensure_loaded()
|
|
finally:
|
|
if trace is not None:
|
|
with self.status_lock:
|
|
trace["load_s"] += round(time.perf_counter() - loading, 3)
|
|
trace["state"] = "queued"
|
|
|
|
def unload(self, idle_for: float | None = None) -> str:
|
|
"""Stop the engine between requests: "unloaded", "not loaded", "busy" (a request is running or waiting, or
|
|
with idle_for: one ran more recently than that) or "unsupported"."""
|
|
if not hasattr(self.engine, "unload"):
|
|
return "unsupported"
|
|
if not self.fifo.acquire(blocking=False):
|
|
return "busy"
|
|
try:
|
|
if not self.engine.alive():
|
|
return "not loaded"
|
|
with self.status_lock:
|
|
if self.status.get("busy") or self.status.get("queued"):
|
|
return "busy"
|
|
if idle_for is not None and time.time() - (self.last_request_at or self.started_at) < idle_for:
|
|
return "busy"
|
|
self.engine.unload()
|
|
if self.vision is not None and hasattr(self.vision, "unload"):
|
|
self.vision.unload()
|
|
print(f"[strata] model unloaded{f' after {idle_for:.0f} s idle' if idle_for else ''}; "
|
|
"the next request loads it again", flush=True)
|
|
return "unloaded"
|
|
finally:
|
|
self.fifo.release()
|
|
|
|
def start_idle_unload(self):
|
|
if not self.idle_unload_s or not hasattr(self.engine, "unload"):
|
|
return
|
|
print(f"[strata] the model unloads after {self.idle_unload_s} s without requests", flush=True)
|
|
|
|
def loop():
|
|
while True:
|
|
time.sleep(max(1.0, min(30.0, self.idle_unload_s / 4)))
|
|
try:
|
|
self.unload(idle_for=self.idle_unload_s)
|
|
except EngineStuck as e: # tried again at the next turn; the thread keeps running
|
|
print(f"[strata] idle unload: {e}", flush=True)
|
|
threading.Thread(target=loop, daemon=True).start()
|
|
|
|
def set_shared(self, defaults) -> dict:
|
|
"""The Chat settings every client gets for what it leaves out; {} / None = clients use their own again."""
|
|
self.shared = clean_shared_defaults(defaults)
|
|
if self.shared_path:
|
|
try:
|
|
if self.shared:
|
|
Path(self.shared_path).write_text(json.dumps(self.shared, indent=1), encoding="utf-8")
|
|
else:
|
|
Path(self.shared_path).unlink(missing_ok=True)
|
|
except OSError as e:
|
|
print(f"[strata] could not save the shared settings: {e}", flush=True)
|
|
return self.shared
|
|
|
|
def with_shared(self, req: dict, api: str) -> dict:
|
|
"""The request with the shared thinking level and max tokens filled in where it has none of its own."""
|
|
s = self.shared
|
|
if not s:
|
|
return req
|
|
req = dict(req)
|
|
if "max_tokens" in s and not req.get("max_tokens") and not req.get("max_completion_tokens"):
|
|
req["max_tokens"] = s["max_tokens"]
|
|
effort = s.get("reasoning_effort")
|
|
if effort:
|
|
if api == "openai":
|
|
ctk = req.get("chat_template_kwargs") if isinstance(req.get("chat_template_kwargs"), dict) else {}
|
|
if not req.get("reasoning_effort") and not req.get("reasoning") and \
|
|
"enable_thinking" not in ctk and "reasoning_effort" not in ctk:
|
|
req["reasoning_effort"] = effort
|
|
elif not req.get("thinking") and not req.get("output_config"):
|
|
if effort == "none":
|
|
req["thinking"] = {"type": "disabled"}
|
|
else:
|
|
req["output_config"] = {"effort": effort}
|
|
return req
|
|
|
|
def start_telemetry(self):
|
|
"""The hardware sampler behind GET /metrics (serve/telemetry.py), recording this server's tok/s too."""
|
|
if getattr(self, "telemetry", None) is None:
|
|
from serve.telemetry import Telemetry
|
|
self.telemetry = Telemetry(extra=lambda: {"tok_s": self._tok_s(), "tok_s_mean": self._tok_s_mean(),
|
|
"prefill_tok_s_mean": self._prefill_tok_s_mean()},
|
|
gpu_index=int(getattr(self, "gpu_index", 0) or 0),
|
|
gpu_indices=getattr(self, "gpu_indices", None),
|
|
amd=getattr(self, "backend", None) == "hip")
|
|
|
|
def _tok_s(self):
|
|
"""tok/s over the last RATE_WINDOW_S seconds. Returns 0.0 while nothing is generating. With parallel requests:
|
|
the sum of theirs."""
|
|
with self.status_lock:
|
|
many = [(dict(st), list(rt)) for st, rt in self.live_reqs.values()]
|
|
if many:
|
|
return sum(self._rate_of(st, rt) for st, rt in many)
|
|
with self.status_lock:
|
|
s = dict(self.status)
|
|
rate = list(self.rate)
|
|
return self._rate_of(s, rate)
|
|
|
|
@staticmethod
|
|
def _rate_of(s, rate):
|
|
if not s.get("busy") or not s.get("first_token"):
|
|
return 0.0
|
|
now = time.time()
|
|
newest = rate[-1] if rate else None
|
|
oldest = next(((t, g) for t, g in rate if now - t <= RATE_WINDOW_S), None)
|
|
if newest and oldest and newest[0] - oldest[0] >= RATE_MIN_SPAN_S:
|
|
return max(0.0, (newest[1] - oldest[1]) / (newest[0] - oldest[0]))
|
|
return s["generated"] / max(RATE_MIN_SPAN_S, now - s["first_token"])
|
|
|
|
def _tok_s_mean(self):
|
|
"""The whole-request mean since the first token (the old formula), kept so the two can be compared."""
|
|
with self.status_lock:
|
|
many = [dict(st) for st, _ in self.live_reqs.values()] or [dict(self.status)]
|
|
return sum(s["generated"] / max(1e-6, time.time() - s["first_token"]) for s in many
|
|
if s.get("busy") and s.get("first_token"))
|
|
|
|
def _prefill_tok_s_mean(self):
|
|
"""Engine-reported mean over newly read tokens, excluding the cached prefix."""
|
|
with self.status_lock:
|
|
reading = self.status.get("busy") and self.status.get("first_token") is None
|
|
return getattr(self.engine, "prefill_tok_s_mean", None) if reading else 0.0
|
|
|
|
def begin_request(self, path, req):
|
|
"""#332: a monitor record for this request, or None when the monitor is off (nothing is kept then)."""
|
|
if not self.api_monitor:
|
|
return None
|
|
raw = json.dumps(req, ensure_ascii=False, indent=2)
|
|
record = {"id": uuid.uuid4().hex[:12], "path": path, "model": req.get("model") or self.model,
|
|
"started_at": time.time(), "state": "queued", "stream": bool(req.get("stream")),
|
|
"response_format": (req.get("response_format") or {}).get("type")
|
|
if isinstance(req.get("response_format"), dict) else None,
|
|
"input": raw[:262144], "input_truncated": len(raw) > 262144,
|
|
"output": "", "reasoning": "", "output_truncated": False, "reasoning_truncated": False,
|
|
"queue_s": 0.0, "load_s": 0.0, "first_token_s": None,
|
|
"_clock": time.perf_counter()}
|
|
with self.status_lock:
|
|
self.api_requests.append(record)
|
|
self.request_trace.record = record
|
|
return record
|
|
|
|
def request_records(self, request_id=None):
|
|
with self.status_lock:
|
|
records = list(self.api_requests)
|
|
if request_id:
|
|
record = next((r for r in records if r["id"] == request_id), None)
|
|
if record is None:
|
|
return None
|
|
return {k: v for k, v in record.items() if not k.startswith("_")}
|
|
return [{k: v for k, v in r.items()
|
|
if k not in ("input", "output", "reasoning", "response") and not k.startswith("_")}
|
|
| {"wallclock_s": r.get("wallclock_s", round(time.perf_counter() - r["_clock"], 3))}
|
|
for r in reversed(records)]
|
|
|
|
def metrics(self, all_requests=False) -> dict:
|
|
"""GET /metrics: what the Monitor tab shows - the engine's facts, what it is doing, the last requests, and
|
|
the hardware (with a minute of history per series)."""
|
|
with self.status_lock:
|
|
s = dict(self.status)
|
|
if self.live_reqs: # parallel requests: the newest one's status
|
|
s = {**s, **dict(list(self.live_reqs.values())[-1][0]), "queued": s.get("queued", 0)}
|
|
hist = list(self.history)
|
|
totals = dict(self.totals)
|
|
now = time.time()
|
|
progress = getattr(self.engine, "progress", None)
|
|
if s.get("busy") and s.get("first_token") is None:
|
|
state = "reading"
|
|
elif s.get("busy"):
|
|
state = "generating"
|
|
elif not self.loaded():
|
|
state = "unloaded"
|
|
else:
|
|
state = "idle"
|
|
live = {"state": state, "queued": s.get("queued", 0), "phase": s.get("phase") if s.get("busy") else None,
|
|
"prompt_tokens": s.get("prompt_tokens") if s.get("busy") else None,
|
|
"prompt_read": None, "prompt_total": None, "generated": s.get("generated") if s.get("busy") else None,
|
|
"max_tokens": s.get("max_tokens") if s.get("busy") else None,
|
|
"elapsed_s": round(now - s["started"], 1) if s.get("busy") and s.get("started") else None,
|
|
"tok_s": round(self._tok_s(), 1) if state == "generating" else None,
|
|
"tok_s_mean": round(self._tok_s_mean(), 1) if state == "generating" else None,
|
|
"prefill_tok_s_mean": getattr(self.engine, "prefill_tok_s_mean", None) if s.get("busy") else None,
|
|
"tok_s_window_s": RATE_WINDOW_S if state == "generating" else None}
|
|
if state == "reading" and progress:
|
|
live["prompt_read"], live["prompt_total"] = progress
|
|
par = int(getattr(self.engine, "batch", 0) or 0)
|
|
if par: # #465: the requests running together, slot by slot
|
|
with self.status_lock:
|
|
running = len(self.live_reqs)
|
|
slots = self.engine.slots_view() if hasattr(self.engine, "slots_view") else []
|
|
# the requests in flight that are not in a slot: the one alone on the solo path, or waiting their turn
|
|
in_slots = sum(1 for x in slots if x["state"] != "idle")
|
|
live.update(parallel=par, running=running, slots=slots, outside_slots=max(0, running - in_slots),
|
|
waiting=int(getattr(self.engine, "waiting", 0) or 0))
|
|
if running and state == "idle":
|
|
live["state"] = "generating"
|
|
engine = {"model": self.model, "max_context": self.engine.max_context, "images": self.vision is not None,
|
|
**dict(getattr(self.engine, "info", {}) or {})}
|
|
tel = self.telemetry.snapshot() if getattr(self, "telemetry", None) else {"now": {}, "history": {}, "static": {}}
|
|
parked = self.conv_log.poll(getattr(self.engine, "log_path", None), getattr(self.engine, "log_start", None))
|
|
return {"engine": engine, "live": live, "requests": hist[::-1][:None if all_requests else 12],
|
|
"conversation_cache": conversation_cache_view(engine, hist, totals, parked),
|
|
"requests_kept": len(hist), "totals": totals, "hardware": tel["now"],
|
|
"hardware_static":
|
|
tel["static"], "history": tel["history"], "time": now}
|
|
|
|
def v1_status(self) -> dict:
|
|
"""GET /v1/status: what this server is and does, for a client that would rather ask than guess (a front-end
|
|
that polls its OpenAI-compatible server's status, collabosm's for one): the model and its window, images,
|
|
the APIs, what is running, and the last request's timings in llama.cpp's names. /metrics has the rest."""
|
|
with self.status_lock:
|
|
s, totals = dict(self.status), dict(self.totals)
|
|
last_t, last_at = (dict(self.last_timings) if self.last_timings else None), self.last_request_at
|
|
tel = self.telemetry.snapshot() if getattr(self, "telemetry", None) else {"now": {}, "static": {}}
|
|
hw, static = tel.get("now") or {}, tel.get("static") or {}
|
|
|
|
def scaled(v, unit, digits=0):
|
|
return round(v / unit, digits) if isinstance(v, (int, float)) else None
|
|
|
|
busy, ctx = bool(s.get("busy")), self.engine.max_context
|
|
images = self.vision is not None
|
|
return {
|
|
"service": "strata", "model": self.model,
|
|
"loaded": self.loaded(), "auto_load": hasattr(self.engine, "restart"),
|
|
"structured_output": {"formats": ["json_object", "json_schema"], "method": "prompt_and_validate",
|
|
"constrained_decoding": False, "stream_buffered": True},
|
|
"engine": (getattr(self.engine, "info", {}) or {}).get("version"),
|
|
"started": int(self.started_at), "uptime_s": int(time.time() - self.started_at),
|
|
"cache_max_tokens": ctx,
|
|
"context": {"native": ctx, "max_positions": ctx},
|
|
# one request at a time (more wait their turn), or "parallel": N batch slots (#465)
|
|
"concurrency": {"serving": max(1, int(getattr(self.engine, "batch", 0) or 0)),
|
|
"requested": max(1, int(getattr(self.engine, "batch", 0) or 0))},
|
|
"dialects": ["/v1/chat/completions", "/v1/messages", "/v1/responses"],
|
|
"vision": {"enabled": images, "available": images, "error": None},
|
|
"activity": {"requests": totals["requests"] + int(busy), "in_flight": int(busy) + int(s.get("queued") or 0),
|
|
"last_request_at": int(last_at) if last_at else None},
|
|
"last_timings": last_t,
|
|
"vram": dict((getattr(self.engine, "info", {}) or {}).get("vram") or {},
|
|
elastic=bool((getattr(self.engine, "info", {}) or {}).get("vram_elastic"))),
|
|
"machine": {
|
|
"at": int(time.time()),
|
|
"gpu": {"name": static.get("gpu_name"), "used_mib": scaled(hw.get("gpu_mem_used"), 2 ** 20),
|
|
"total_mib": scaled(hw.get("gpu_mem_total"), 2 ** 20), "util_pct": hw.get("gpu_util"),
|
|
"temp_c": hw.get("gpu_temp"), "power_w": hw.get("gpu_power")} if static.get("gpu_name") else None,
|
|
"ram": {"used_gib": scaled(hw.get("ram_used"), 2 ** 30, 1),
|
|
"total_gib": scaled(hw.get("ram_total"), 2 ** 30, 1)} if hw.get("ram_total") else None}}
|
|
|
|
def render_prompt(self, messages, tools, kwargs) -> str:
|
|
"""The template rendered. #458: with `effort_end`, a request with a non-default effort (low, medium or no
|
|
thinking) is rendered as a default one up to the answer - the same prompt start, so the conversation cache
|
|
keeps it - and its effort follows in a short system turn right before the answer (thinking off: the template's
|
|
empty thinking block). The engine (--tail-role-token) checkpoints in front of that turn."""
|
|
effort = kwargs.get("reasoning_effort")
|
|
off = kwargs.get("enable_thinking") is False
|
|
if not self.effort_end or (not off and effort in (None, "", "xhigh", "high")):
|
|
return self.template.render(messages, tools=tools, **kwargs)
|
|
base = {k: v for k, v in kwargs.items() if k not in ("reasoning_effort", "enable_thinking")}
|
|
history = self.template.render(messages, tools=tools, add_generation_prompt=False, **base)
|
|
full = self.template.render(messages, tools=tools, add_generation_prompt=True, **kwargs)
|
|
mine = self.template.render(messages, tools=tools, add_generation_prompt=False, **kwargs)
|
|
if not full.startswith(mine): # a template this cannot take apart: rendered as asked
|
|
return full
|
|
tail = "" if off else EFFORT_TURN.format(EFFORT_TEXT.get(effort, EFFORT_TEXT["medium"]))
|
|
return history + tail + full[len(mine):]
|
|
|
|
def encode_prompt(self, messages, tools, kwargs) -> list[int]:
|
|
"""The request's prompt: the template rendered and tokenized. #537: a <think> / </think> written inside a
|
|
message's text is encoded as the text it is, not as the model's reasoning markers (the template's own are)."""
|
|
marked, marked_tools, changed = mark_think_literals(messages, tools)
|
|
prompt = self.render_prompt(marked, marked_tools, kwargs)
|
|
if not changed:
|
|
return self.tok.encode(prompt, parse_special=True)
|
|
prompt, plain = unmark_think_literals(prompt)
|
|
return self.tok.encode(prompt, parse_special=True, plain=plain)
|
|
|
|
def prepare(self, messages, tools, kwargs, max_new=None):
|
|
"""-> (ids, thinking, max_new). An unset or non-positive max_new (some clients send -1) means "unlimited":
|
|
the rest of the context."""
|
|
ids = self.encode_prompt(messages, tools, kwargs)
|
|
self.embeddings.path = None
|
|
images = images_of(messages)
|
|
if images:
|
|
if self.vision is None:
|
|
raise ValueError("this server was started without the vision encoder (run setup again and choose "
|
|
"'vision'), so it cannot read images")
|
|
pad = self.tok.encode(IMAGE_PAD, parse_special=True)[0]
|
|
start = self.tok.encode(VISION_START, parse_special=True)[0]
|
|
# An image URL is downloaded first, outside the FIFO: under it, a slow server held every other request
|
|
# for as long as urlopen waited.
|
|
images = [Vision.download(src) if src.startswith(("http://", "https://")) else src for src in images]
|
|
# Encode only while the engine is idle: the engine and the image encoder (a separate process) must not
|
|
# run on the GPU at the same time - an encode during a running request left that request stuck at
|
|
# "reading the prompt" with CPU and GPU busy, for good (reproduced). So encoding takes its turn in the
|
|
# same FIFO as the requests.
|
|
with self.fifo:
|
|
encoded = [self.vision.encode(src) for src in images]
|
|
# one <|image_pad|> per image -> one per image token. Only the markers the template writes for an image
|
|
# (right after <|vision_start|>) are images: the same text inside a message (an agent reading these docs,
|
|
# #150) is kept as plain text, or it took an image's place and the counts no longer matched.
|
|
literal = self.tok.encode(IMAGE_PAD, parse_special=False)
|
|
out, k = [], 0
|
|
for j, t in enumerate(ids):
|
|
if t == pad and j > 0 and ids[j - 1] == start and k < len(encoded):
|
|
out += [pad] * encoded[k][1]
|
|
k += 1
|
|
elif t == pad:
|
|
out += literal
|
|
else:
|
|
out.append(t)
|
|
if k != len(encoded):
|
|
raise ValueError("the prompt and its images do not match")
|
|
ids = out
|
|
combined = self.vision.dir / f"req-{uuid.uuid4().hex[:12]}.sve"
|
|
with open(combined, "wb") as f:
|
|
for path, _ in encoded:
|
|
f.write(path.read_bytes())
|
|
self.embeddings.path = combined
|
|
ctx = self.engine.max_context
|
|
if ctx <= 0:
|
|
if getattr(self.engine, "starting", False): # #344: (re)starting, not a prompt that is too long
|
|
raise EngineStarting("the engine is starting (a minute or two); try again shortly")
|
|
# a failed restart leaves max_context 0: check against the last known context, so the request reaches
|
|
# run() - which starts the engine again - instead of failing with "exceeds the context (0)" forever
|
|
ctx = getattr(self.engine, "known_ctx", 0)
|
|
if ctx <= 0:
|
|
raise EngineStarting("the engine is starting (a minute or two); try again shortly")
|
|
room = ctx - CTX_SLACK - len(ids)
|
|
if max_new is None or max_new <= 0 or (self.fit_max_tokens and room < 1):
|
|
if room < 1:
|
|
raise ValueError(f"prompt ({len(ids)} tokens) leaves no room to answer in the context "
|
|
f"({ctx}); requests are never truncated")
|
|
max_new = room
|
|
elif max_new > room:
|
|
if not self.fit_max_tokens:
|
|
raise ValueError(f"prompt ({len(ids)} tokens) + max tokens ({max_new}) exceeds the context "
|
|
f"({ctx}); requests are never truncated. Send a smaller "
|
|
f"max_tokens (at most {max(0, room)} here), or add \"fit_max_tokens\": true to the "
|
|
"model's strata-<model>.json to shorten it to the room left (#545)")
|
|
max_new = max(1, room) # --fit-max-tokens: a shorter completion beats a 400
|
|
return ids, kwargs.get("enable_thinking", True) is not False, max_new
|
|
|
|
def _note(self, n, evs, st=None, rate=None):
|
|
with self.status_lock:
|
|
s = self.status if st is None else st
|
|
s["generated"] = n
|
|
if s.get("first_token") is None:
|
|
s["first_token"] = time.time()
|
|
(self.rate if rate is None else rate).append((time.time(), n)) # the live rate's window (RATE_WINDOW_S)
|
|
for ev in evs:
|
|
if ev.kind == "reasoning":
|
|
s["phase"] = "thinking"
|
|
elif ev.kind == "content":
|
|
s["phase"] = "answering"
|
|
elif ev.kind == "tool_start":
|
|
s["phase"], s["tool"] = f"writing a tool call: {ev.call.name}", ev.call.name
|
|
elif ev.kind == "tool_call":
|
|
s["phase"] = "tool call complete"
|
|
s["tail"] = ((s.get("tail") or "") + (ev.text or ""))[-600:]
|
|
|
|
def _progress(self, last_print, every=1.0, st=None):
|
|
"""A progress line in the server window every `every` seconds while a request runs."""
|
|
now = time.time()
|
|
if now - last_print < every:
|
|
return last_print
|
|
with self.status_lock:
|
|
s = dict(self.status if st is None else st)
|
|
el = now - s.get("started", now)
|
|
if s.get("first_token") is None:
|
|
pr = getattr(self.engine, "progress", None) # (position reached, prompt tokens): a reused prefix counts
|
|
done = f"{pr[0]:,} of {pr[1]:,}" if pr and pr[1] else f"{s.get('prompt_tokens', 0):,}" # as read (#29)
|
|
print(f"[strata] reading the prompt: {done} tokens, {el:.0f} s so far", flush=True)
|
|
else:
|
|
rate = s["generated"] / max(1e-6, now - s["first_token"])
|
|
print(f"[strata] {s['phase']}: {s['generated']} of max {s.get('max_tokens')} tokens, {rate:.1f} tok/s, "
|
|
f"{el:.0f} s", flush=True)
|
|
return now
|
|
|
|
def run(self, ids, thinking, tools, max_new, sampling, cancel) -> Iterator[tuple[str, object]]:
|
|
"""Yields ("event", Event) as text arrives, then ("done", {"finish": .., "completion_tokens": ..})."""
|
|
budget = self.reasoning_budget(sampling) if thinking else None # #123: opt-in, off by default
|
|
defaults = {**self.sampling_defaults, **self.shared} # the config's, then the Chat settings shared with apps
|
|
if defaults: # the request's own fields win (explicit 0 stays greedy)
|
|
req_values = {k: v for k, v in (sampling or {}).items() if v is not None}
|
|
sampling = {**defaults, **req_values}
|
|
parser = OutputParser(thinking=thinking, tools=tools, stream_tools=True)
|
|
detok, n, finish = Detokenizer(self.tok), 0, "length"
|
|
run_tok, run_len, repeated = None, 0, False # #606: the current run of one repeated token
|
|
thinking_n = 0 # tokens written while thinking (Responses' reasoning_tokens)
|
|
stop_list = stop_strings(sampling)
|
|
stops = StopMatcher(stop_list) if stop_list else None
|
|
|
|
def cut(evs):
|
|
"""The events with the answer's text cut at a stop string (OpenAI stop, Anthropic stop_sequences)."""
|
|
if stops is None:
|
|
return evs
|
|
out = []
|
|
for ev in evs:
|
|
if stops.hit is not None:
|
|
break # nothing after the stop string is sent
|
|
if ev.kind == "content":
|
|
text = stops.push(ev.text or "")
|
|
if text:
|
|
out.append(Event("content", text))
|
|
continue
|
|
held = stops.flush() # a stop string does not run across a tool call or thinking
|
|
if held:
|
|
out.append(Event("content", held))
|
|
out.append(ev)
|
|
return out
|
|
timings, before = None, None # this request's timings; the engine's `last` before it
|
|
raw_ids = [] # every generated id (STRATA_DEBUG: dump raw model text)
|
|
emb = getattr(self.embeddings, "path", None)
|
|
# Identity token: only a DONE line replaces engine.last, so a request that died, errored or was
|
|
# disconnected must not have the PREVIOUS request's decode figures recorded as its own.
|
|
engine_last0 = getattr(self.engine, "last", None)
|
|
trace = getattr(self.request_trace, "record", None)
|
|
waiting = time.perf_counter()
|
|
# #465: with "parallel" (the engine's batch slots) requests run at once: each keeps its own status and rate
|
|
# window (self.status says busy while any runs); one at a time they are self.status / self.rate, as before
|
|
par = bool(getattr(self.engine, "batch", 0))
|
|
st = {} if par else self.status
|
|
rate = collections.deque(maxlen=32) if par else self.rate
|
|
with self.status_lock:
|
|
self.status["queued"] += 1
|
|
try:
|
|
# --batch: the engine runs several requests at once (StrataEngine.generate_batched orders them)
|
|
with (contextlib.nullcontext() if getattr(self.engine, "batch", 0) else self.fifo):
|
|
try:
|
|
with self.status_lock:
|
|
if trace is not None:
|
|
trace["queue_s"] += round(time.perf_counter() - waiting, 3)
|
|
trace["state"] = "generating"
|
|
self.status["queued"] -= 1
|
|
# issue #27: it died in an earlier request (or was unloaded) - start it again instead of failing
|
|
# (parallel requests do not hold the fifo: one of them starts it, the others wait for that)
|
|
with (self.fifo if par else contextlib.nullcontext()):
|
|
self.ensure_loaded()
|
|
with self.status_lock:
|
|
st.update(busy=True, phase="reading the prompt", prompt_tokens=len(ids),
|
|
generated=0, started=time.time(), first_token=None, tool=None, tail="",
|
|
max_tokens=max_new)
|
|
if par:
|
|
self.live_reqs[id(st)] = (st, rate)
|
|
self.status.update(st)
|
|
self.last_request_at = time.time()
|
|
rate.clear() # the previous request's samples must not leak into this one
|
|
before = getattr(self.engine, "last", None)
|
|
last_print = time.time()
|
|
prompt, thought = ids, 0 # thought: the reasoning tokens so far (the budget's count)
|
|
while True:
|
|
gen = self.engine.generate(prompt, max_new - n, sampling, cancel, embeddings=emb) if emb \
|
|
else self.engine.generate(prompt, max_new - n, sampling, cancel)
|
|
seg, wrap, leaving = [], False, False # this pass's tokens; the budget is reached; closed
|
|
try:
|
|
for t in gen:
|
|
if t is None: # heartbeat while the engine is quiet
|
|
last_print = self._progress(last_print, st=st)
|
|
yield "ping", None
|
|
continue
|
|
n += 1
|
|
if trace is not None and trace["first_token_s"] is None:
|
|
with self.status_lock:
|
|
trace["first_token_s"] = round(time.perf_counter() - trace["_clock"], 3)
|
|
if t in self.stop_ids:
|
|
finish = "stop"
|
|
raw_ids.append(t)
|
|
break
|
|
raw_ids.append(t)
|
|
seg.append(t)
|
|
thinking_n += parser.state in ("reasoning", "rcall")
|
|
run_len = run_len + 1 if t == run_tok else 1
|
|
run_tok = t
|
|
if self.repeat_stop_tokens and run_len >= self.repeat_stop_tokens:
|
|
repeated = True # #606: a degenerate output, not an answer: end it here
|
|
break
|
|
evs = cut(parser.feed(detok.push(t)))
|
|
self._note(n, evs, st, rate)
|
|
last_print = self._progress(last_print, st=st)
|
|
for ev in evs:
|
|
yield "event", ev
|
|
if stops is not None and stops.hit is not None:
|
|
finish = "stop" # gen.close() below STOPs the engine, as for a stop token
|
|
break
|
|
if budget and parser.state == "reasoning":
|
|
thought += 1
|
|
# at a clean point: no tag held back, no character split across tokens
|
|
if thought >= budget and not parser.buf and not detok.pending():
|
|
wrap = True
|
|
break
|
|
except EngineDied as e:
|
|
finish = "error"
|
|
self._say_died(e)
|
|
raise
|
|
except ValueError as e: # the engine's ERR line (it may have ended after it)
|
|
finish = "error"
|
|
print(f"[strata] the engine reported an error: {e}", flush=True)
|
|
raise
|
|
except GeneratorExit: # the client went away: an engine that never acknowledges
|
|
leaving = True # the STOP below is ended, but no error replaces this
|
|
raise
|
|
finally:
|
|
try:
|
|
gen.close() # STOP+drain to THIS request's DONE while still holding the
|
|
# fifo, so a stop-token break can't leave the shared engine
|
|
# queue mid-drain for the next request to read as its own DONE
|
|
except EngineSilent as e: # #481: the STOP was never acknowledged: the engine is ended
|
|
finish = "error"
|
|
self._say_died(e)
|
|
if not leaving and not cancel.is_set():
|
|
raise
|
|
if not wrap or cancel.is_set():
|
|
break
|
|
# #123: the thinking reached reasoning_budget_tokens. Close it the way the model would (a
|
|
# short wrap-up and </think>) and let it answer: the next pass's prompt is this one plus what
|
|
# was generated plus the wrap-up, so the engine continues from the prefix it already holds.
|
|
budget = None
|
|
extra = self.tok.encode(REASONING_WRAP_UP, parse_special=True)
|
|
if max_new - n - len(extra) < 1:
|
|
break # no room left to answer: "length", as without a budget
|
|
print(f"[strata] thinking budget reached ({thought} tokens): wrapping up the thinking",
|
|
flush=True)
|
|
for t in extra:
|
|
n += 1
|
|
raw_ids.append(t)
|
|
thinking_n += parser.state in ("reasoning", "rcall")
|
|
evs = cut(parser.feed(detok.push(t)))
|
|
self._note(n, evs, st, rate)
|
|
for ev in evs:
|
|
yield "event", ev
|
|
if stops is not None and stops.hit is not None:
|
|
finish = "stop"
|
|
break
|
|
prompt = prompt + seg + extra
|
|
if cancel.is_set():
|
|
finish = "cancel"
|
|
elif repeated:
|
|
print(f"[strata] the reply repeated one token ({self.tok.decode([run_tok])!r}) "
|
|
f"{run_len} times in a row: ended as \"length\" (repeat_stop_tokens in "
|
|
"strata-<model>.json; 0 turns this off). If a new request with a short prompt does the "
|
|
"same, restart the server and report it (#606)", flush=True)
|
|
except GeneratorExit: # the client disconnected mid-stream
|
|
finish = "disconnect"
|
|
raise
|
|
finally:
|
|
# #266: settle this request's status, history and totals while still holding the fifo: once
|
|
# it is released the next request sets its own status, which this must not record or clear
|
|
with self.status_lock:
|
|
if st.get("busy"):
|
|
# only this request's DONE counts: same object means no DONE arrived (death, error,
|
|
# disconnect)
|
|
last = dict(getattr(self.engine, "last", {}) or {}) \
|
|
if getattr(self.engine, "last", None) is not engine_last0 else {}
|
|
started = st.get("started", time.time())
|
|
cvec = (getattr(self.engine, "info", {}) or {}).get("cvec", 0)
|
|
loaded = str(cvec) not in ("0", "", "None")
|
|
hit_rate = round(last["hits"] / last["lookups"], 3) if last.get("lookups") else None
|
|
# #588: the share the GPU read over PCIe (--pcie-frac) or another GPU computed - not in
|
|
# the hit rate's lookups, which is the VRAM share of the rest (None: an older engine)
|
|
routed = (last.get("lookups") or 0) + (last.get("offloaded") or 0)
|
|
pcie_share = round(last["offloaded"] / routed, 3) \
|
|
if last.get("offloaded") is not None and routed else None
|
|
seen = prompt_tokens_seen(len(ids), last) # #471: < len(ids) when cancelled mid-read
|
|
self.history.append({
|
|
"projection": (sampling or {}).get("experimental_speed_projection") is not False
|
|
if loaded else None,
|
|
"time": started, "duration_s": round(time.time() - started, 1), "finish": finish,
|
|
"prompt_tokens": seen, "reused": last.get("reused"), "output_tokens": n,
|
|
# the request's whole prompt, and the tokens read of it (None: an older engine)
|
|
"prompt_total": len(ids), "prompt_read": last.get("prompt_read"),
|
|
"engine_generated": last.get("generated"),
|
|
"prompt_ms": last.get("prompt_ms"), "decode_ms": last.get("decode_ms"),
|
|
"decode_tok_s": round(last["generated"] / (last["decode_ms"] / 1000), 1)
|
|
if n and last.get("generated") and last.get("decode_ms") else None,
|
|
"hit_rate": hit_rate, "pcie_share": pcie_share, "ram_blobs": last.get("ram_blobs"),
|
|
"file_blobs": last.get("file_blobs"), "file_mb": last.get("file_mb"),
|
|
# #457: the speculative drafts from the DONE line (None: the engine did not say)
|
|
"drafts_offered": last.get("drafts_offered"),
|
|
"drafts_accepted": last.get("drafts_accepted")})
|
|
t = self.totals
|
|
t["requests"] += 1
|
|
t["prompt_tokens"] += seen
|
|
t["reused"] += last.get("reused") or 0
|
|
t["output_tokens"] += n
|
|
t["prompt_ms"] += last.get("prompt_ms") or 0.0
|
|
t["decode_ms"] += last.get("decode_ms") or 0.0
|
|
t["drafts_offered"] += last.get("drafts_offered") or 0
|
|
t["drafts_accepted"] += last.get("drafts_accepted") or 0
|
|
fresh = getattr(self.engine, "last", None)
|
|
if fresh is not None and fresh is not before: # the engine's clock for THIS request
|
|
timings = request_timings(seen, n, last)
|
|
self.last_timings = dict(timings, at=int(time.time())) if timings else None
|
|
self.last_request_at = time.time()
|
|
now = time.time()
|
|
el = now - st.get("started", now)
|
|
ft = st.get("first_token")
|
|
rate = n / max(1e-6, now - ft) if ft else 0.0
|
|
hit_msg = f", expert cache {hit_rate*100:.1f}% hit" if hit_rate is not None else ""
|
|
if hit_msg and pcie_share:
|
|
hit_msg += f" (+{pcie_share*100:.1f}% of the routed experts over PCIe)"
|
|
print(f"[strata] done: {n} tokens in {el:.0f} s ({rate:.1f} tok/s) "
|
|
f"({finish}, cancel={cancel.is_set()}){hit_msg}", flush=True)
|
|
if finish == "length" and parser.state in ("reasoning", "rcall"): # #530
|
|
print("[strata] the reply reached max tokens while still thinking, so it has no "
|
|
"answer: a thinking budget (reasoning_budget_tokens, in the request or in "
|
|
"strata-<model>.json for every request) leaves room to answer", flush=True)
|
|
if os.environ.get("STRATA_DEBUG") and raw_ids:
|
|
print(f"[strata] raw: {self.tok.decode(raw_ids)!r}", flush=True)
|
|
st["busy"] = False
|
|
st.pop("tail", None) # #212: the answer's end is not kept once it is done
|
|
st.pop("tool", None)
|
|
if par:
|
|
self.live_reqs.pop(id(st), None)
|
|
if self.live_reqs: # the newest request still running
|
|
self.status.update(list(self.live_reqs.values())[-1][0])
|
|
else:
|
|
self.status.update(busy=False)
|
|
self.status.pop("tail", None)
|
|
self.status.pop("tool", None)
|
|
finally:
|
|
if emb:
|
|
Path(emb).unlink(missing_ok=True)
|
|
for ev in cut(parser.finish()):
|
|
yield "event", ev
|
|
if stops is not None and stops.hit is None and stops.held:
|
|
yield "event", Event("content", stops.flush()) # the held tail was not a stop string after all
|
|
done = {"finish": finish, "completion_tokens": n, "reused": (timings or {}).get("cache_n", 0),
|
|
"timings": timings, "reasoning_tokens": thinking_n}
|
|
if stops is not None and stops.hit is not None:
|
|
done["stop_sequence"] = stops.hit
|
|
yield "done", done
|
|
|
|
|
|
def prompt_tokens_seen(prompt_tokens: int, last: dict) -> int:
|
|
"""#471: the prompt tokens a request got through - all of them, unless the engine's DONE line says a cancel stopped
|
|
its prompt read part-way (then the reused ones plus those read). /metrics' history and totals count these, so a
|
|
cancelled read is neither recorded as the whole prompt nor given a rate from tokens it never read. An engine
|
|
before 0.1.36 does not say (no `prompt_read`): the whole prompt, as before."""
|
|
read = last.get("prompt_read")
|
|
if read is None or last.get("finish") != "cancel":
|
|
return prompt_tokens
|
|
return min(prompt_tokens, int(last.get("reused") or 0) + int(read))
|
|
|
|
|
|
def request_timings(prompt_tokens: int, generated: int, last: dict) -> dict | None:
|
|
"""One request's `timings` in llama.cpp's names (what its clients show as speed), from the engine's own clock
|
|
(StrataEngine.last): prompt_n is what was read, cache_n what the conversation cache already held. None when the
|
|
engine keeps no clock (MockEngine)."""
|
|
if last.get("prompt_ms") is None:
|
|
return None
|
|
cache_n = int(last.get("reused") or 0)
|
|
prompt_n, prompt_ms, decode_ms = max(0, prompt_tokens - cache_n), float(last["prompt_ms"]), float(last.get("decode_ms") or 0)
|
|
decoded = int(last.get("generated") or generated) # the engine's count gives its rate, as /metrics does
|
|
return {"cache_n": cache_n, "prompt_n": prompt_n, "prompt_ms": round(prompt_ms, 1),
|
|
"prompt_per_token_ms": round(prompt_ms / prompt_n, 3) if prompt_n else None,
|
|
"prompt_per_second": round(prompt_n / (prompt_ms / 1000), 1) if prompt_n and prompt_ms > 0 else None,
|
|
"predicted_n": generated, "predicted_ms": round(decode_ms, 1),
|
|
"predicted_per_token_ms": round(decode_ms / decoded, 3) if decoded else None,
|
|
"predicted_per_second": round(decoded / (decode_ms / 1000), 1) if decoded and decode_ms > 0 else None,
|
|
# the speculative drafts, as llama.cpp names them (from PR #83, @mikicvi): only when the engine reported them
|
|
**({"draft_n": int(last["drafts_offered"]), "draft_n_accepted": int(last["drafts_accepted"])}
|
|
if last.get("drafts_offered") is not None else {})}
|
|
|
|
|
|
def _debug_req(api, req, messages, tools, max_new, thinking, prompt_tokens):
|
|
"""One compact line per request while diagnosing blank/empty turns. Set STRATA_DEBUG=1 to enable."""
|
|
if not os.environ.get("STRATA_DEBUG"):
|
|
return
|
|
last = messages[-1] if messages else {}
|
|
body = last.get("content")
|
|
if isinstance(body, list):
|
|
body = " ".join(p.get("text", "") for p in body if isinstance(p, dict))
|
|
preview = (str(body or "")[:80]).replace("\n", " ")
|
|
print(f"[strata] req {api}: msgs={len(messages)} tools={len(tools or [])} "
|
|
f"max_tokens_raw={req.get('max_tokens')!r}/{req.get('max_completion_tokens')!r} "
|
|
f"max_new={max_new} thinking={thinking} stream={bool(req.get('stream'))} "
|
|
f"prompt_tokens={prompt_tokens} last={last.get('role')!r}:{preview!r}", flush=True)
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------ MCP tool loop
|
|
def run_with_mcp(svc: Service, hub, messages, tools, kw, ids, thinking, max_new, max_req, sampling, cancel,
|
|
mcp_names):
|
|
"""Service.run with the MCP tools executed here: the model writes a call to an MCP tool, the server runs it, adds
|
|
the call and its result to the conversation and lets the model continue - up to `max_rounds` times. Yields what
|
|
Service.run yields (text, thinking, the request's own tool calls) plus ("mcp", {...}) for the tool activity, and
|
|
one ("done", ...) at the very end with the output tokens of every round.
|
|
|
|
`mcp_names`: the MCP tools this request offered; any other call is one of the request's own tools and ends the
|
|
turn as always (the client answers it). MCP calls written in the same answer are then not run (their results
|
|
could not reach the model before the client's)."""
|
|
max_rounds = int(hub.settings["max_rounds"])
|
|
total, rounds, done = 0, 0, None
|
|
messages = list(messages)
|
|
while True:
|
|
text, reasoning, calls, own_calls = [], [], [], 0
|
|
for kind, x in svc.run(ids, thinking, tools, max_new, sampling, cancel):
|
|
if kind == "done":
|
|
done = x
|
|
continue
|
|
if kind == "event":
|
|
ev: Event = x
|
|
if ev.call is not None and ev.call.name in mcp_names:
|
|
if ev.kind == "tool_start": # the model has started writing a call: say so at once
|
|
yield "mcp", {"event": "start", "id": ev.call.id, "name": ev.call.name}
|
|
elif ev.kind == "tool_call":
|
|
calls.append(ev.call)
|
|
continue # its argument pieces are not streamed to the client
|
|
if ev.kind == "tool_call":
|
|
own_calls += 1
|
|
elif ev.kind == "content":
|
|
text.append(ev.text)
|
|
elif ev.kind == "reasoning":
|
|
reasoning.append(ev.text)
|
|
yield kind, x
|
|
total += done["completion_tokens"]
|
|
run_them = calls and not own_calls and done["finish"] == "stop" and not cancel.is_set()
|
|
if run_them and rounds >= max_rounds:
|
|
yield "mcp", {"event": "limit", "max_rounds": max_rounds}
|
|
run_them = False
|
|
if not run_them:
|
|
for c in calls: # announced, never run: close them in the client's view
|
|
yield "mcp", {"event": "result", "id": c.id, "ok": False, "skipped": True, "text": "not run",
|
|
"chars": 0, "truncated": False, "ms": 0}
|
|
break
|
|
rounds += 1
|
|
results = []
|
|
for c in calls:
|
|
s, tool = hub.routes().get(c.name, (None, c.name))
|
|
yield "mcp", {"event": "call", "id": c.id, "name": c.name, "server": s.name if s else None,
|
|
"tool": tool, "arguments": c.arguments, "round": rounds}
|
|
# The call runs on a thread while this generator keeps yielding heartbeats: they reach the client as
|
|
# keep-alives, which is how a client that went away (the web app's Stop) is noticed during a slow tool.
|
|
box = {}
|
|
|
|
def work(c=c, box=box):
|
|
try:
|
|
box["r"] = hub.call(c.name, c.arguments, cancel)
|
|
except McpCancelled:
|
|
box["cancelled"] = True
|
|
worker = threading.Thread(target=work, daemon=True)
|
|
worker.start()
|
|
try:
|
|
while worker.is_alive():
|
|
worker.join(1.0)
|
|
if worker.is_alive():
|
|
yield "ping", None
|
|
except GeneratorExit:
|
|
cancel.set() # the client is gone: stop the tool too
|
|
raise
|
|
if "r" not in box:
|
|
break
|
|
r = box["r"]
|
|
print(f"[strata] tool {c.name}: {'ok' if r['ok'] else 'error'}, {r['chars']:,} characters in "
|
|
f"{r['ms'] / 1000:.1f} s{' (truncated for the model)' if r['truncated'] else ''}", flush=True)
|
|
results.append(r["text"])
|
|
yield "mcp", {"event": "result", "id": c.id, **{k: r[k] for k in ("ok", "text", "chars", "truncated", "ms")}}
|
|
if cancel.is_set() or len(results) < len(calls):
|
|
done = {**done, "finish": "cancel"}
|
|
break
|
|
messages.append({"role": "assistant", "content": "".join(text).strip(),
|
|
**({"reasoning_content": "".join(reasoning).strip()} if reasoning else {}),
|
|
"tool_calls": [{"function": {"name": c.name, "arguments": c.arguments}} for c in calls]})
|
|
messages += [{"role": "tool", "content": r} for r in results]
|
|
ids, thinking, max_new = svc.prepare(messages, tools, kw, max_req)
|
|
yield "done", {**done, "completion_tokens": total, "prompt_tokens": len(ids)}
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------ OpenAI
|
|
def openai_chunks(svc: Service, req: dict, ids, thinking, tools, max_new, cancel, run=None):
|
|
"""`run`: the events to send instead of Service.run's (run_with_mcp); its ("mcp", {...}) items become chunks with
|
|
an empty delta and a `strata_mcp` field, which only the web app reads."""
|
|
cid, created = "chatcmpl-" + uuid.uuid4().hex[:24], int(time.time())
|
|
model = svc.model_for(req)
|
|
|
|
def chunk(delta, finish=None):
|
|
return {"id": cid, "object": "chat.completion.chunk", "created": created, "model": model,
|
|
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}
|
|
|
|
yield chunk({"role": "assistant", "content": ""})
|
|
calls = 0
|
|
streamed = {} # tool call id -> index, for calls sent piece by piece
|
|
finished = set() # ... and the ones whose final tool_call came (#211)
|
|
for kind, x in run if run is not None else svc.run(ids, thinking, tools, max_new, req, cancel):
|
|
if kind == "ping":
|
|
yield None
|
|
elif kind == "mcp":
|
|
c = chunk({})
|
|
c["strata_mcp"] = x
|
|
yield c
|
|
elif kind == "event":
|
|
ev: Event = x
|
|
if ev.kind == "reasoning" and ev.text:
|
|
yield chunk({"reasoning_content": ev.text})
|
|
elif ev.kind == "content" and ev.text:
|
|
yield chunk({"content": ev.text})
|
|
elif ev.kind == "tool_start":
|
|
streamed[ev.call.id] = calls
|
|
calls += 1
|
|
yield chunk({"tool_calls": [{"index": streamed[ev.call.id], "id": ev.call.id, "type": "function",
|
|
"function": {"name": ev.call.name, "arguments": ""}}]})
|
|
elif ev.kind == "tool_args":
|
|
yield chunk({"tool_calls": [{"index": streamed[ev.call.id], "function": {"arguments": ev.text}}]})
|
|
elif ev.kind == "tool_call" and ev.call.id in streamed:
|
|
finished.add(ev.call.id)
|
|
elif ev.kind == "tool_call":
|
|
yield chunk({"tool_calls": [{"index": calls, "id": ev.call.id, "type": "function",
|
|
"function": {"name": ev.call.name,
|
|
"arguments": json.dumps(ev.call.arguments, ensure_ascii=False)}}]})
|
|
calls += 1
|
|
else:
|
|
# a streamed call without its final tool_call is one the output ended inside: not "tool_calls" (#211)
|
|
whole = calls and streamed.keys() <= finished
|
|
finish = "tool_calls" if whole and x["finish"] == "stop" else {"cancel": "stop"}.get(x["finish"], x["finish"])
|
|
last = chunk({}, finish)
|
|
pt = x.get("prompt_tokens", len(ids)) # after MCP rounds: the last round's prompt
|
|
last["usage"] = {"prompt_tokens": pt, "completion_tokens": x["completion_tokens"],
|
|
"total_tokens": pt + x["completion_tokens"],
|
|
# the part of the prompt the conversation cache already held (OpenAI's field)
|
|
"prompt_tokens_details": {"cached_tokens": x.get("reused") or 0}}
|
|
if x.get("timings"):
|
|
last["timings"] = x["timings"] # llama.cpp's field: the speed its clients show
|
|
yield last
|
|
|
|
|
|
def _is_json(text: str) -> bool:
|
|
try:
|
|
json.loads(text)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def openai_collect(chunks) -> dict:
|
|
content, reasoning, by_index, last, mcp = [], [], {}, None, []
|
|
for c in chunks:
|
|
if c is None: # a heartbeat
|
|
continue
|
|
if c.get("strata_mcp"):
|
|
mcp.append(c["strata_mcp"])
|
|
d = c["choices"][0]["delta"]
|
|
content.append(d.get("content") or "")
|
|
reasoning.append(d.get("reasoning_content") or "")
|
|
for tc in d.get("tool_calls") or []: # streamed calls arrive in pieces: merge them by index
|
|
cur = by_index.setdefault(tc.get("index", len(by_index)), {"id": None, "type": "function",
|
|
"function": {"name": "", "arguments": ""}})
|
|
cur["id"] = tc.get("id") or cur["id"]
|
|
fn = tc.get("function") or {}
|
|
cur["function"]["name"] += fn.get("name") or ""
|
|
cur["function"]["arguments"] += fn.get("arguments") or ""
|
|
last = c
|
|
calls = [by_index[i] for i in sorted(by_index)]
|
|
if last["choices"][0]["finish_reason"] != "tool_calls":
|
|
calls = [c for c in calls if _is_json(c["function"]["arguments"])] # a call the output ended inside (#211)
|
|
msg = {"role": "assistant", "content": "".join(content) or None}
|
|
if "".join(reasoning):
|
|
msg["reasoning_content"] = "".join(reasoning)
|
|
if calls:
|
|
msg["tool_calls"] = calls
|
|
if mcp:
|
|
msg["strata_mcp"] = mcp
|
|
out = {"id": last["id"], "object": "chat.completion", "created": last["created"], "model": last["model"],
|
|
"choices": [{"index": 0, "message": msg, "finish_reason": last["choices"][0]["finish_reason"]}],
|
|
"usage": last["usage"]}
|
|
if last.get("timings"):
|
|
out["timings"] = last["timings"]
|
|
return out
|
|
|
|
|
|
def structured_chunks(chunks, validator):
|
|
"""Buffer structured streams so a client never receives unvalidated content."""
|
|
buffered = []
|
|
heartbeat = time.monotonic()
|
|
try:
|
|
for chunk in chunks:
|
|
if chunk is not None:
|
|
buffered.append(chunk)
|
|
if chunk is None or time.monotonic() - heartbeat >= 1:
|
|
heartbeat = time.monotonic()
|
|
yield None
|
|
result = openai_collect(buffered)
|
|
choice = result["choices"][0]
|
|
content = validated_json(choice["message"]["content"], validator, choice["finish_reason"])
|
|
yield buffered[0]
|
|
delta = {"content": content}
|
|
if choice["message"].get("reasoning_content"):
|
|
delta["reasoning_content"] = choice["message"]["reasoning_content"]
|
|
yield {**buffered[0], "choices": [{"index": 0, "delta": delta, "finish_reason": None}]}
|
|
yield buffered[-1]
|
|
finally:
|
|
chunks.close()
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------ Anthropic
|
|
def anthropic_events(svc: Service, req: dict, ids, thinking, tools, max_new, cancel):
|
|
mid = "msg_" + uuid.uuid4().hex[:24]
|
|
yield "message_start", {"type": "message_start", "message": {
|
|
"id": mid, "type": "message", "role": "assistant", "model": svc.model_for(req), "content": [],
|
|
"stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": len(ids), "output_tokens": 0}}}
|
|
index, open_kind, used_tool = -1, None, False
|
|
|
|
def close():
|
|
return ("content_block_stop", {"type": "content_block_stop", "index": index})
|
|
|
|
streamed, finished = set(), set() # calls sent piece by piece; those whose final tool_call came (#211)
|
|
for kind, x in svc.run(ids, thinking, tools, max_new, req, cancel):
|
|
if kind == "ping":
|
|
yield None
|
|
continue
|
|
if kind == "event":
|
|
ev: Event = x
|
|
if ev.kind == "tool_args":
|
|
yield "content_block_delta", {"type": "content_block_delta", "index": index,
|
|
"delta": {"type": "input_json_delta", "partial_json": ev.text}}
|
|
continue
|
|
if ev.kind == "tool_call" and ev.call.id in streamed:
|
|
finished.add(ev.call.id)
|
|
continue
|
|
want = {"reasoning": "thinking", "content": "text", "tool_call": "tool_use", "tool_start": "tool_use"}[ev.kind]
|
|
if ev.kind not in ("tool_call", "tool_start") and not ev.text:
|
|
continue
|
|
if ev.kind == "tool_start":
|
|
streamed.add(ev.call.id)
|
|
used_tool = True
|
|
if open_kind != want or want == "tool_use":
|
|
if open_kind is not None:
|
|
yield close()
|
|
index += 1
|
|
open_kind = want
|
|
block = {"thinking": {"type": "thinking", "thinking": "", "signature": ""},
|
|
"text": {"type": "text", "text": ""},
|
|
"tool_use": {"type": "tool_use", "id": ev.call.id if ev.call else "", "name":
|
|
ev.call.name if ev.call else "", "input": {}}}[want]
|
|
yield "content_block_start", {"type": "content_block_start", "index": index, "content_block": block}
|
|
if want == "thinking":
|
|
yield "content_block_delta", {"type": "content_block_delta", "index": index,
|
|
"delta": {"type": "thinking_delta", "thinking": ev.text}}
|
|
elif want == "text":
|
|
yield "content_block_delta", {"type": "content_block_delta", "index": index,
|
|
"delta": {"type": "text_delta", "text": ev.text}}
|
|
elif ev.kind == "tool_start":
|
|
pass # its input follows as tool_args pieces
|
|
else:
|
|
used_tool = True
|
|
yield "content_block_delta", {"type": "content_block_delta", "index": index, "delta": {
|
|
"type": "input_json_delta", "partial_json": json.dumps(ev.call.arguments, ensure_ascii=False)}}
|
|
else:
|
|
if open_kind is not None:
|
|
yield close()
|
|
stop = "stop_sequence" if x.get("stop_sequence") is not None else \
|
|
"tool_use" if used_tool and streamed <= finished and x["finish"] == "stop" else \
|
|
{"stop": "end_turn", "length": "max_tokens", "cancel": "end_turn"}[x["finish"]]
|
|
# the final counts, Anthropic's way: input_tokens leaves out what the conversation cache already held,
|
|
# which is cache_read_input_tokens (message_start could only say the whole prompt)
|
|
reused = min(x.get("reused") or 0, len(ids))
|
|
yield "message_delta", {"type": "message_delta", "delta": {"stop_reason": stop,
|
|
"stop_sequence": x.get("stop_sequence")},
|
|
"usage": {"input_tokens": len(ids) - reused, "cache_read_input_tokens": reused,
|
|
"output_tokens": x["completion_tokens"]}}
|
|
yield "message_stop", {"type": "message_stop"}
|
|
|
|
|
|
def anthropic_collect(events) -> dict:
|
|
msg, blocks = None, []
|
|
for item in events:
|
|
if item is None: # a heartbeat
|
|
continue
|
|
name, e = item
|
|
if name == "message_start":
|
|
msg = e["message"]
|
|
elif name == "content_block_start":
|
|
blocks.append(dict(e["content_block"]))
|
|
elif name == "content_block_delta":
|
|
d, b = e["delta"], blocks[-1]
|
|
if d["type"] == "text_delta":
|
|
b["text"] += d["text"]
|
|
elif d["type"] == "thinking_delta":
|
|
b["thinking"] += d["thinking"]
|
|
else: # input_json_delta pieces: parsed when complete
|
|
b["_json"] = b.get("_json", "") + d["partial_json"]
|
|
elif name == "content_block_stop" and blocks and "_json" in blocks[-1]:
|
|
b = blocks[-1]
|
|
try:
|
|
b["input"] = json.loads(b.pop("_json") or "{}")
|
|
except ValueError: # a call the output ended inside (#211): it has no input to give
|
|
blocks.pop()
|
|
elif name == "message_delta":
|
|
msg["stop_reason"] = e["delta"]["stop_reason"]
|
|
msg["stop_sequence"] = e["delta"].get("stop_sequence")
|
|
msg["usage"].update(e["usage"])
|
|
msg["content"] = blocks
|
|
return msg
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------ HTTP
|
|
def make_handler(svc: Service):
|
|
class Handler(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.0" # SSE ends by closing the connection
|
|
|
|
record = None # #332: this request's monitor record, if kept
|
|
watch_done = None # #430 #431: stops this request's disconnect watcher
|
|
body_read = False # whether a handler took this request's body
|
|
DRAIN_SECONDS = 5 # the longest an unread body is read and dropped
|
|
|
|
def log_message(self, fmt, *args):
|
|
pass
|
|
|
|
def handle_one_request(self):
|
|
super().handle_one_request()
|
|
self._drain_body()
|
|
|
|
def _body(self) -> bytes:
|
|
self.body_read = True
|
|
return self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
|
|
|
def _drain_body(self):
|
|
"""An answer sent before the body was read (a 401, a 403, /load, a method with no handler) must not close
|
|
the connection on unread bytes: the close then sends a reset, and a client that sends its body after the
|
|
headers (http.client, urllib, requests) gets a connection error instead of the answer. So the body is
|
|
read and dropped here, once, after an answer, in pieces so that its size is never held in memory. What
|
|
has not arrived DRAIN_SECONDS later is left unread: the limit is time, so that a conversation of many
|
|
megabytes, at any speed the client has, still gets its answer."""
|
|
headers = getattr(self, "headers", None)
|
|
if self.body_read or headers is None:
|
|
return
|
|
try:
|
|
left = int(headers.get("Content-Length", 0))
|
|
except ValueError:
|
|
return
|
|
if left <= 0:
|
|
return
|
|
deadline = time.monotonic() + self.DRAIN_SECONDS
|
|
try:
|
|
while left > 0 and (wait := deadline - time.monotonic()) > 0:
|
|
self.connection.settimeout(wait) # a client that never sends what it announced
|
|
piece = self.rfile.read1(min(left, 1 << 20)) # one socket read: read() would wait for it all
|
|
if not piece:
|
|
break
|
|
left -= len(piece)
|
|
except OSError:
|
|
pass
|
|
|
|
def parse_request(self):
|
|
"""Without an API key, every request (any method) first passes the Host check: DNS rebinding protection
|
|
(host_allowed). With a key a rebinding page cannot authenticate, so the check is skipped: tunnels and
|
|
proxies that pass their own name on keep working."""
|
|
if not super().parse_request():
|
|
return False
|
|
host = self.headers.get("Host")
|
|
if svc.api_key or host_allowed(host, svc.host_names, "*" in svc.allowed_hosts):
|
|
return True
|
|
print(f"[strata] refused a request for Host {host!r} from {self.client_address[0]}: not a name this server "
|
|
f"answers to (add it to \"allowed_hosts\" in the config or STRATA_ALLOWED_HOSTS, or set an API key)",
|
|
flush=True)
|
|
self._json(403, {"error": {"type": "forbidden", "message":
|
|
f"Host {host!r} is not allowed (DNS rebinding protection). Reaching Strata under this "
|
|
f"name on purpose? Add it to \"allowed_hosts\" in the config (strata-<model>.json) or to "
|
|
f"the STRATA_ALLOWED_HOSTS environment variable, or set an API key (\"api_key\"), which "
|
|
f"turns this check off"}})
|
|
return False
|
|
|
|
def _foreign_page(self) -> bool:
|
|
"""Without an API key, a /v1 POST from a browser page of another site (any site can POST text/plain
|
|
there without a CORS preflight) would burn GPU time: an Origin header must name an allowed page, and
|
|
then the body must be JSON. No Origin (curl, the SDKs, other servers): any content type, as before."""
|
|
origin = self.headers.get("Origin")
|
|
if svc.api_key or not origin:
|
|
return False
|
|
if not origin_allowed(origin, self.headers.get("Host"), svc.host_names,
|
|
[*svc.trusted_origins, *svc.cors_origins]):
|
|
print(f"[strata] refused an API request from the web page {origin!r} (no API key; add its host to "
|
|
f"\"allowed_hosts\" or its origin to \"cors_origins\" in the config)", flush=True)
|
|
self._json(403, {"error": {"type": "forbidden", "message":
|
|
f"web pages of {origin} may not use this server without an API key; set \"api_key\", "
|
|
f"or add the page's host to \"allowed_hosts\" (or its origin to \"cors_origins\") "
|
|
f"in the config"}})
|
|
return True
|
|
if not self.headers.get("Content-Type", "").startswith("application/json"):
|
|
self._json(415, {"error": {"message": "send application/json"}})
|
|
return True
|
|
return False
|
|
|
|
def _watch_client(self, cancel: threading.Event) -> None:
|
|
"""#430 #431: cancel the request as soon as its client hangs up. A non-streamed request writes nothing
|
|
until it ends, and a streamed one only a keep-alive per prompt chunk (and the first write after a hang-up
|
|
usually still succeeds), so a dropped request kept the engine busy until its answer or the whole prompt
|
|
was done. Every 0.5 s: the socket readable with nothing to read (EOF) means the client closed it. A
|
|
request is HTTP/1.0 and fully read here, so no later bytes are expected - data is not a hang-up."""
|
|
done = self.watch_done = threading.Event()
|
|
sock = self.connection
|
|
|
|
def watch():
|
|
while not done.wait(0.5) and not cancel.is_set():
|
|
try:
|
|
readable, _, _ = select.select([sock], [], [], 0)
|
|
gone = bool(readable) and sock.recv(1, socket.MSG_PEEK) == b""
|
|
except (ConnectionError, TimeoutError):
|
|
gone = True
|
|
except (OSError, ValueError): # the socket was closed here: the request has ended
|
|
return
|
|
if gone:
|
|
self._note(outcome="disconnected")
|
|
cancel.set()
|
|
return
|
|
|
|
threading.Thread(target=watch, daemon=True, name="strata-client-watch").start()
|
|
|
|
def _note(self, **values):
|
|
"""#332: what the monitor shows about this request (nothing when the monitor is off)."""
|
|
if self.record is not None:
|
|
with svc.status_lock:
|
|
self.record.update(values)
|
|
|
|
def _cors(self):
|
|
"""#321: CORS headers for an API path (/v1/*) and an origin the config lists in cors_origins - nothing
|
|
otherwise, so a browser keeps every other page away from the API, /settings, /unload and the MCP tools."""
|
|
if not svc.cors_origins or not self.path.split("?")[0].startswith("/v1/"):
|
|
return
|
|
origin = (self.headers.get("Origin") or "").rstrip("/")
|
|
if "*" in svc.cors_origins:
|
|
allow = "*"
|
|
elif origin and origin in svc.cors_origins:
|
|
allow = origin
|
|
self.send_header("Vary", "Origin")
|
|
else:
|
|
return
|
|
self.send_header("Access-Control-Allow-Origin", allow)
|
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
# the headers the preflight asks for (SDKs add their own; "*" does not cover Authorization)
|
|
asked = self.headers.get("Access-Control-Request-Headers")
|
|
self.send_header("Access-Control-Allow-Headers",
|
|
asked or "Authorization, Content-Type, x-api-key, anthropic-version, anthropic-beta")
|
|
self.send_header("Access-Control-Max-Age", "600")
|
|
|
|
def do_OPTIONS(self):
|
|
# a CORS preflight: no credentials come with it, so no API key; the headers only for cors_origins
|
|
self.send_response(204)
|
|
self._cors()
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
|
|
def _json(self, code, obj):
|
|
if self.record is not None:
|
|
with svc.status_lock:
|
|
self.record["http_status"] = code
|
|
raw = json.dumps(obj, ensure_ascii=False, indent=2)
|
|
self.record.update(response=raw[:262144], response_truncated=len(raw) > 262144)
|
|
for key in ("error", "usage", "timings"):
|
|
if key in obj:
|
|
self.record[key] = obj[key]
|
|
body = json.dumps(obj, ensure_ascii=False).encode()
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json")
|
|
self._cors()
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _authorized(self) -> bool:
|
|
if not svc.api_key:
|
|
return True
|
|
auth = self.headers.get("Authorization", "")
|
|
given = auth[7:].strip() if auth.lower().startswith("bearer ") else self.headers.get("x-api-key", "")
|
|
if hmac.compare_digest(given.encode(), svc.api_key.encode()): # #213: constant-time
|
|
return True
|
|
self._json(401, {"error": {"type": "authentication_error", "message": "missing or wrong API key"}})
|
|
return False
|
|
|
|
def do_GET(self):
|
|
path = self.path.split("?")[0].rstrip("/")
|
|
if path.startswith("/fonts/"):
|
|
# the web app's font (Outfit, OFL: serve/web/fonts); the page falls back to the system font
|
|
name = path[len("/fonts/"):]
|
|
f = ROOT / "serve" / "web" / "fonts" / name
|
|
if "/" in name or "\\" in name or not name.endswith(".woff2") or not f.is_file():
|
|
self._json(404, {"error": {"message": "not found"}})
|
|
return
|
|
body = f.read_bytes()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "font/woff2")
|
|
self.send_header("Cache-Control", "max-age=86400")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
return
|
|
if path.startswith("/web/"):
|
|
# the web app's own files (serve/web): styles, script, icon sprite - same origin, no CDN
|
|
name = path[len("/web/"):]
|
|
types = {".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
|
".svg": "image/svg+xml"}
|
|
f = ROOT / "serve" / "web" / name
|
|
ext = os.path.splitext(name)[1]
|
|
if "/" in name or "\\" in name or ext not in types or not f.is_file():
|
|
self._json(404, {"error": {"message": "not found"}})
|
|
return
|
|
body = f.read_bytes()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", types[ext])
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
return
|
|
if path == "/metrics":
|
|
if self._authorized():
|
|
# the last 12 requests; `?requests=all` every one kept (the Monitor's "Show all", issue #35)
|
|
self._json(200, svc.metrics(all_requests="requests=all" in self.path))
|
|
return
|
|
if path == "/api/requests" and svc.api_monitor:
|
|
if self._authorized():
|
|
request_id = parse_qs(urlsplit(self.path).query).get("id", [None])[0]
|
|
records = svc.request_records(request_id)
|
|
self._json(404 if records is None else 200,
|
|
{"error": {"message": "request no longer retained"}} if records is None else
|
|
records if request_id else {"requests": records, "retention": 100, "persistent": False,
|
|
"loaded": svc.loaded(), "auto_load": hasattr(svc.engine, "restart")})
|
|
return
|
|
if path == "/settings":
|
|
if self._authorized():
|
|
self._json(200, {"shared": bool(svc.shared), "defaults": svc.shared})
|
|
return
|
|
if path == "/config": # #564: the Settings view's keys of the run config
|
|
if self._authorized():
|
|
self._config_get()
|
|
return
|
|
if path == "/mcp":
|
|
# the MCP servers, their state and tools (the web app's switch and Monitor card)
|
|
if self._authorized():
|
|
self._json(200, svc.mcp.status() if svc.mcp else {"servers": [], "tools": 0})
|
|
return
|
|
if path == "" or (path == "/api-monitor" and svc.api_monitor):
|
|
body = (ROOT / "serve" / "web" / ("monitor.html" if path else "index.html")).read_bytes()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
elif path in ("/health", "/api/health"):
|
|
self._json(200, {"status": "ok", "max_context": svc.engine.max_context, "model": svc.model,
|
|
"images": svc.vision is not None, "api_key": bool(svc.api_key),
|
|
"loaded": svc.loaded(), "service": "strata"})
|
|
elif path == "/status":
|
|
if not self._authorized(): # #212: it shows the end of the last answer
|
|
return
|
|
with svc.status_lock:
|
|
s = dict(svc.status)
|
|
now = time.time()
|
|
if s.get("busy"):
|
|
s["elapsed_s"] = round(now - s["started"], 1)
|
|
if s.get("first_token"):
|
|
s["tokens_per_s"] = round(svc._tok_s(), 1)
|
|
s["tokens_per_s_mean"] = round(svc._tok_s_mean(), 1)
|
|
for k in ("started", "first_token"):
|
|
s.pop(k, None)
|
|
self._json(200, s)
|
|
elif path in ("/v1/models", "/models"):
|
|
if self._authorized():
|
|
loaded = svc.loaded()
|
|
model = {"id": svc.model, "object": "model", "status": {"value": "loaded"},
|
|
"meta": {"n_ctx": svc.engine.max_context},
|
|
"architecture": {"input_modalities": ["text", "image"] if svc.vision is not None else ["text"],
|
|
"output_modalities": ["text"]}}
|
|
if not loaded and (svc.idle_unload_s or getattr(svc.engine, "unloaded", False)):
|
|
model["status"] = {"value": "unloaded"} # like llama-server's router: listed, loads on use
|
|
loaded = True
|
|
if svc.aliases: # #297: the aliases, and each one listed under its own id
|
|
model["aliases"] = list(svc.aliases)
|
|
data = [model, *({**model, "id": x, "alias_of": svc.model} for x in svc.aliases)]
|
|
self._json(200, {"object": "list", "data": data if loaded else []})
|
|
elif path == "/props":
|
|
if self._authorized():
|
|
self._props()
|
|
elif path == "/slots":
|
|
if self._authorized():
|
|
loaded = not hasattr(svc.engine, "alive") or svc.engine.alive()
|
|
with svc.status_lock:
|
|
busy = bool(svc.status.get("busy"))
|
|
slot = {"id": 0, "n_ctx": svc.engine.max_context, "is_processing": busy}
|
|
self._json(200, [slot] if loaded else [])
|
|
elif path == "/v1/status":
|
|
if self._authorized():
|
|
self._json(200, svc.v1_status())
|
|
else:
|
|
self._json(404, {"error": {"message": "not found"}})
|
|
|
|
def do_POST(self):
|
|
if not self._authorized():
|
|
return
|
|
path = self.path.split("?")[0].rstrip("/") # issue #55: Claude Code posts /v1/messages?beta=true
|
|
if path.startswith("/v1/") and self._foreign_page():
|
|
return
|
|
if path == "/settings":
|
|
self._settings()
|
|
return
|
|
if path == "/config":
|
|
self._config_post()
|
|
return
|
|
if path in ("/unload", "/load") and not self._control_body():
|
|
return
|
|
# JSON from Strata's own page only, as /settings: else a plain form POST from any site unloads the model
|
|
if path in ("/unload", "/load") and not self._own_page("the model can be loaded or unloaded"):
|
|
return
|
|
if path == "/unload": # give the GPU back now (between requests)
|
|
try:
|
|
r = svc.unload()
|
|
except EngineStuck as e:
|
|
self._json(503, {"error": {"type": "server_error", "message": str(e)}})
|
|
return
|
|
self._json(409 if r == "busy" else 200, {"status": r})
|
|
return
|
|
if path == "/load": # load now, e.g. ahead of a request
|
|
try:
|
|
svc.load()
|
|
self._json(200, {"status": "loaded"})
|
|
except GpuBusy as e:
|
|
self._json(503, {"error": {"type": "server_error", "message": str(e)}})
|
|
return
|
|
try:
|
|
req = json.loads(self._body() or b"{}")
|
|
if not isinstance(req, dict):
|
|
raise ValueError("send a JSON object")
|
|
if path.startswith("/v1/responses/"): # retrieve/delete/cancel/compact: nothing is stored
|
|
self._json(404, responses_error_body("this server keeps no responses (stateless); send the "
|
|
"whole conversation to POST /v1/responses", code="not_found"))
|
|
return
|
|
if path in ("/v1/load", "/v1/unload"):
|
|
if not self._own_page("the model can be loaded or unloaded"):
|
|
return
|
|
if req.get("model") not in (None, svc.model):
|
|
self._json(404, {"error": {"message": "model not found"}})
|
|
return
|
|
if path == "/v1/unload":
|
|
result = svc.unload()
|
|
if result == "busy":
|
|
raise ModelBusy("a request is running or queued")
|
|
else:
|
|
if not svc.fifo.acquire(blocking=False):
|
|
raise ModelBusy("a request is running or queued")
|
|
try:
|
|
with svc.status_lock:
|
|
if svc.status.get("busy") or svc.status.get("queued"):
|
|
raise ModelBusy("a request is running or queued")
|
|
svc.ensure_loaded()
|
|
svc.last_request_at = time.time()
|
|
finally:
|
|
svc.fifo.release()
|
|
result = "loaded"
|
|
self._json(200, {"status": result, **svc.v1_status()})
|
|
return
|
|
if path == "/v1/vram": # #533: give VRAM back to other programs, or take it
|
|
if not self._own_page("the VRAM reserve can be changed"):
|
|
return
|
|
r = req.get("reserve_mib")
|
|
if r is not None and (isinstance(r, bool) or not isinstance(r, int) or r < 0):
|
|
raise ValueError("reserve_mib: a whole number of MiB (0 or more), or null for the start's")
|
|
try:
|
|
self._json(200, svc.vram(r))
|
|
except EngineDied as e:
|
|
self._json(503, {"error": {"type": "server_error", "message": str(e)}})
|
|
return
|
|
if path in ("/v1/chat/completions", "/v1/messages", "/v1/responses"):
|
|
self.record = svc.begin_request(path, req)
|
|
if path == "/v1/responses":
|
|
self._responses(req)
|
|
elif path == "/v1/chat/completions":
|
|
self._openai(req)
|
|
elif path == "/v1/messages":
|
|
self._anthropic(req)
|
|
elif path == "/v1/messages/count_tokens":
|
|
self._count_tokens(req)
|
|
else:
|
|
self._json(404, {"error": {"message": "not found"}})
|
|
except ValueError as e:
|
|
print(f"[strata] 400 invalid request: {e}", flush=True)
|
|
if path == "/v1/responses":
|
|
self._json(400, responses_error_body(str(e)))
|
|
else:
|
|
self._json(400, {"error": {"type": "invalid_request_error", "message": str(e)}})
|
|
except ModelBusy as e:
|
|
self._json(409, {"error": {"type": "model_busy", "message": str(e)}})
|
|
except StructuredOutputError as e:
|
|
self._json(502, {"error": {"type": "structured_output_failed", "code": "structured_output_failed",
|
|
"message": str(e)}})
|
|
except (GpuBusy, EngineStarting) as e:
|
|
self._json(503, {"error": {"type": "server_error", "message": str(e)}})
|
|
except EngineDied as e: # before the answer started (not streamed)
|
|
self._json(503, {"error": {"type": "server_error", "message": f"{e}; the next request restarts it"}})
|
|
except EngineStuck as e: # an unload or restart that could not end the engine
|
|
self._json(503, {"error": {"type": "server_error", "message": str(e)}})
|
|
|
|
except OSError:
|
|
self._note(outcome="disconnected")
|
|
raise # as before #332: the server's own handling
|
|
finally:
|
|
if self.watch_done is not None:
|
|
self.watch_done.set()
|
|
record = self.record
|
|
if record is not None:
|
|
with svc.status_lock:
|
|
record["wallclock_s"] = round(time.perf_counter() - record["_clock"], 3)
|
|
record["finished_at"] = time.time()
|
|
record["state"] = "error" if record.get("error") else record.get("outcome", "completed")
|
|
svc.request_trace.record = None
|
|
|
|
def _props(self):
|
|
model = parse_qs(urlsplit(self.path).query).get("model", [svc.model])[0]
|
|
if model not in svc.model_names():
|
|
self._json(404, {"error": {"message": "model not found"}})
|
|
return
|
|
if not svc.loaded() and not getattr(svc.engine, "unloaded", False):
|
|
self._json(503, {"error": {"message": "the engine is not running"}})
|
|
return
|
|
defaults = {**svc.sampling_defaults, **svc.shared}
|
|
names = {"repetition_penalty": "repeat_penalty", "penalty_last_n": "repeat_last_n"}
|
|
params = {names.get(k, k): v for k, v in defaults.items()
|
|
if k in ("temperature", "top_p", "top_k", "min_p", "seed", "repetition_penalty",
|
|
"presence_penalty", "frequency_penalty", "penalty_last_n")}
|
|
params["n_predict"] = svc.shared.get("max_tokens", -1)
|
|
props = {"default_generation_settings": {"n_ctx": svc.engine.max_context, "params": params},
|
|
"total_slots": 1, "model_alias": svc.model, "chat_template": svc.template.source,
|
|
"modalities": {"vision": svc.vision is not None}, "models_autoload": hasattr(svc.engine, "restart"),
|
|
"is_sleeping": not svc.loaded()}
|
|
if getattr(svc.engine, "model_path", None):
|
|
props["model_path"] = svc.engine.model_path
|
|
version = getattr(svc.engine, "info", {}).get("version")
|
|
if version:
|
|
props["build_info"] = "Strata " + str(version)
|
|
self._json(200, props)
|
|
|
|
def _control_body(self) -> bool:
|
|
"""Consume the unused control body before replying/closing (Windows otherwise sends a TCP reset)."""
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
except ValueError:
|
|
self._json(400, {"error": {"message": "invalid Content-Length"}})
|
|
return False
|
|
if not 0 <= length <= 65536:
|
|
self._json(413, {"error": {"message": "control request body is limited to 64 KiB"}})
|
|
return False
|
|
timeout = self.connection.gettimeout()
|
|
try:
|
|
self.connection.settimeout(2.0)
|
|
complete = len(self.rfile.read(length)) == length
|
|
except OSError:
|
|
complete = False
|
|
finally:
|
|
self.connection.settimeout(timeout)
|
|
self.body_read = True # nothing left for the drain to wait for (#594)
|
|
if not complete:
|
|
self._json(400, {"error": {"message": "incomplete control request body"}})
|
|
return complete
|
|
|
|
def _own_page(self, what) -> bool:
|
|
"""Only JSON (a form or a "simple" cross-site request can't send it without a CORS preflight, which this
|
|
server never grants) and no foreign Origin: a web page elsewhere must not change settings or run tools."""
|
|
if not self.headers.get("Content-Type", "").startswith("application/json"):
|
|
self._json(415, {"error": {"message": "send application/json"}})
|
|
return False
|
|
if self._foreign_origin():
|
|
self._json(403, {"error": {"message": f"{what} only from Strata's own page (or an origin in the "
|
|
f"config's trusted_origins)"}})
|
|
return False
|
|
return True
|
|
|
|
def _config_get(self):
|
|
if not svc.config_path:
|
|
self._json(404, {"error": {"message": "this server was started without a run config"}})
|
|
return
|
|
try:
|
|
cfg = runconfig.load(svc.config_path)
|
|
except (OSError, ValueError) as e:
|
|
self._json(500, {"error": {"type": "server_error", "message": f"the run config cannot be read: {e}"}})
|
|
return
|
|
self._json(200, runconfig.view(cfg, svc.config_path))
|
|
|
|
def _config_post(self):
|
|
"""#564: change a few documented keys of the run config - JSON from Strata's own page only, as
|
|
/settings (the key is checked before); every other key of the file stays as it is."""
|
|
body = self._body()
|
|
if not self._own_page("the run config can be changed"):
|
|
return
|
|
if not svc.config_path:
|
|
self._json(404, {"error": {"message": "this server was started without a run config"}})
|
|
return
|
|
try:
|
|
req = json.loads(body or b"{}")
|
|
with svc.config_lock:
|
|
cfg = runconfig.load(svc.config_path)
|
|
new, changed = runconfig.apply(cfg, req.get("set") if isinstance(req, dict) else None)
|
|
if changed:
|
|
bak = runconfig.save(svc.config_path, new)
|
|
except ValueError as e:
|
|
self._json(400, {"error": {"type": "invalid_request_error", "message": str(e)}})
|
|
return
|
|
except OSError as e:
|
|
self._json(500, {"error": {"type": "server_error", "message": f"the run config cannot be written: {e}"}})
|
|
return
|
|
if changed:
|
|
print(f"[strata] the Settings view changed {', '.join(changed)} in {Path(svc.config_path).name} "
|
|
f"(the earlier file: {bak.name}); used from the next start", flush=True)
|
|
self._json(200, {**runconfig.view(new, svc.config_path), "changed": changed})
|
|
def _foreign_origin(self) -> bool:
|
|
"""A web page of another origin sent this. The Origin must be this server's own address (host and port),
|
|
or an origin the config trusts (trusted_origins: the web app behind a reverse proxy or tunnel); no Origin
|
|
is a client that is no browser. Headers a proxy adds (X-Forwarded-*, CF-Ray, CF-Connecting-IP) prove
|
|
nothing about the page that sent the request, so they open nothing."""
|
|
origin = (self.headers.get("Origin") or "").rstrip("/")
|
|
return bool(origin) and origin.split("://", 1)[-1] != self.headers.get("Host", "") and \
|
|
origin not in svc.trusted_origins
|
|
|
|
def _no_local_images(self, messages):
|
|
"""A web page of another origin must not have a file on this computer read as an image. Without an
|
|
api_key, _foreign_page refuses such a page unless cors_origins lets it in - and a page cors_origins lets
|
|
in (["*"]: every page) could name any file and, CORS allowing, read what the model says about it. data:
|
|
and http(s) images are the page's own to send; files come from Strata's own page, a trusted origin or a
|
|
client that is no browser. ValueError (a 400)."""
|
|
if self._foreign_origin() and any(not src.startswith(("data:", "http://", "https://"))
|
|
for src in images_of(messages)):
|
|
raise ValueError("a web page of another origin cannot have a file on this computer read as an "
|
|
"image: send it as a data: URL (or add the page's origin to the config's "
|
|
"trusted_origins)")
|
|
|
|
def _settings(self):
|
|
# They change what every client gets, so only the app's own page may set them
|
|
body = self._body()
|
|
if not self._own_page("settings can be changed"):
|
|
return
|
|
try:
|
|
req = json.loads(body or b"{}")
|
|
shared = svc.set_shared(req.get("defaults") if isinstance(req, dict) else None)
|
|
except ValueError as e:
|
|
self._json(400, {"error": {"type": "invalid_request_error", "message": str(e)}})
|
|
return
|
|
print("[strata] other apps now use the Chat settings: " + ", ".join(f"{k}={v}" for k, v in shared.items())
|
|
if shared else "[strata] other apps use their own settings again", flush=True)
|
|
self._json(200, {"shared": bool(shared), "defaults": shared})
|
|
|
|
def _sse(self):
|
|
self._note(http_status=200)
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.send_header("X-Accel-Buffering", "no") # nginx and similar proxies pass each event at once
|
|
self._cors()
|
|
self.end_headers()
|
|
|
|
def _capture(self, items, api):
|
|
"""Retain bounded input/output for the monitor without changing the API response (the items as they
|
|
are when the monitor is off)."""
|
|
if self.record is None:
|
|
return items
|
|
return self._captured(items, api)
|
|
|
|
def _captured(self, items, api):
|
|
try:
|
|
for item in items:
|
|
if item is not None:
|
|
if api == "openai":
|
|
delta = item["choices"][0]["delta"]
|
|
content, reasoning = delta.get("content", ""), delta.get("reasoning_content", "")
|
|
usage, timings = item.get("usage"), item.get("timings")
|
|
elif api == "responses":
|
|
kind = item["type"]
|
|
content = item["delta"] if kind == "response.output_text.delta" else ""
|
|
reasoning = item["delta"] if kind == "response.reasoning_text.delta" else ""
|
|
usage, timings = (item.get("response") or {}).get("usage"), None
|
|
else:
|
|
_, event = item
|
|
delta = event.get("delta", {})
|
|
content, reasoning = delta.get("text", ""), delta.get("thinking", "")
|
|
usage, timings = event.get("usage"), None
|
|
with svc.status_lock:
|
|
for name, value in (("output", content), ("reasoning", reasoning)):
|
|
if value:
|
|
combined = self.record[name] + value
|
|
self.record[name] = combined[:262144]
|
|
self.record[name + "_truncated"] |= len(combined) > 262144
|
|
if usage:
|
|
self.record["usage"] = usage
|
|
if timings:
|
|
self.record["timings"] = timings
|
|
yield item
|
|
finally:
|
|
items.close()
|
|
|
|
def _openai(self, req):
|
|
req = svc.with_shared(req, "openai")
|
|
messages, tools, kw = openai_to_messages(req)
|
|
self._no_local_images(messages)
|
|
messages, validator = prepare_format(req.get("response_format"), messages)
|
|
if validator is not None and (tools or req.get("strata_mcp")):
|
|
raise ValueError("structured response_format with tools/MCP is not supported")
|
|
svc.load()
|
|
max_req = max_new = int(req.get("max_completion_tokens") or req.get("max_tokens") or 0) # 0/-1: the rest
|
|
use_mcp = req.get("strata_mcp") is True and svc.mcp is not None # the web app's opt-in (serve/mcp.py)
|
|
own = {t.get("name") for t in tools or [] if isinstance(t, dict)} # #592: a second line of defence
|
|
if use_mcp:
|
|
if not self._own_page("MCP tools can be used"): # tools run with the user's rights on this PC
|
|
return
|
|
svc.mcp.wait(10) # servers still starting (only right after start)
|
|
extra = svc.mcp.template_tools(exclude=own) # the request's own tools win a name clash
|
|
use_mcp = bool(extra)
|
|
tools = (tools or []) + extra or None
|
|
svc.reasoning_budget(req) # a bad value is a 400 before anything is sent
|
|
stop_strings(req) # ... and so is a bad stop / stop_sequences
|
|
ids, thinking, max_new = svc.prepare(messages, tools, kw, max_new)
|
|
_debug_req("openai", req, messages, tools, max_new, thinking, len(ids))
|
|
cancel = threading.Event()
|
|
self._watch_client(cancel) # #430 #431
|
|
run = run_with_mcp(svc, svc.mcp, messages, tools, kw, ids, thinking, max_new, max_req, req, cancel,
|
|
{t["name"] for t in extra}) if use_mcp else None
|
|
chunks = openai_chunks(svc, req, ids, thinking, tools, max_new, cancel, run=run)
|
|
if validator is not None:
|
|
chunks = structured_chunks(chunks, validator)
|
|
chunks = self._capture(chunks, "openai")
|
|
if not req.get("stream"):
|
|
return self._json(200, openai_collect(chunks))
|
|
self._sse()
|
|
try:
|
|
for c in chunks:
|
|
if c is None:
|
|
self.wfile.write(b": keep-alive\n\n") # an SSE comment: clients ignore it
|
|
else:
|
|
self.wfile.write(b"data: " + json.dumps(c, ensure_ascii=False).encode() + b"\n\n")
|
|
self.wfile.flush()
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
except OSError:
|
|
self._note(outcome="disconnected")
|
|
cancel.set() # client went away: stop the engine
|
|
chunks.close()
|
|
except EngineDied as e: # mid-stream: say so, then end the stream properly
|
|
err = {"error": {"type": "server_error", "message": f"{e}; the next request restarts it"}}
|
|
self._note(error=err["error"])
|
|
self.wfile.write(b"data: " + json.dumps(err).encode() + b"\n\ndata: [DONE]\n\n")
|
|
except StructuredOutputError as e:
|
|
err = {"error": {"type": "structured_output_failed", "code": "structured_output_failed", "message": str(e)}}
|
|
self._note(error=err["error"])
|
|
self.wfile.write(b"data: " + json.dumps(err).encode() + b"\n\ndata: [DONE]\n\n")
|
|
except ValueError as e: # the engine's ERR after the stream started: the
|
|
err = {"error": {"type": "server_error", "message": str(e)}} # headers are sent, so no 400 now
|
|
self._note(error=err["error"])
|
|
self.wfile.write(b"data: " + json.dumps(err).encode() + b"\n\ndata: [DONE]\n\n")
|
|
|
|
def _responses(self, req):
|
|
"""#451: POST /v1/responses - OpenAI's Responses API, stateless (serve/responses.py), on the chat path.
|
|
Errors use the Responses format; once the stream has started they arrive as a response.failed event."""
|
|
try:
|
|
ids, thinking, tools, max_new, asm, validator, req = self._responses_prepare(req)
|
|
except ResponsesError as e:
|
|
return self._json(e.status, e.body())
|
|
except ModelBusy as e:
|
|
return self._json(409, responses_error_body(str(e), "server_error", code="model_busy"))
|
|
except (GpuBusy, EngineStarting, EngineStuck, EngineDied) as e:
|
|
return self._json(503, responses_error_body(str(e), "server_error", code="server_error"))
|
|
cancel = threading.Event()
|
|
self._watch_client(cancel) # #430 #431
|
|
|
|
def check(text, finish):
|
|
return validated_json(text, validator, finish)
|
|
|
|
def events():
|
|
yield from asm.start()
|
|
done = None
|
|
for kind, x in svc.run(ids, thinking, tools, max_new, req, cancel):
|
|
if kind == "ping":
|
|
yield None
|
|
elif kind == "event":
|
|
yield from asm.feed(x)
|
|
elif kind == "done":
|
|
done = x
|
|
if done is not None and done["finish"] != "cancel" and not cancel.is_set():
|
|
yield from asm.finish(done, check)
|
|
items = self._capture(events(), "responses")
|
|
if not req.get("stream"):
|
|
try:
|
|
result = responses_api.collect(items)
|
|
except StructuredOutputError as e:
|
|
return self._json(502, responses_error_body(str(e), "server_error",
|
|
code="structured_output_failed"))
|
|
except EngineDied as e:
|
|
return self._json(503, responses_error_body(f"{e}; the next request restarts it", "server_error",
|
|
code="server_error"))
|
|
except ValueError as e: # the engine's ERR line
|
|
return self._json(500, responses_error_body(str(e), "server_error", code="server_error"))
|
|
return self._json(200, result)
|
|
self._sse()
|
|
last = time.monotonic()
|
|
|
|
def send(e):
|
|
self.wfile.write(f"event: {e['type']}\n".encode() + b"data: " +
|
|
json.dumps(e, ensure_ascii=False).encode() + b"\n\n")
|
|
|
|
try:
|
|
for e in items:
|
|
if e is None:
|
|
# Codex's idle timeout (5 minutes by default) counts events, not SSE comments: while a long
|
|
# prompt is read, a response.in_progress every 15 s tells it the server is still working
|
|
if time.monotonic() - last >= 15:
|
|
send(asm.in_progress())
|
|
last = time.monotonic()
|
|
else:
|
|
self.wfile.write(b": keep-alive\n\n")
|
|
else:
|
|
send(e)
|
|
last = time.monotonic()
|
|
self.wfile.flush()
|
|
except OSError:
|
|
self._note(outcome="disconnected")
|
|
cancel.set() # client went away: stop the engine
|
|
items.close()
|
|
except EngineDied as e: # mid-stream: response.failed, then the stream ends
|
|
self._responses_failed(asm, f"{e}; the next request restarts it", "server_error", send)
|
|
except StructuredOutputError as e:
|
|
self._responses_failed(asm, str(e), "structured_output_failed", send)
|
|
except ValueError as e: # the engine's ERR after the stream started
|
|
self._responses_failed(asm, str(e), "server_error", send)
|
|
|
|
def _responses_failed(self, asm, message, code, send):
|
|
e = asm.failed(message, code)
|
|
self._note(error=e["response"]["error"])
|
|
try:
|
|
send(e)
|
|
self.wfile.flush()
|
|
except OSError:
|
|
pass
|
|
|
|
def _responses_prepare(self, req):
|
|
responses_api.check_request(req)
|
|
messages = responses_api.input_messages(req)
|
|
tools, names, skipped = responses_api.request_tools(req)
|
|
kw = responses_api.template_kwargs(req, svc.shared)
|
|
try:
|
|
self._no_local_images(messages) # as on the chat route: no file read for a foreign page
|
|
except ValueError as e:
|
|
raise ResponsesError(str(e), "input") from None
|
|
try:
|
|
messages, validator = prepare_format(responses_api.text_format(req), messages)
|
|
except ValueError as e:
|
|
raise ResponsesError(str(e).replace("response_format", "text.format"), "text.format") from None
|
|
if validator is not None and tools:
|
|
raise ResponsesError("a JSON text.format with tools is not supported", "text.format",
|
|
"unsupported_parameter")
|
|
noted = svc.__dict__.setdefault("hosted_tools_noted", set()) # said once per tool, not per request
|
|
if set(skipped) - noted:
|
|
print(f"[strata] /v1/responses: left out the hosted tools {', '.join(sorted(set(skipped) - noted))} "
|
|
f"(this server cannot run them)", flush=True)
|
|
noted.update(skipped)
|
|
if req.get("max_output_tokens") is None and "max_tokens" in svc.shared: # the shared Chat settings
|
|
req = {**req, "max_output_tokens": svc.shared["max_tokens"]}
|
|
try:
|
|
svc.reasoning_budget(req) # a bad value is a 400 before anything is sent
|
|
except ValueError as e:
|
|
raise ResponsesError(str(e), "reasoning_budget_tokens") from None
|
|
try:
|
|
stop_strings(req)
|
|
except ValueError as e:
|
|
raise ResponsesError(str(e), "stop") from None
|
|
svc.load()
|
|
try:
|
|
ids, thinking, max_new = svc.prepare(messages, tools, kw, req.get("max_output_tokens") or 0)
|
|
except ResponsesError:
|
|
raise
|
|
except ValueError as e: # too long for the context, an image without vision
|
|
raise ResponsesError(str(e), "input", "context_length_exceeded" if "context" in str(e) else None) \
|
|
from None
|
|
_debug_req("responses", req, messages, tools, max_new, thinking, len(ids))
|
|
include = req.get("include") if isinstance(req.get("include"), list) else []
|
|
asm = responses_api.Assembler(req, svc.model_for(req), len(ids), names,
|
|
"reasoning.encrypted_content" in include, validator is not None)
|
|
# the request is also the sampling dict, as on the chat path (temperature, top_p, top_k, seed, ...)
|
|
return ids, thinking, tools, max_new, asm, validator, req
|
|
|
|
def _count_tokens(self, req):
|
|
"""Anthropic's token count, which Claude Code asks for its context figures: the prompt this server would
|
|
read for the same request, rendered and tokenized - the model does not run."""
|
|
req = svc.with_shared(req, "anthropic")
|
|
messages, tools, kw = anthropic_to_messages(req, svc.anthropic_think_unasked)
|
|
stop_strings(req) # the same 400 as the request itself would get
|
|
self._json(200, {"input_tokens": len(svc.encode_prompt(messages, tools, kw))})
|
|
|
|
def _anthropic(self, req):
|
|
svc.load()
|
|
req = svc.with_shared(req, "anthropic")
|
|
messages, tools, kw = anthropic_to_messages(req, svc.anthropic_think_unasked)
|
|
self._no_local_images(messages)
|
|
max_new = int(req.get("max_tokens") or 0) # 0/-1: the rest of the context
|
|
svc.reasoning_budget(req) # a bad value is a 400 before anything is sent
|
|
stop_strings(req) # ... and so is a bad stop / stop_sequences
|
|
ids, thinking, max_new = svc.prepare(messages, tools, kw, max_new)
|
|
_debug_req("anthropic", req, messages, tools, max_new, thinking, len(ids))
|
|
cancel = threading.Event()
|
|
self._watch_client(cancel) # #430 #431
|
|
events = anthropic_events(svc, req, ids, thinking, tools, max_new, cancel)
|
|
events = self._capture(events, "anthropic")
|
|
if not req.get("stream"):
|
|
return self._json(200, anthropic_collect(events))
|
|
self._sse()
|
|
try:
|
|
for item in events:
|
|
if item is None:
|
|
self.wfile.write(b": keep-alive\n\n")
|
|
else:
|
|
name, e = item
|
|
self.wfile.write(f"event: {name}\n".encode() + b"data: " +
|
|
json.dumps(e, ensure_ascii=False).encode() + b"\n\n")
|
|
self.wfile.flush()
|
|
except OSError:
|
|
self._note(outcome="disconnected")
|
|
cancel.set()
|
|
events.close()
|
|
except EngineDied as e: # mid-stream: Anthropic's error event
|
|
err = {"type": "error", "error": {"type": "api_error", "message": f"{e}; the next request restarts it"}}
|
|
self._note(error=err["error"])
|
|
self.wfile.write(b"event: error\ndata: " + json.dumps(err).encode() + b"\n\n")
|
|
except ValueError as e: # the engine's ERR after the stream started
|
|
err = {"type": "error", "error": {"type": "api_error", "message": str(e)}}
|
|
self._note(error=err["error"])
|
|
self.wfile.write(b"event: error\ndata: " + json.dumps(err).encode() + b"\n\n")
|
|
|
|
return Handler
|
|
|
|
|
|
class Server(ThreadingHTTPServer):
|
|
# On Windows SO_REUSEADDR lets a second server bind a port that is already serving, and requests then land on
|
|
# either one (a forgotten second start of run-<model>.bat). Without it the second start fails loudly instead.
|
|
allow_reuse_address = os.name != "nt"
|
|
|
|
def handle_error(self, request, client_address):
|
|
if not isinstance(sys.exc_info()[1], ConnectionError): # a client that hangs up needs no stack trace
|
|
super().handle_error(request, client_address)
|
|
|
|
|
|
def warn_tight_ram(arena_mib) -> None:
|
|
"""The model's experts live in RAM (INFO arena_mib, engine 0.1.10+). With less than ~6 GB left beside them for the
|
|
system, the engine and this server, Linux ends the engine mid-answer when memory runs out (issue #27) and Windows
|
|
pages to disk; say so at start instead of after a lost answer."""
|
|
if not isinstance(arena_mib, int) or arena_mib <= 0:
|
|
return
|
|
try:
|
|
import psutil
|
|
total = psutil.virtual_memory().total
|
|
except Exception: # noqa: BLE001 - psutil is optional here
|
|
return
|
|
left = total / 2**30 - arena_mib / 1024
|
|
if left < 6:
|
|
print(f"[strata] WARNING: RAM is tight - the model's experts take {arena_mib / 1024:.1f} GB of this PC's "
|
|
f"{total / 2**30:.0f} GB, leaving {left:.1f} GB for everything else. "
|
|
+ ("Linux may stop the engine in the middle of an answer. " if os.name != "nt" else
|
|
"Windows will slow down (paging to disk). ")
|
|
+ "Close other programs, or run START-HERE --setup and pick a smaller size (Q2_0 / IQ2_XS).", flush=True)
|
|
|
|
|
|
DESKTOP_FREE_MIB = 2048 # #560 #516: below this, an AMD card that also drives a Linux desktop can run out
|
|
DESKTOP_RESERVE_MIB = 3072 # what kept KDE/Wayland alive beside a full expert cache in both reports
|
|
|
|
|
|
def linux_desktop(env=None) -> bool:
|
|
"""A graphical session on Linux (Wayland or X): its compositor, browser and apps take VRAM after the model has."""
|
|
env = os.environ if env is None else env
|
|
return os.name != "nt" and sys.platform != "darwin" and bool(env.get("WAYLAND_DISPLAY") or env.get("DISPLAY"))
|
|
|
|
|
|
def desktop_vram_note(backend, vram_free_mib, args: list, desktop: bool) -> str:
|
|
"""#560 #516: on Linux, when the desktop needs VRAM the AMD card does not have, amdgpu moves GPU memory (the expert
|
|
cache, ~24 GB) to system RAM, which the experts already fill - the OOM killer then ends the compositor. The
|
|
default reserve (700 MiB) is sized for a card without a desktop. A recommendation, nothing changes: "" when it
|
|
does not apply."""
|
|
if backend != "hip" or not desktop or not isinstance(vram_free_mib, int) or vram_free_mib >= DESKTOP_FREE_MIB:
|
|
return ""
|
|
try:
|
|
reserve = int(args[args.index("--vram-reserve-mib") + 1]) if "--vram-reserve-mib" in args else 700
|
|
except (ValueError, IndexError):
|
|
reserve = 700
|
|
if reserve >= DESKTOP_RESERVE_MIB:
|
|
return ""
|
|
return (f"[strata] note: {vram_free_mib} MiB of VRAM free with the model loaded. If this AMD card also drives your "
|
|
"desktop and the desktop or apps crash after the start (the driver moves the expert cache to RAM and "
|
|
"the OOM killer ends the session), keep more VRAM free: ./setup.sh --vram-reserve-mib "
|
|
f"{DESKTOP_RESERVE_MIB} (remembered; the expert cache gets "
|
|
f"{(DESKTOP_RESERVE_MIB - reserve) / 1024:.1f} GB less, a few % of speed)")
|
|
|
|
|
|
def lan_addresses() -> list[str]:
|
|
"""This PC's IPv4 addresses on its networks (what another device types in), without loopback/link-local."""
|
|
import socket
|
|
first, ips = None, set()
|
|
try: # the address of the default route; sends nothing (UDP)
|
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
s.connect(("10.255.255.255", 1))
|
|
first = s.getsockname()[0]
|
|
except OSError:
|
|
pass
|
|
try:
|
|
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
|
ips.add(info[4][0])
|
|
except OSError:
|
|
pass
|
|
ok = lambda ip: ip and not ip.startswith(("127.", "169.254.", "0."))
|
|
return ([first] if ok(first) else []) + sorted(ip for ip in ips if ok(ip) and ip != first)
|
|
|
|
|
|
def host_name(value) -> str:
|
|
"""The name in a Host header (or an origin's host[:port]): "Example.com:8080" -> "example.com",
|
|
"[::1]:8095" -> "::1"; "" when it is malformed."""
|
|
v = (value or "").strip().lower()
|
|
if v.startswith("["):
|
|
name, sep, rest = v[1:].partition("]")
|
|
return name if sep and (not rest or (rest[:1] == ":" and rest[1:].isdigit())) else ""
|
|
if v.count(":") == 1:
|
|
v, port = v.split(":")
|
|
if not port.isdigit():
|
|
return ""
|
|
elif ":" in v: # a bare IPv6 address (a config entry)
|
|
return v
|
|
v = v.rstrip(".")
|
|
return v if v and all(c.isalnum() or c in "-._" for c in v) else ""
|
|
|
|
|
|
def _is_ip(name: str) -> bool:
|
|
import ipaddress
|
|
try:
|
|
ipaddress.ip_address(name)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def _name_in(name: str, names) -> bool:
|
|
"""`name` is one of `names`, or below an entry that starts with a dot (".example.com")."""
|
|
return name in names or any(n.startswith(".") and (name.endswith(n) or name == n[1:]) for n in names)
|
|
|
|
|
|
def allowed_hosts_of(value, env: str = "") -> list[str]:
|
|
"""The config's allowed_hosts (a name or a list) plus $STRATA_ALLOWED_HOSTS (comma-separated): host names, "*" or
|
|
".example.com" (it and every name below it). A scheme, port or path is dropped ("https://a.example.com:8443/"
|
|
-> "a.example.com"); a wrong entry stops the start (ValueError)."""
|
|
items = [] if value in (None, "") else [value] if isinstance(value, str) else value
|
|
if not isinstance(items, list) or not all(isinstance(x, str) for x in items):
|
|
raise ValueError("allowed_hosts: expected a host name or a list of them")
|
|
out = []
|
|
for raw in items + [x for x in env.split(",") if x.strip()]:
|
|
x = raw.strip().lower()
|
|
if x == "*":
|
|
out.append(x)
|
|
continue
|
|
x = x.split("://", 1)[-1].split("/", 1)[0]
|
|
dot = x.startswith(".")
|
|
name = host_name(x[1:] if dot else x)
|
|
if not name:
|
|
raise ValueError(f"allowed_hosts: {raw!r} is not a host name like strata.example.com")
|
|
out.append("." + name if dot else name)
|
|
return list(dict.fromkeys(out))
|
|
|
|
|
|
def host_names_for(bind_host: str, allowed_hosts=(), trusted_origins=()) -> set[str]:
|
|
"""Every name this server answers to besides an IP address: localhost, the address it listens on, the config's
|
|
allowed_hosts and trusted_origins' hosts, and - listening beyond this PC (0.0.0.0 or a LAN address) - this PC's
|
|
name and LAN addresses (the LAN addresses matter for the Origin check, which takes no IP on trust)."""
|
|
names = set(LOOPBACK_NAMES)
|
|
bind = host_name(bind_host)
|
|
if bind and bind not in ("0.0.0.0", "::"):
|
|
names.add(bind)
|
|
if bind not in LOOPBACK_NAMES:
|
|
try:
|
|
pc = socket.gethostname().lower()
|
|
names.update((pc, pc + ".local"))
|
|
except OSError:
|
|
pass
|
|
names.update(("host.docker.internal", *lan_addresses()))
|
|
names.update(x for x in allowed_hosts if x != "*")
|
|
names.update(filter(None, (host_name(o.split("://", 1)[-1]) for o in trusted_origins)))
|
|
return names
|
|
|
|
|
|
def host_allowed(host, names, any_host=False) -> bool:
|
|
"""DNS rebinding: a web page of another site whose name its DNS points at 127.0.0.1 reaches this server as the
|
|
same origin, so the browser lets it read every answer. Its requests carry that site's name in Host, so only
|
|
the names this server answers to pass. An IP address passes (a page served from an IP is that IP's own page;
|
|
rebinding needs a name), as does "*.localhost" (browsers never ask DNS for it) and a request without a Host
|
|
header (HTTP/1.0 clients; browsers always send one)."""
|
|
if any_host or not (host or "").strip():
|
|
return True
|
|
name = host_name(host)
|
|
return bool(name) and (_is_ip(name) or name == "localhost" or name.endswith(".localhost")
|
|
or _name_in(name, names))
|
|
|
|
|
|
def origin_allowed(origin: str, host, names, origins=()) -> bool:
|
|
"""A browser page's Origin that may use the model without an API key: this server's own page (the Origin is the
|
|
request's own Host), a page on one of the names this server answers to (any port), or an origin the config lists
|
|
(trusted_origins, cors_origins). Unlike the Host check no IP passes on trust: a page served from any other IP is
|
|
another site. "null" (a sandboxed frame, a file:// page) does not pass: any site can send it. An origin of another
|
|
scheme (chrome-extension://, moz-extension://, an Electron app's app://) does: no web site can send one."""
|
|
origin = (origin or "").strip().rstrip("/")
|
|
if origin in origins or "*" in origins: # cors_origins ["*"]: the config lets every page in
|
|
return True
|
|
scheme, sep, rest = origin.lower().partition("://")
|
|
if not sep or not scheme:
|
|
return False
|
|
if scheme not in ("http", "https"):
|
|
return True
|
|
if host and rest == host.strip().lower():
|
|
return True
|
|
name = host_name(rest)
|
|
return bool(name) and (name in LOOPBACK_NAMES or name.endswith(".localhost") or _name_in(name, names))
|
|
|
|
|
|
def serve(svc: Service, host="127.0.0.1", port=8095) -> ThreadingHTTPServer:
|
|
svc.host_names = host_names_for(host, svc.allowed_hosts, svc.trusted_origins)
|
|
svc.start_telemetry()
|
|
httpd = Server((host, port), make_handler(svc))
|
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
return httpd
|
|
|
|
|
|
def origins_of(value, key: str, wildcard: bool) -> list[str]:
|
|
"""#321: a config's origin list ("https://chat.example.com" or a list of them) - scheme://host[:port], no path;
|
|
"*" only where `wildcard` allows it. A wrong entry stops the start rather than opening less or more than meant."""
|
|
if value is None or value == "" or value == []:
|
|
return []
|
|
items = [value] if isinstance(value, str) else value
|
|
if not isinstance(items, list) or not all(isinstance(x, str) for x in items):
|
|
raise SystemExit(f"[strata] {key}: expected an origin or a list of origins")
|
|
out = []
|
|
for x in items:
|
|
x = x.strip().rstrip("/")
|
|
if x == "*" and wildcard:
|
|
out.append(x)
|
|
continue
|
|
scheme, sep, rest = x.partition("://")
|
|
if scheme not in ("http", "https") or not sep or not rest or "/" in rest or "*" in rest:
|
|
raise SystemExit(f"[strata] {key}: {x!r} is not an origin like https://chat.example.com"
|
|
+ ("" if wildcard else " (no wildcards here)"))
|
|
out.append(x)
|
|
return out
|
|
|
|
|
|
SHARED_KEYS = ("reasoning_effort", "temperature", "top_p", "top_k", "seed", "max_tokens", "experimental_speed_projection")
|
|
|
|
|
|
def clean_shared_defaults(d) -> dict:
|
|
"""The Chat settings other apps get (POST /settings): only known keys, each checked; ValueError names a bad one."""
|
|
if d is None:
|
|
return {}
|
|
if not isinstance(d, dict):
|
|
raise ValueError("defaults must be an object")
|
|
out = {}
|
|
for key, value in d.items():
|
|
if value is None or value == "":
|
|
continue
|
|
number = isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
if key == "reasoning_effort":
|
|
if value not in ("none", "low", "medium", "high"):
|
|
raise ValueError("reasoning_effort: none, low, medium or high")
|
|
elif key == "temperature":
|
|
if not number or not 0 <= value <= 2:
|
|
raise ValueError("temperature: 0..2")
|
|
elif key == "top_p":
|
|
if not number or not 0 < value <= 1:
|
|
raise ValueError("top_p: 0 < top_p <= 1")
|
|
elif key == "top_k":
|
|
if not number or value != int(value) or not 1 <= value <= 64:
|
|
raise ValueError("top_k: an integer 1..64")
|
|
value = int(value)
|
|
elif key in ("seed", "max_tokens"):
|
|
if not number or value != int(value) or value <= 0:
|
|
raise ValueError(f"{key}: a positive integer")
|
|
value = int(value)
|
|
elif key == "experimental_speed_projection":
|
|
if not isinstance(value, bool):
|
|
raise ValueError("experimental_speed_projection: true or false")
|
|
else:
|
|
raise ValueError(f"unknown setting {key!r}")
|
|
out[key] = float(value) if key in ("temperature", "top_p") else value
|
|
return out
|
|
|
|
|
|
def sampling_defaults_from_config(cfg: dict) -> dict:
|
|
"""The run config's optional `sampling` block: defaults for the sampling fields a request leaves out, so
|
|
a plain client gets configured sampling instead of greedy. Supported: temperature, top_p, top_k, min_p,
|
|
presence_penalty, repetition_penalty, frequency_penalty, penalty_last_n, seed. The request's own fields
|
|
always win - an explicit temperature=0 still means greedy, a field set to null falls back to the default.
|
|
A bad value refuses to start the server (a typo'd config should not quietly change sampling); unknown keys
|
|
are named at startup and ignored."""
|
|
out = {}
|
|
for key, value in (cfg.get("sampling") or {}).items():
|
|
if value is None:
|
|
continue
|
|
number = isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
if key == "temperature":
|
|
if not number or value < 0:
|
|
raise SystemExit(f"[strata] config sampling.temperature={value!r}: expected a number >= 0 (0 = greedy)")
|
|
out[key] = float(value)
|
|
elif key == "top_p":
|
|
if not number or not 0 < value <= 1:
|
|
raise SystemExit(f"[strata] config sampling.top_p={value!r}: expected 0 < top_p <= 1")
|
|
out[key] = float(value)
|
|
elif key == "min_p":
|
|
if not number or not 0 <= value <= 1:
|
|
raise SystemExit(f"[strata] config sampling.min_p={value!r}: expected 0 <= min_p <= 1")
|
|
out[key] = float(value)
|
|
elif key == "top_k":
|
|
if not number or value != int(value) or not 1 <= value <= 64:
|
|
raise SystemExit(f"[strata] config sampling.top_k={value!r}: the sampled path takes an integer 1..64")
|
|
out[key] = int(value)
|
|
elif key == "presence_penalty":
|
|
if not number or value < 0:
|
|
raise SystemExit(f"[strata] config sampling.presence_penalty={value!r}: expected a number >= 0")
|
|
out[key] = float(value)
|
|
elif key == "frequency_penalty":
|
|
if not number or value < 0:
|
|
raise SystemExit(f"[strata] config sampling.frequency_penalty={value!r}: expected a number >= 0")
|
|
out[key] = float(value)
|
|
elif key == "repetition_penalty":
|
|
if not number or value <= 0:
|
|
raise SystemExit(f"[strata] config sampling.repetition_penalty={value!r}: expected a number > 0 (1 = off)")
|
|
out[key] = float(value)
|
|
elif key == "penalty_last_n":
|
|
if not number or value != int(value) or value < 0:
|
|
raise SystemExit(f"[strata] config sampling.penalty_last_n={value!r}: expected a non-negative integer")
|
|
out[key] = int(value)
|
|
elif key == "seed":
|
|
if not number or value != int(value) or value <= 0:
|
|
raise SystemExit(f"[strata] config sampling.seed={value!r}: expected a positive integer")
|
|
out[key] = int(value)
|
|
elif key == "experimental_speed_projection":
|
|
if not isinstance(value, bool):
|
|
raise SystemExit(f"[strata] config sampling.experimental_speed_projection={value!r}: expected true or "
|
|
"false (the default for requests that leave it out, when the engine has the vector)")
|
|
out[key] = value
|
|
else:
|
|
print(f"[strata] config sampling.{key}={value!r}: unknown key, ignored", flush=True)
|
|
return out
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--engine", choices=["mock", "strata"], default="mock")
|
|
ap.add_argument("--config", help="strata engine config (JSON: exe, args, cwd, tokenizer, model_name), "
|
|
"written by setup.py")
|
|
ap.add_argument("--host", default=None,
|
|
help="the address to listen on: 127.0.0.1 = this PC only (the default), 0.0.0.0 = also other devices "
|
|
"on your network (set an API key); also \"host\" in the config")
|
|
ap.add_argument("--script", action="append",
|
|
help="the mock engine's answer (default: a short greeting); given more than once, requests get "
|
|
"them in turn and the last one repeats")
|
|
ap.add_argument("--port", type=int, default=8095)
|
|
ap.add_argument("--gpu", help="the GPU to run on, as nvidia-smi numbers them, or several for a layer split "
|
|
"(\"0,2\"; also \"gpu\" in the config)")
|
|
ap.add_argument("--tokenizer", default=str(ROOT / "pack/full/tokenizer"),
|
|
help="pack tokenizer directory (falls back to a byte tokenizer if absent)")
|
|
ap.add_argument("--open", action="store_true", help="open the local page in the browser once the model is ready")
|
|
ap.add_argument("--fit-max-tokens", action="store_true",
|
|
help="clamp max_tokens to the remaining context instead of rejecting the request "
|
|
"(default: reject with 400, like llama.cpp; also \"fit_max_tokens\": true in the config)")
|
|
ap.add_argument("--api-key", default=os.environ.get("STRATA_API_KEY", ""),
|
|
help="require this key on /v1/* (Authorization: Bearer ... or x-api-key); also $STRATA_API_KEY")
|
|
ap.add_argument("--mcp-config", help="a JSON file with MCP servers in Claude Desktop's format ({\"mcpServers\": "
|
|
"{...}}); the web app's chat can use their tools (also \"mcp_servers\" in "
|
|
"the config)")
|
|
ap.add_argument("--lazy", action="store_true", help="start the text-only API unloaded; load on first request")
|
|
ap.add_argument("--api-monitor", action="store_true",
|
|
help="the API request monitor at /api-monitor: keeps the last 100 requests' prompts and answers in "
|
|
"memory (also \"api_monitor\": true in the config; off by default)")
|
|
ap.add_argument("--idle-unload", type=float, default=None, metavar="SECONDS",
|
|
help="unload the model after this many seconds without requests, so other programs (games, other "
|
|
"model servers) can use the VRAM; the next request loads it again (also \"idle_unload_s\" "
|
|
"in the config; default: never)")
|
|
ap.add_argument("--min-free-vram-mib", type=int, default=None,
|
|
help="load an unloaded model only when this much VRAM is free, else answer 503 (also "
|
|
"\"min_free_vram_mib\" in the config; default: always load)")
|
|
ap.add_argument("--before-load", help="a command run before the model is loaded again (e.g. to unload another "
|
|
"server's model; also \"before_load\" in the config, a string or a list)")
|
|
a = ap.parse_args()
|
|
cfg = json.loads(Path(a.config).read_text(encoding="utf-8-sig")) if a.config else {} # Notepad adds a BOM
|
|
if a.gpu is not None:
|
|
cfg["gpu"] = int(a.gpu) if a.gpu.strip().isdigit() else a.gpu
|
|
a.host = a.host or cfg.get("host") or "127.0.0.1" # issue #26: the run scripts pass no --host, the config can
|
|
try: # before the minutes of loading: is the port free?
|
|
Server((a.host, a.port), BaseHTTPRequestHandler).server_close()
|
|
except OSError:
|
|
ap.error(f"port {a.port} is already in use - is Strata (or another server) already running? "
|
|
f"Close it, or start this one with a different --port")
|
|
if cfg.get("tokenizer"):
|
|
a.tokenizer = cfg["tokenizer"]
|
|
tok = ByteTokenizer()
|
|
tpath = Path(a.tokenizer)
|
|
if a.engine == "strata" and not (tpath / "vocab.json").exists():
|
|
ap.error(f"the model's tokenizer is missing ({tpath / 'vocab.json'}); run setup again")
|
|
if (tpath / "vocab.json").exists():
|
|
import strata_tokenizer as ST
|
|
vocab = json.loads((tpath / "vocab.json").read_text(encoding="utf-8"))
|
|
tokens = [None] * len(vocab)
|
|
for t, i in vocab.items():
|
|
tokens[i] = t
|
|
merges = (tpath / "merges.txt").read_text(encoding="utf-8").split("\n")
|
|
types = json.loads((tpath / "token_type.json").read_text())
|
|
tok = ST.Tokenizer(tokens, merges, types)
|
|
hub = hub_from_config(cfg, a.mcp_config) # before the minutes of loading: a bad entry stops here
|
|
if a.engine == "strata":
|
|
if not cfg:
|
|
ap.error("--engine strata needs --config")
|
|
vision = None
|
|
env = child_env(cfg)
|
|
sampling_defaults = sampling_defaults_from_config(cfg)
|
|
if sampling_defaults:
|
|
pretty = ", ".join(f"{k}={v}" for k, v in sampling_defaults.items())
|
|
print(f"[strata] sampling defaults from the config: {pretty}", flush=True)
|
|
lazy = a.lazy or cfg.get("lazy_load") is True
|
|
if lazy and cfg.get("vision"):
|
|
ap.error("lazy loading is text-only; disable vision in the config")
|
|
if cfg.get("vision"):
|
|
print("loading the vision encoder ...", flush=True)
|
|
# relative paths are the config's cwd's, as for the engine below
|
|
vcfg = {k: (os.path.abspath(os.path.join(cfg.get("cwd") or ".", v))
|
|
if k in ("exe", "mmproj", "model") and isinstance(v, str) and not os.path.isabs(v) else v)
|
|
for k, v in cfg["vision"].items()}
|
|
vision = Vision(vcfg, log=open(cfg["log"], "a", encoding="utf-8") if cfg.get("log") else None,
|
|
env=vision_env(cfg, env))
|
|
print("model unloaded; the first request loads it ..." if lazy else
|
|
"loading the model (the first start takes a minute or two) ...", flush=True)
|
|
if len(gpu_list(cfg)) > 1:
|
|
try:
|
|
split = layer_split_value(cfg) # #644: before the (minutes-long) start
|
|
except ValueError as e:
|
|
raise SystemExit(f"[strata] config {e}")
|
|
print(f"[strata] layer split across GPUs {gpu_list(cfg)} ({split})", flush=True)
|
|
# a relative "exe" is the config's cwd's: Windows' CreateProcess resolves "engine/strata.exe" against nothing
|
|
# it is told about (WinError 2), so it is made absolute here
|
|
exe = cfg["exe"] if os.path.isabs(cfg["exe"]) else os.path.abspath(os.path.join(cfg.get("cwd") or ".", cfg["exe"]))
|
|
try:
|
|
silence = engine_silence_s(cfg) # #481: checked before the (minutes-long) start
|
|
except ValueError as e:
|
|
raise SystemExit(f"[strata] config {e}")
|
|
try:
|
|
effort_end = effort_end_args(cfg, exe, tok) # #458
|
|
except ValueError as e:
|
|
raise SystemExit(f"[strata] config {e}")
|
|
engine = StrataEngine(exe, engine_args(cfg) + (effort_end or []), cwd=cfg.get("cwd"), log=cfg.get("log"),
|
|
env=env, lazy=lazy)
|
|
engine.silence_s = silence # an attribute of its own: restart() keeps it
|
|
warn_tight_ram(engine.info.get("arena_mib"))
|
|
note = desktop_vram_note(cfg.get("backend"), engine.info.get("vram_free_mib"), engine.spawn[1],
|
|
linux_desktop())
|
|
if note: # #560 #516: before --open starts a browser on that card
|
|
print(note, flush=True)
|
|
else:
|
|
effort_end = None
|
|
engine, vision, sampling_defaults = MockEngine(tok, a.script or [
|
|
"Thinking about it.</think>\n\nHello from the mock engine."]), None, {}
|
|
# the model's own chat template (exported with its tokenizer), else the original model's
|
|
tpl = tpath / "chat_template.jinja"
|
|
svc = Service(engine, tok, ChatTemplate(tpl if tpl.exists() else ROOT / "serve/chat_template.jinja"),
|
|
model_name=cfg.get("model_name", "qwen3.8-flash-next"), vision=vision,
|
|
sampling_defaults=sampling_defaults,
|
|
fit_max_tokens=a.fit_max_tokens or cfg.get("fit_max_tokens") is True)
|
|
try:
|
|
svc.set_aliases(cfg.get("aliases")) # #297: other names the model answers to
|
|
except ValueError as e:
|
|
raise SystemExit(f"[strata] config {e}")
|
|
if svc.aliases:
|
|
print(f"[strata] model aliases: {', '.join(svc.aliases)}", flush=True)
|
|
if ("STRATA_API_KEY" in os.environ and not os.environ["STRATA_API_KEY"].strip()) or any(x == "--api-key" and i + 1 < len(sys.argv) and not sys.argv[i + 1].strip() or x.strip() == "--api-key="
|
|
for i, x in enumerate(sys.argv)):
|
|
# #213: an empty key would switch authentication off without a word
|
|
print("[strata] an API key was given but it is empty: set a key, or leave --api-key / STRATA_API_KEY out",
|
|
file=sys.stderr)
|
|
return 2
|
|
svc.api_key = a.api_key or cfg.get("api_key", "")
|
|
svc.cors_origins = origins_of(cfg.get("cors_origins"), "cors_origins", wildcard=True)
|
|
svc.trusted_origins = origins_of(cfg.get("trusted_origins"), "trusted_origins", wildcard=False)
|
|
try:
|
|
svc.allowed_hosts = allowed_hosts_of(cfg.get("allowed_hosts"), os.environ.get("STRATA_ALLOWED_HOSTS", ""))
|
|
except ValueError as e:
|
|
raise SystemExit(f"[strata] config {e}")
|
|
if svc.allowed_hosts:
|
|
print("[strata] Host check off: any name reaches this server (allowed_hosts \"*\")" if "*" in svc.allowed_hosts
|
|
else f"[strata] also answers to the host names {', '.join(svc.allowed_hosts)} (allowed_hosts)", flush=True)
|
|
svc.api_monitor = a.api_monitor or cfg.get("api_monitor") is True
|
|
if svc.api_monitor:
|
|
print("[strata] API request monitor on (/api-monitor): the last 100 requests' prompts and answers are kept in "
|
|
"memory" + ("" if svc.api_key else "; anyone who can reach this server can read them (no API key)"),
|
|
flush=True)
|
|
if svc.cors_origins:
|
|
print(f"[strata] CORS on /v1/* for {', '.join(svc.cors_origins)}"
|
|
+ ("" if svc.api_key or "*" not in svc.cors_origins else
|
|
" - WARNING: any web page may use the model (no API key)"), flush=True)
|
|
svc.idle_unload_s = a.idle_unload if a.idle_unload is not None else float(cfg.get("idle_unload_s") or 0)
|
|
svc.min_free_vram_mib = a.min_free_vram_mib if a.min_free_vram_mib is not None else \
|
|
int(cfg.get("min_free_vram_mib") or 0)
|
|
svc.before_load = a.before_load or cfg.get("before_load") or None
|
|
mode = str(cfg.get("anthropic_thinking") or "model") # #278: "on_request" = only when the request asks
|
|
if mode not in ("model", "on_request"):
|
|
raise SystemExit(f"[strata] config anthropic_thinking must be \"model\" or \"on_request\", not {mode!r}")
|
|
svc.anthropic_think_unasked = mode == "model"
|
|
rs = cfg.get("repeat_stop_tokens", REPEAT_STOP_TOKENS) # #606: opt-out with 0
|
|
if isinstance(rs, bool) or not isinstance(rs, int) or rs < 0:
|
|
raise SystemExit(f"[strata] config \"repeat_stop_tokens\" must be a whole number >= 0 (0 = off), not {rs!r}")
|
|
svc.repeat_stop_tokens = rs
|
|
svc.effort_end = bool(effort_end) # #458: "effort_position": "end" with an engine that has it
|
|
if cfg.get("reasoning_budget_tokens") is not None: # #123: a default thinking budget for every request
|
|
try:
|
|
svc.reasoning_budget_tokens = cfg["reasoning_budget_tokens"]
|
|
budget = svc.reasoning_budget({})
|
|
except ValueError as e:
|
|
raise SystemExit(f"[strata] config {e}")
|
|
if budget:
|
|
print(f"[strata] thinking budget: {budget} tokens (reasoning_budget_tokens; a request can set its own)",
|
|
flush=True)
|
|
svc.gpu_index = (gpu_list(cfg) or [0])[0] # the Monitor reads the card the engine runs on (issue #51)
|
|
svc.gpu_indices = gpu_list(cfg) # ... or every card of a layer split (issue #112)
|
|
svc.backend = cfg.get("backend") # "hip": the AMD cards' readings come from sysfs (#301)
|
|
if a.config:
|
|
svc.config_path = a.config # #564: the web page's Settings view
|
|
if a.config: # the Chat settings shared with other apps, from last time
|
|
svc.shared_path = str(Path(a.config).with_suffix("")) + ".shared-settings.json"
|
|
try:
|
|
svc.shared = clean_shared_defaults(json.loads(Path(svc.shared_path).read_text(encoding="utf-8")))
|
|
if svc.shared:
|
|
print("[strata] other apps use the Chat settings: " +
|
|
", ".join(f"{k}={v}" for k, v in svc.shared.items()), flush=True)
|
|
except (OSError, ValueError):
|
|
svc.shared = {}
|
|
if hub is not None:
|
|
import atexit
|
|
svc.mcp = hub
|
|
print(f"[strata] starting {len(hub.servers)} MCP server{'s' * (len(hub.servers) != 1)} for the web app's "
|
|
f"chat: {', '.join(hub.servers)}", flush=True)
|
|
hub.start()
|
|
atexit.register(hub.close) # the servers Strata started end with it
|
|
httpd = serve(svc, host=a.host, port=a.port)
|
|
svc.start_idle_unload()
|
|
here = "127.0.0.1" if a.host in ("0.0.0.0", "", "::") else a.host
|
|
print(f"ready: http://{here}:{a.port}/v1 (OpenAI: /v1/chat/completions, Anthropic: /v1/messages, "
|
|
f"context {engine.max_context} tokens{', images on' if vision else ''}"
|
|
f"{', API key required' if svc.api_key else ''})", flush=True)
|
|
print(f" open http://{here}:{a.port}/ in a browser to chat; close this window to stop the model", flush=True)
|
|
if a.host not in ("127.0.0.1", "localhost", "::1"):
|
|
# issue #26: reachable from other devices - say at which address, and what can still block it
|
|
ips = lan_addresses()
|
|
for ip in ips:
|
|
print(f" from other devices: http://{ip}:{a.port}/ (API: http://{ip}:{a.port}/v1)", flush=True)
|
|
if not ips:
|
|
print(" from other devices: http://<this PC's IP address>:" + str(a.port) + "/", flush=True)
|
|
if not svc.api_key:
|
|
print(" WARNING: no API key - anyone on your network can use this model. Add \"api_key\": \"...\" "
|
|
"to the config (clients send it as their API key; the web page asks for it)", flush=True)
|
|
if os.name == "nt":
|
|
print(" nothing arrives? Windows Firewall blocks it until allowed: accept its prompt for Python, or run "
|
|
"in an admin PowerShell:\n New-NetFirewallRule -DisplayName \"Strata " + str(a.port) + "\" "
|
|
"-Direction Inbound -Protocol TCP -LocalPort " + str(a.port) + " -Action Allow -Profile Private\n"
|
|
" (and set this network to Private in Windows' network settings)", flush=True)
|
|
if a.open and cfg.get("open_browser") is not False: # #609: the config's "open_browser": false wins (an older
|
|
import webbrowser # run-<model>.bat still passes --open)
|
|
webbrowser.open(f"http://{'127.0.0.1' if a.host in ('0.0.0.0', '') else a.host}:{a.port}/")
|
|
# #96: docker stop sends SIGTERM, which Python ignores by default, so the container's PID 1 would be killed after
|
|
# the grace period with the engine still running. SIGTERM takes Ctrl+C's path below (QUIT to the engine).
|
|
# SIGINT keeps Python's own handler, so Ctrl+C and a second Ctrl+C work as before.
|
|
def on_sigterm(signum, frame):
|
|
raise KeyboardInterrupt
|
|
try:
|
|
signal.signal(signal.SIGTERM, on_sigterm)
|
|
except (ValueError, OSError, AttributeError): # not the main thread
|
|
pass
|
|
try:
|
|
while True:
|
|
time.sleep(1) # Windows never delivers Ctrl+C to an untimed Event.wait()
|
|
except KeyboardInterrupt:
|
|
print("\n[strata] stopping (Ctrl+C again to end the engine at once) ...", flush=True)
|
|
closers = [httpd.shutdown, getattr(engine, "close", None), vision.close if vision else None,
|
|
hub.close if hub is not None else None]
|
|
for close in filter(None, closers):
|
|
try:
|
|
close()
|
|
except KeyboardInterrupt: # a second Ctrl+C: don't wait for the engine to free its memory
|
|
if getattr(engine, "proc", None):
|
|
engine.proc.kill()
|
|
print("[strata] stopped", flush=True)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|