mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 17:38:20 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba22c7145 | ||
|
|
67492fb32b | ||
|
|
4c623f1d78 | ||
|
|
c1afbdac8a | ||
|
|
069b92b38d | ||
|
|
de71982be9 |
No files matched your search
+1
-1
@@ -8,7 +8,7 @@
|
||||
# written against sm_120 (RTX 5000 / Blackwell) and silently building for an older arch would produce a
|
||||
# binary that runs and is wrong about its own performance, which is the worst of both outcomes.
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(strata VERSION 0.1.18 LANGUAGES CXX)
|
||||
project(strata VERSION 0.1.19 LANGUAGES CXX)
|
||||
add_compile_definitions(STRATA_VERSION="${PROJECT_VERSION}")
|
||||
# issue #31: release builds keep a PDB beside the exe (not shipped in it), so a minidump from a stalled engine can
|
||||
# be read back to the exact source line
|
||||
|
||||
@@ -39,6 +39,9 @@ A card with more VRAM is faster, because more of the model fits on the GPU: an R
|
||||
100-140 tokens per second. All measurements, long-context numbers and estimates for other cards are in the
|
||||
[details](docs/DETAILS.md#speed-measured).
|
||||
|
||||
Every PC is different: `START-HERE.bat --calibrate` measures a few engine settings on yours and keeps the fastest
|
||||
(about 5-10 minutes; on the PC above it made the Coder 7% faster).
|
||||
|
||||
## Which model should I pick?
|
||||
|
||||
**The size** (the same model, compressed more or less):
|
||||
|
||||
+62
-2
@@ -4,7 +4,8 @@ The technical side of Strata: every measured number, the API, images, all settin
|
||||
New here? Start with the [README](../README.md) - it has everything you need to install and use it.
|
||||
|
||||
> **On this page:** [Speed](#speed-measured) · [Other GPUs](#other-gpus-estimated) · [Which model?](#which-model) ·
|
||||
> [Requirements](#before-you-start) · [Windows](#windows) · [Linux](#linux) · [API](#using-it) · [Images](#images-vision) ·
|
||||
> [Requirements](#before-you-start) · [Windows](#windows) · [Linux](#linux) · [API](#using-it) ·
|
||||
> [MCP tools](#tools-from-mcp-servers) · [Images](#images-vision) ·
|
||||
> [Troubleshooting](#troubleshooting) · [How it works](#how-it-works)
|
||||
|
||||
---
|
||||
@@ -199,10 +200,22 @@ START-HERE.bat --gguf-dir D:\models\IQ2_XS use GGUF files you already have
|
||||
START-HERE.bat --data-dir E:\Strata-data keep the model files somewhere else
|
||||
START-HERE.bat --port 8081 another port
|
||||
START-HERE.bat --gpu 1 another GPU (numbered as nvidia-smi; setup picks the one with the most VRAM)
|
||||
START-HERE.bat --calibrate tune the engine for this PC (about 5-10 minutes), then start
|
||||
```
|
||||
|
||||
With more than one model installed, it asks which one to start. `run-<model>.bat` starts a model directly.
|
||||
|
||||
**Tuning for your PC (`--calibrate`, engine 0.1.19).** Three engine settings depend on the PC more than on the model:
|
||||
- the share of the experts missing from VRAM that are copied to the GPU instead of computed by the CPU
|
||||
(`--pcie-frac`: a fast PCIe link and a slower CPU want more, a laptop's narrower link less);
|
||||
- how sure the draft layer must be to add another guess to a check (`--spec-min-p`);
|
||||
- how many CPU threads compute experts (`--pool-workers`: on CPUs with efficiency cores, fewer can be faster).
|
||||
|
||||
The defaults were measured on a Ryzen 5 7600 with an RTX 5070. Setup offers to measure them on your PC after an
|
||||
install; `START-HERE.bat --calibrate` (Linux: `./setup.sh --calibrate`) does it any time. It measures the output
|
||||
speed with each setting and keeps one only when it is more than 3% faster. The result is remembered per PC and model
|
||||
(in the settings file next to the data folder's record), so updates keep it.
|
||||
|
||||
### Chat in the terminal (optional)
|
||||
|
||||
```
|
||||
@@ -244,6 +257,7 @@ The server listens on `http://127.0.0.1:8080` (change with `--port` in setup, or
|
||||
| Model list / health | `GET /v1/models`, `GET /health` |
|
||||
| What the model is doing right now | `GET /status` |
|
||||
| Everything the Monitor tab shows (engine, live state, last requests, hardware) | `GET /metrics` |
|
||||
| The MCP servers, their state and tools ([below](#tools-from-mcp-servers)) | `GET /mcp` |
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8080/v1/chat/completions -H "Content-Type: application/json" -d '{
|
||||
@@ -308,7 +322,52 @@ engine arguments. The run config's optional `sampling` block sets the defaults f
|
||||
block at all a request without sampling keys decodes greedy. The penalties (`presence_penalty`, `frequency_penalty`,
|
||||
`repetition_penalty`, with `penalty_last_n` capping how many recent tokens they count over, default 64 when any
|
||||
penalty is set) ride the same path; they count the tokens the request has consumed, so a repetition penalty
|
||||
suppresses what the model itself just said, not the prompt alone.
|
||||
suppresses what the model itself just said, not the prompt alone. Since engine 0.1.19 they apply to every token
|
||||
the speculative decoding checks at once, exactly as if it decoded one token at a time (before, only the first of
|
||||
each batch got them). That makes requests with penalties 1-11% slower than in 0.1.18: the draft layer guesses
|
||||
without penalties, so more of its guesses are now rejected. Requests without penalties are unchanged. `top_k` keeps at most 64 candidates: `0` ("off") or anything above 64 uses all 64.
|
||||
|
||||
---
|
||||
|
||||
## Tools from MCP servers
|
||||
|
||||
The chat page can give the model tools from [MCP](https://modelcontextprotocol.io) servers, as LM Studio and Claude
|
||||
Desktop do: reading your files, fetching web pages, searching, anything an MCP server offers. List the servers in
|
||||
`strata-<model>.json` under `"mcp_servers"` - the same shape as Claude Desktop's `mcpServers` block, which you can
|
||||
also paste as it is (key `"mcpServers"`):
|
||||
|
||||
```json
|
||||
"mcp_servers": {
|
||||
"files": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "C:\\Users\\me\\Documents\\notes"]},
|
||||
"search": {"url": "http://127.0.0.1:3000/mcp", "headers": {"Authorization": "Bearer ..."}}
|
||||
},
|
||||
"mcp": {"timeout_s": 60, "max_result_chars": 20000, "max_rounds": 8}
|
||||
```
|
||||
|
||||
Or keep them in their own file and start the server with `--mcp-config path\to\claude_desktop_config.json` (a file
|
||||
with an `mcpServers` block; add it to the `serve/server.py` line of your run script). Restart Strata after a change.
|
||||
|
||||
- **A program** (`command`, `args`, optional `env` and `cwd`) is started by Strata and spoken to over its
|
||||
stdin/stdout; `npx`, `uvx`, `python` and friends are found on `PATH` as usual (Node.js is needed for `npx`
|
||||
servers). **An address** (`url`, optional `headers`) uses MCP's Streamable HTTP transport (the older SSE-only
|
||||
transport is not supported). `"disabled": true` leaves an entry out.
|
||||
- The servers start with Strata, in the background; the server window says what each one offers
|
||||
(`MCP server 'files': 14 tools (...)`), or why it did not start - its tools are then left out and the chat works
|
||||
without them. The Monitor tab lists them, and the Sampling drawer has **Use tools from MCP servers** (on by
|
||||
default). A server that stops later is started again at its next call.
|
||||
- In the chat each call shows as a small block (tool, arguments, result); the model reads the result and goes on,
|
||||
up to `max_rounds` calls in a row per answer. A tool that fails or takes longer than `timeout_s` (default 60 s)
|
||||
gives the model an `error: ...` result instead of ending the chat. Results longer than `max_result_chars`
|
||||
(default 20,000 characters) are cut, with a note, before the model reads them. Stop stops a running tool too.
|
||||
- Only the chat page uses them. API clients (omp, Claude Code, OpenAI and Anthropic SDKs) see the API exactly as
|
||||
before and keep their own tools; a request to `/v1/chat/completions` opts in with `"strata_mcp": true` (it then
|
||||
gets `strata_mcp` tool events in the stream).
|
||||
|
||||
**Security.** MCP tools run on your PC with your user's rights, and **the model decides when to call them** - also
|
||||
because of what it reads (a web page or a file can contain instructions). Give a filesystem server only the folders
|
||||
it needs, prefer read-only tools, and don't add servers you don't trust. The tools can only be used from the chat
|
||||
page itself (a request with another site's Origin or without a JSON content type is refused); if Strata is reachable
|
||||
from other devices, set an API key.
|
||||
|
||||
---
|
||||
|
||||
@@ -433,6 +492,7 @@ the document, +0.4% on the chat. Details: `bench/results/2026-09-27-esp/`.
|
||||
| Python or the build tools could not be installed | Install what it names (links are printed), then run it again. Everything already done is kept. |
|
||||
| `port 8080 is already in use` | Strata is already running (look for its window), or another program uses the port: `START-HERE.bat --port 8081`. |
|
||||
| `cudaHostRegister ... out of memory` in the log | Normal on Windows: the engine pins the experts in per-layer slices instead. Only a problem if the load then fails. |
|
||||
| `ExpertCache: cudaMalloc(...) failed: out of memory` although VRAM is free | Windows' page file is off or tiny: every allocation on the graphics card is also charged to Windows' commit (RAM + page file). Set the page file to "System managed" (System > About > Advanced system settings > Performance > Advanced > Virtual memory) and restart. Since 0.1.19 the engine retries with a smaller cache instead of stopping, and setup warns about a page file under 4 GB (issue #60). |
|
||||
| The first start takes minutes | It is reading 34-55 GB into RAM; the second start is faster while the files are in the OS cache. |
|
||||
| The PC freezes for a few minutes at the start | Normal, most of all the first time (the server window says when it happens): the engine loads the experts into RAM, pins part of it for the GPU and sizes the expert cache. Wait; don't close the window. Still frozen after 10 minutes: restart the PC, close other programs, try again, or pick a smaller size. |
|
||||
| `the engine stopped unexpectedly (exit code ...)` | The engine process ended mid-answer - usually out of RAM (Linux ends the biggest program: `sudo dmesg \| grep -i -E 'killed process\|out of memory'`). The next request starts it again by itself. If it repeats: close other programs or pick a smaller size. The server also warns at start when the model's experts leave less than ~6 GB of RAM for everything else. |
|
||||
|
||||
@@ -71,14 +71,20 @@ public:
|
||||
sampling_ = sp; // row t of a window at pos0 draws Philox(seed, pos0 + t): see run()
|
||||
}
|
||||
|
||||
/// The penalty-history row for `sampling_.penalty_last_n`: ONE row of `history_len` int32 slots, most
|
||||
/// recent token LAST, unused front slots -1 (the kernel reads only the tail window). Null disables the
|
||||
/// penalties entirely - the neutral run's sampling call is byte-for-byte what it was. The engine
|
||||
/// re-uploads the request's tail before every window; the pointer must stay alive across the request.
|
||||
/// The penalty histories for `sampling_.penalty_last_n`: ONE ROW PER WINDOW ROW, T rows of `history_len`
|
||||
/// int32 slots at that stride (`strata::kernels::penalty_rows` builds them), most recent token LAST, unused
|
||||
/// front slots -1 (the kernel reads only the tail window). Row t follows the window's drafts 1..t - staging
|
||||
/// row 0 alone (before 0.1.19) left the drafted rows with unwritten histories. Null disables the penalties
|
||||
/// entirely - the neutral run's sampling call is byte-for-byte what it was. The engine re-uploads the rows
|
||||
/// before every window; the buffer must hold kVerifyMaxT rows and stay alive across the request.
|
||||
void set_history(const int32_t* history, int history_len) {
|
||||
hist_d_ = history;
|
||||
hist_len_ = history_len;
|
||||
}
|
||||
/// Off: `run` skips the request's head sampling and `out` is the recorded greedy pick. For windows whose
|
||||
/// picks are discarded - a prompt read through windows commits every token - so they cost no sampler launch
|
||||
/// or sync and never read a history staged for another position.
|
||||
void set_head_sampling(bool on) { head_sampling_ = on; }
|
||||
|
||||
/// Keep the first `n_keep` (1..T) tokens of the last window; advances `ss.ple_prev` by them.
|
||||
bool commit(int n_keep, std::string& err);
|
||||
@@ -112,6 +118,7 @@ private:
|
||||
}(); ///< greedy by default; per-request via set_sampling
|
||||
const int32_t* hist_d_ = nullptr; ///< penalty-history row (set_history); null = no penalties apply
|
||||
int hist_len_ = 0;
|
||||
bool head_sampling_ = true; ///< set_head_sampling
|
||||
bool capture_commit(std::string& err);
|
||||
bool record_window(int T, cudaStream_t cs, std::string& err);
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
// include/strata/kernels/sampler.hpp - the sampler chain, host-callable (P2.S2).
|
||||
//
|
||||
// penalties -> top_k -> min_p -> top_p -> temperature -> penalties -> pick
|
||||
// penalties -> top_k -> top_p -> min_p -> temperature -> pick
|
||||
//
|
||||
// The ORDER is specified in docs/sampling.md, transcribed from llama.cpp's own chain. Three facts there are
|
||||
// easy to get backwards: temperature comes AFTER the truncation filters, penalties come AFTER temperature as
|
||||
// well as before the selection, and min_p cuts BEFORE top_p (both on the pre-temperature logits).
|
||||
// llama.cpp's default chain (issue #53): ONE penalties stage, first, and temperature AFTER the truncation filters;
|
||||
// top_p cuts before min_p. `src/kernels/cuda/sampler.cu` has the details and `sampler_parity` pins them.
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace strata::kernels {
|
||||
|
||||
struct SamplerParams {
|
||||
int top_k = 20; // 0 disables the filter
|
||||
int top_k = 20; // sampled path: 1..64 as given; 0 (off) or more than 64 keep the widest list, 64
|
||||
float top_p = 0.95f; // 1.0 disables the filter
|
||||
float min_p = 0.0f; // 0 disables; keeps tokens with p >= min_p * p_max (a prefix of the top_k list)
|
||||
float temperature = 1.0f; // <= 0 means greedy
|
||||
@@ -34,9 +35,29 @@ struct SamplerParams {
|
||||
// reported by whatever synchronising call happens NEXT, which will be somewhere else entirely and will name a
|
||||
// buffer that has nothing to do with it. A sticky async fault does not know where it came from.
|
||||
//
|
||||
// `history` is (n_tokens, history_len) int32, the most recent tokens for each row with any unused slots set to
|
||||
// -1; only the last `p.penalty_last_n` of each row are counted. Pass nullptr and 0 when no penalties apply.
|
||||
// `history` is (n_tokens, history_len) int32 - ONE ROW PER TOKEN ROW, at a stride of `history_len` - the most
|
||||
// recent tokens that row's pick follows, with any unused slots set to -1; only the last `p.penalty_last_n` of
|
||||
// each row are counted, and ids outside [0, n_vocab) are ignored. Pass nullptr and 0 when no penalties apply.
|
||||
// A verify window's rows need DIFFERENT histories: row t follows the window's drafts 1..t (`penalty_rows`).
|
||||
void sample_tokens(const float* logits, int n_tokens, int n_vocab, const int* history, int history_len,
|
||||
const SamplerParams& p, int* out, void* stream);
|
||||
|
||||
// The penalty-history rows of a verify window, on the host: row t of `out` (T rows of `h` slots) is the last `h`
|
||||
// tokens of `tail[0..n_tail)` followed by `window[0..t]`, most recent LAST, -1 in the unused front slots.
|
||||
// `tail` is what the state consumed before the window, `window[0]` the fed-back token and `window[1..]` the
|
||||
// drafts: row t is exactly the history plain decode counts when it picks the token after `window[t]`, so drafting
|
||||
// cannot change which tokens are penalised. Row 0 alone is the single row the engine staged before 0.1.19.
|
||||
inline void penalty_rows(const int32_t* tail, int64_t n_tail, const int32_t* window, int T, int h, int32_t* out) {
|
||||
for (int t = 0; t < T; ++t) {
|
||||
int32_t* row = out + (size_t) t * (size_t) h;
|
||||
const int64_t avail = n_tail + t + 1; // tail + window[0..t]
|
||||
const int take = (int) std::min<int64_t>(h, avail);
|
||||
std::fill(row, row + (h - take), -1);
|
||||
for (int j = 0; j < take; ++j) {
|
||||
const int64_t i = avail - take + j; // index into (tail, window)
|
||||
row[h - take + j] = i < n_tail ? tail[i] : window[i - n_tail];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace strata::kernels
|
||||
+669
@@ -0,0 +1,669 @@
|
||||
"""serve/mcp.py - tools from MCP (Model Context Protocol) servers for the web app's chats.
|
||||
|
||||
The user lists servers in the run config (`"mcp_servers"`, or Claude Desktop's `"mcpServers"` block pasted as is) or
|
||||
in a separate file (`--mcp-config`). Each one is either a program Strata starts (`command`, `args`, `env`, `cwd`:
|
||||
the stdio transport, newline-delimited JSON-RPC 2.0 on its stdin/stdout) or an address (`url`, `headers`: the
|
||||
Streamable HTTP transport, JSON-RPC POSTed, answered as JSON or as an event stream). Both are implemented here with
|
||||
the standard library only; no MCP SDK is needed.
|
||||
|
||||
`McpHub` owns every configured server: it starts them in the background when the server starts, offers their tools to
|
||||
the model as OpenAI function tools named `<server>__<tool>` (two servers may both have a `search`), and routes a call
|
||||
back to the server that has it. A server that does not start is left out with one log line; the chat works without
|
||||
it. A server that stops later (a crash) is started again on its next call.
|
||||
|
||||
Tool results reach the model as text: the MCP content blocks joined, capped at `max_result_chars` (a directory tree or
|
||||
a web page can be megabytes, far more than a context holds) with a note saying so. A failed call - the tool's own
|
||||
error, a crash, a timeout - becomes a result that starts with "error:", so the model can react instead of the chat
|
||||
breaking.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
PROTOCOL = "2025-06-18" # the MCP revision Strata asks for; the server's answer is used as given
|
||||
DEFAULTS = {"timeout_s": 60.0, "max_result_chars": 20000, "max_rounds": 8, "start_timeout_s": 120.0}
|
||||
|
||||
|
||||
class McpError(RuntimeError):
|
||||
"""A server answered with an error, stopped, or could not be reached."""
|
||||
|
||||
|
||||
class McpTimeout(McpError):
|
||||
pass
|
||||
|
||||
|
||||
class McpCancelled(McpError):
|
||||
"""The chat was stopped while the call ran."""
|
||||
|
||||
|
||||
class _Slot:
|
||||
"""One request waiting for its response."""
|
||||
|
||||
def __init__(self):
|
||||
self.done = threading.Event()
|
||||
self.msg = None
|
||||
self.error = None
|
||||
|
||||
|
||||
def _wait(slot: _Slot, timeout: float, cancel: threading.Event | None, what: str):
|
||||
"""Wait for a response, checking the chat's stop button and the deadline a few times a second."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while not slot.done.wait(0.05):
|
||||
if cancel is not None and cancel.is_set():
|
||||
raise McpCancelled("stopped")
|
||||
if time.monotonic() > deadline:
|
||||
raise McpTimeout(f"{what} timed out after {timeout:g} s")
|
||||
if slot.error:
|
||||
raise slot.error
|
||||
msg = slot.msg
|
||||
if "error" in msg:
|
||||
err = msg["error"] if isinstance(msg["error"], dict) else {"message": str(msg["error"])}
|
||||
raise McpError(f"{err.get('message') or 'error'} (code {err.get('code')})")
|
||||
return msg.get("result") or {}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ transports
|
||||
class StdioTransport:
|
||||
"""A server Strata starts: JSON-RPC messages one per line on its stdin and stdout; its stderr is its log."""
|
||||
kind = "stdio"
|
||||
|
||||
def __init__(self, name: str, cfg: dict):
|
||||
self.name, self.cfg = name, cfg
|
||||
self.proc = None
|
||||
self.lock = threading.Lock() # the pending requests
|
||||
self.write_lock = threading.Lock() # stdin; separate, so a full pipe can't block the reader
|
||||
self.pending: dict[int, _Slot] = {}
|
||||
self.next_id = 0
|
||||
self.stderr = collections.deque(maxlen=40) # its last log lines, for the error message when it fails
|
||||
self.protocol = PROTOCOL
|
||||
|
||||
def start(self):
|
||||
env = dict(os.environ)
|
||||
env.update({str(k): str(v) for k, v in (self.cfg.get("env") or {}).items()})
|
||||
command = str(self.cfg["command"])
|
||||
# On Windows `npx`, `uvx` and friends are .cmd files that CreateProcess finds only with their extension
|
||||
exe = shutil.which(command, path=env.get("PATH")) or command
|
||||
extra = {}
|
||||
if os.name == "nt":
|
||||
extra["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
else:
|
||||
extra["start_new_session"] = True # its own process group, so close() ends its children too
|
||||
try:
|
||||
self.proc = subprocess.Popen([exe, *[str(a) for a in self.cfg.get("args") or []]], stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=self.cfg.get("cwd") or None,
|
||||
env=env, **extra)
|
||||
except OSError as e:
|
||||
raise McpError(f"could not start {command!r}: {e}") from None
|
||||
threading.Thread(target=self._read, daemon=True).start()
|
||||
threading.Thread(target=self._read_stderr, daemon=True).start()
|
||||
|
||||
def alive(self) -> bool:
|
||||
return self.proc is not None and self.proc.poll() is None and not getattr(self, "ended", False)
|
||||
|
||||
@staticmethod
|
||||
def _lines(f):
|
||||
"""A pipe's lines until it closes (or close() closed it under us)."""
|
||||
try:
|
||||
yield from f
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
|
||||
def _read_stderr(self):
|
||||
for raw in self._lines(self.proc.stderr):
|
||||
line = raw.decode("utf-8", "replace").rstrip()
|
||||
if line:
|
||||
self.stderr.append(line)
|
||||
if os.environ.get("STRATA_DEBUG"):
|
||||
print(f"[mcp {self.name}] {line}", flush=True)
|
||||
|
||||
def _read(self):
|
||||
for raw in self._lines(self.proc.stdout):
|
||||
line = raw.decode("utf-8", "replace").strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except ValueError: # a server that prints to stdout; the spec forbids it
|
||||
self.stderr.append(line)
|
||||
continue
|
||||
for m in msg if isinstance(msg, list) else [msg]:
|
||||
if isinstance(m, dict):
|
||||
self._dispatch(m)
|
||||
self.ended = True
|
||||
code = self.proc.poll()
|
||||
err = McpError(f"the server stopped{f' (exit code {code})' if code is not None else ''}{self._tail()}")
|
||||
with self.lock:
|
||||
slots, self.pending = list(self.pending.values()), {}
|
||||
for s in slots:
|
||||
s.error = err
|
||||
s.done.set()
|
||||
|
||||
def _tail(self) -> str:
|
||||
return f": {self.stderr[-1][:300]}" if self.stderr else ""
|
||||
|
||||
def _dispatch(self, m: dict):
|
||||
if "method" in m:
|
||||
if "id" in m: # a request from the server: we offer no client features
|
||||
try:
|
||||
if m["method"] == "ping":
|
||||
self._send({"jsonrpc": "2.0", "id": m["id"], "result": {}})
|
||||
else:
|
||||
self._send({"jsonrpc": "2.0", "id": m["id"],
|
||||
"error": {"code": -32601, "message": f"method not supported: {m['method']}"}})
|
||||
except McpError:
|
||||
pass
|
||||
return # notifications (log messages, list changes): not used
|
||||
with self.lock:
|
||||
slot = self.pending.pop(m.get("id"), None)
|
||||
if slot is not None: # an answer to a call that timed out is simply dropped
|
||||
slot.msg = m
|
||||
slot.done.set()
|
||||
|
||||
def _send(self, msg: dict):
|
||||
data = json.dumps(msg, ensure_ascii=False).encode("utf-8") + b"\n"
|
||||
try:
|
||||
with self.write_lock:
|
||||
self.proc.stdin.write(data)
|
||||
self.proc.stdin.flush()
|
||||
except (OSError, ValueError):
|
||||
raise McpError(f"the server stopped{self._tail()}") from None
|
||||
|
||||
def request(self, method: str, params: dict, timeout: float, cancel=None):
|
||||
if not self.alive():
|
||||
raise McpError(f"the server is not running{self._tail()}")
|
||||
slot = _Slot()
|
||||
with self.lock:
|
||||
self.next_id += 1
|
||||
rid = self.next_id
|
||||
self.pending[rid] = slot
|
||||
try:
|
||||
self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
|
||||
return _wait(slot, timeout, cancel, method)
|
||||
except (McpTimeout, McpCancelled) as e:
|
||||
with self.lock:
|
||||
self.pending.pop(rid, None)
|
||||
try: # tell the server to stop working on it
|
||||
self.notify("notifications/cancelled", {"requestId": rid, "reason": str(e)})
|
||||
except McpError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def notify(self, method: str, params: dict | None = None):
|
||||
self._send({"jsonrpc": "2.0", "method": method, **({"params": params} if params is not None else {})})
|
||||
|
||||
def close(self):
|
||||
"""As the spec says: close its stdin, give it a moment to exit, then end it (and what it started)."""
|
||||
p = self.proc
|
||||
if p is None:
|
||||
return
|
||||
try:
|
||||
p.stdin.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
p.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._kill_tree(p)
|
||||
for f in (p.stdout, p.stderr):
|
||||
try:
|
||||
f.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _kill_tree(p):
|
||||
try:
|
||||
if os.name == "nt": # npx.cmd -> node: end the whole tree
|
||||
subprocess.run(["taskkill", "/PID", str(p.pid), "/T", "/F"], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=10)
|
||||
else:
|
||||
os.killpg(p.pid, signal.SIGTERM)
|
||||
try:
|
||||
p.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(p.pid, signal.SIGKILL)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
try:
|
||||
p.kill()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class HttpTransport:
|
||||
"""A server at an address (Streamable HTTP): every message is a POST; the answer is JSON or an event stream that
|
||||
carries it. The session id the server hands out at `initialize` rides on every later request."""
|
||||
kind = "http"
|
||||
|
||||
def __init__(self, name: str, cfg: dict):
|
||||
self.name, self.url = name, str(cfg["url"])
|
||||
self.headers = {str(k): str(v) for k, v in (cfg.get("headers") or {}).items()}
|
||||
self.session = None
|
||||
self.protocol = None # sent as MCP-Protocol-Version once negotiated
|
||||
self.next_id = 0
|
||||
self.lock = threading.Lock()
|
||||
self.broken = None
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
def alive(self) -> bool:
|
||||
return self.broken is None
|
||||
|
||||
def _headers(self) -> dict:
|
||||
h = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream",
|
||||
"User-Agent": "strata", **self.headers}
|
||||
if self.session:
|
||||
h["Mcp-Session-Id"] = self.session
|
||||
if self.protocol:
|
||||
h["MCP-Protocol-Version"] = self.protocol
|
||||
return h
|
||||
|
||||
def _post(self, msg: dict, rid, timeout: float, slot: _Slot | None):
|
||||
try:
|
||||
req = urllib.request.Request(self.url, data=json.dumps(msg, ensure_ascii=False).encode("utf-8"),
|
||||
headers=self._headers(), method="POST")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
if r.headers.get("Mcp-Session-Id"):
|
||||
self.session = r.headers["Mcp-Session-Id"]
|
||||
if slot is None: # a notification: 202, nothing to read
|
||||
return
|
||||
ctype = r.headers.get("Content-Type", "")
|
||||
if "text/event-stream" in ctype:
|
||||
found = self._from_events(r, rid)
|
||||
else:
|
||||
body = r.read()
|
||||
found = None
|
||||
parsed = json.loads(body) if body.strip() else None
|
||||
for m in parsed if isinstance(parsed, list) else [parsed]:
|
||||
if isinstance(m, dict) and m.get("id") == rid and "method" not in m:
|
||||
found = m
|
||||
if found is None:
|
||||
raise McpError("the server's answer did not contain a response")
|
||||
slot.msg = found
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = ""
|
||||
try:
|
||||
detail = e.read().decode("utf-8", "replace")[:300]
|
||||
except OSError:
|
||||
pass
|
||||
if e.code == 404 and self.session: # the session expired: a restart starts a new one
|
||||
self.broken = McpError("the server ended the session")
|
||||
err = McpError(f"HTTP {e.code} {e.reason}{': ' + detail if detail else ''}")
|
||||
if slot is None:
|
||||
raise err from None
|
||||
slot.error = err
|
||||
except (OSError, ValueError, McpError) as e:
|
||||
err = e if isinstance(e, McpError) else McpError(f"could not reach {self.url}: {e}")
|
||||
if slot is None:
|
||||
raise err from None
|
||||
slot.error = err
|
||||
finally:
|
||||
if slot is not None:
|
||||
slot.done.set()
|
||||
|
||||
@staticmethod
|
||||
def _from_events(r, rid):
|
||||
"""Read the event stream until the event that answers `rid` (it may carry notifications first)."""
|
||||
data = []
|
||||
for raw in r:
|
||||
line = raw.decode("utf-8", "replace").rstrip("\r\n")
|
||||
if line.startswith("data:"):
|
||||
data.append(line[5:].lstrip(" "))
|
||||
elif not line and data:
|
||||
try:
|
||||
m = json.loads("\n".join(data))
|
||||
except ValueError:
|
||||
m = None
|
||||
data = []
|
||||
for x in m if isinstance(m, list) else [m]:
|
||||
if isinstance(x, dict) and x.get("id") == rid and "method" not in x:
|
||||
return x
|
||||
return None
|
||||
|
||||
def request(self, method: str, params: dict, timeout: float, cancel=None):
|
||||
if self.broken:
|
||||
raise self.broken
|
||||
with self.lock:
|
||||
self.next_id += 1
|
||||
rid = self.next_id
|
||||
slot = _Slot()
|
||||
# the POST runs on its own thread so the stop button and the deadline are noticed while it waits
|
||||
threading.Thread(target=self._post, args=({"jsonrpc": "2.0", "id": rid, "method": method, "params": params},
|
||||
rid, timeout + 5, slot), daemon=True).start()
|
||||
try:
|
||||
return _wait(slot, timeout, cancel, method)
|
||||
except (McpTimeout, McpCancelled) as e:
|
||||
threading.Thread(target=self._quiet_notify, args=("notifications/cancelled",
|
||||
{"requestId": rid, "reason": str(e)}), daemon=True).start()
|
||||
raise
|
||||
|
||||
def _quiet_notify(self, method, params):
|
||||
try:
|
||||
self.notify(method, params)
|
||||
except McpError:
|
||||
pass
|
||||
|
||||
def notify(self, method: str, params: dict | None = None):
|
||||
self._post({"jsonrpc": "2.0", "method": method, **({"params": params} if params is not None else {})},
|
||||
None, 10, None)
|
||||
|
||||
def close(self):
|
||||
if not self.session:
|
||||
return
|
||||
try: # end the session on the server (it may not allow that)
|
||||
req = urllib.request.Request(self.url, headers=self._headers(), method="DELETE")
|
||||
urllib.request.urlopen(req, timeout=3).close()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ one server
|
||||
class McpServer:
|
||||
"""One configured server: started once (and again after it stopped), with the tools it listed."""
|
||||
|
||||
def __init__(self, name: str, cfg: dict, settings: dict):
|
||||
self.name, self.cfg, self.settings = name, cfg, settings
|
||||
self.kind = "http" if cfg.get("url") else "stdio"
|
||||
self.status = "idle" # idle -> starting -> ready | failed; ready -> stopped when it ends
|
||||
self.error = None
|
||||
self.tools: list[dict] = []
|
||||
self.info: dict = {}
|
||||
self.transport = None
|
||||
self.lock = threading.Lock()
|
||||
self.last_start = 0.0
|
||||
|
||||
def start(self) -> bool:
|
||||
with self.lock:
|
||||
return self._start()
|
||||
|
||||
def _start(self) -> bool:
|
||||
if self.transport is not None:
|
||||
self.transport.close()
|
||||
self.status, self.error, self.last_start = "starting", None, time.monotonic()
|
||||
t = HttpTransport(self.name, self.cfg) if self.kind == "http" else StdioTransport(self.name, self.cfg)
|
||||
self.transport = t
|
||||
timeout = float(self.settings["start_timeout_s"])
|
||||
try:
|
||||
t.start()
|
||||
res = t.request("initialize", {"protocolVersion": PROTOCOL, "capabilities": {},
|
||||
"clientInfo": {"name": "strata", "title": "Strata", "version": "1"}},
|
||||
timeout)
|
||||
t.protocol = str(res.get("protocolVersion") or PROTOCOL)
|
||||
self.info = {k: v for k, v in (res.get("serverInfo") or {}).items() if k in ("name", "title", "version")}
|
||||
self.info["protocol"] = t.protocol
|
||||
t.notify("notifications/initialized")
|
||||
tools, cursor = [], None
|
||||
for _ in range(100): # pages; a server that never stops paging is cut off
|
||||
try:
|
||||
page = t.request("tools/list", {"cursor": cursor} if cursor else {}, timeout)
|
||||
except McpError as e:
|
||||
if "code -32601" in str(e) and not tools: # a server without tools (prompts/resources only)
|
||||
break
|
||||
raise
|
||||
tools += [x for x in page.get("tools") or [] if isinstance(x, dict) and x.get("name")]
|
||||
cursor = page.get("nextCursor")
|
||||
if not cursor:
|
||||
break
|
||||
self.tools, self.status = tools, "ready"
|
||||
names = ", ".join(x["name"] for x in tools[:8]) + (", ..." if len(tools) > 8 else "")
|
||||
print(f"[strata] MCP server {self.name!r}: {len(tools)} tool{'s' * (len(tools) != 1)}"
|
||||
f"{' (' + names + ')' if tools else ''}", flush=True)
|
||||
return True
|
||||
except McpError as e:
|
||||
self.status, self.error, self.tools = "failed", str(e), []
|
||||
t.close()
|
||||
print(f"[strata] MCP server {self.name!r} did not start: {e}. Its tools are left out; the chat works "
|
||||
"without them.", flush=True)
|
||||
return False
|
||||
|
||||
def call(self, tool: str, arguments: dict, timeout: float, cancel=None) -> dict:
|
||||
with self.lock:
|
||||
if self.transport is None or not self.transport.alive():
|
||||
# it stopped since its last call (a crash, an expired session): start it again; one that failed to
|
||||
# start is tried again at most every 10 s, so a broken command does not run on every call
|
||||
if self.status == "failed" and time.monotonic() - self.last_start < 10:
|
||||
raise McpError(f"the server is not running ({self.error or 'it stopped'})")
|
||||
print(f"[strata] MCP server {self.name!r} had stopped; starting it again", flush=True)
|
||||
if not self._start():
|
||||
raise McpError(f"the server could not be started again: {self.error}")
|
||||
t = self.transport
|
||||
try:
|
||||
return t.request("tools/call", {"name": tool, "arguments": arguments}, timeout, cancel)
|
||||
except McpError as e:
|
||||
if not t.alive():
|
||||
self.status, self.error = "stopped", str(e)
|
||||
raise
|
||||
|
||||
def close(self):
|
||||
with self.lock:
|
||||
if self.transport is not None:
|
||||
self.transport.close()
|
||||
if self.status in ("ready", "starting"):
|
||||
self.status = "stopped"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ the hub
|
||||
def result_text(result: dict) -> str:
|
||||
"""An MCP tool result as the text the model reads: the text blocks as they are, other blocks named."""
|
||||
parts = []
|
||||
for block in result.get("content") or []:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
kind = block.get("type")
|
||||
if kind == "text":
|
||||
parts.append(str(block.get("text", "")))
|
||||
elif kind == "resource":
|
||||
res = block.get("resource") or {}
|
||||
parts.append(str(res["text"]) if "text" in res else f"[resource {res.get('uri', '')} "
|
||||
f"({res.get('mimeType', 'binary')}), not shown]")
|
||||
elif kind == "resource_link":
|
||||
parts.append(f"[resource link: {block.get('name') or ''} {block.get('uri', '')}]".replace(" ", " "))
|
||||
elif kind in ("image", "audio"):
|
||||
size = len(block.get("data") or "") * 3 // 4
|
||||
parts.append(f"[{kind} ({block.get('mimeType', '?')}, {size:,} bytes): not shown to the model]")
|
||||
else:
|
||||
parts.append(json.dumps(block, ensure_ascii=False))
|
||||
if not parts and result.get("structuredContent") is not None:
|
||||
parts.append(json.dumps(result["structuredContent"], ensure_ascii=False))
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _clean(name: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9_-]", "_", str(name)) or "x"
|
||||
|
||||
|
||||
class McpHub:
|
||||
"""Every configured server, and the merged, namespaced tool list the model sees."""
|
||||
|
||||
def __init__(self, servers: dict[str, dict], settings: dict | None = None):
|
||||
self.settings = {**DEFAULTS, **(settings or {})}
|
||||
self.servers = {name: McpServer(name, cfg, self.settings) for name, cfg in servers.items()}
|
||||
self.threads: list[threading.Thread] = []
|
||||
self._routes: dict[str, tuple[McpServer, str]] = {}
|
||||
|
||||
def start(self, wait: bool = False):
|
||||
"""Start every server on its own thread (npx may download a package first: that must not delay the chat)."""
|
||||
self.threads = [threading.Thread(target=s.start, daemon=True, name=f"mcp-{s.name}")
|
||||
for s in self.servers.values()]
|
||||
for t in self.threads:
|
||||
t.start()
|
||||
if wait:
|
||||
self.wait(None)
|
||||
|
||||
def wait(self, timeout: float | None) -> None:
|
||||
"""Wait (up to `timeout` s in all) for servers that are still starting."""
|
||||
end = None if timeout is None else time.monotonic() + timeout
|
||||
for t in self.threads:
|
||||
t.join(None if end is None else max(0.0, end - time.monotonic()))
|
||||
|
||||
def routes(self) -> dict[str, tuple[McpServer, str]]:
|
||||
"""tool name the model sees -> (server, the tool's own name). A server that stopped after it was ready keeps
|
||||
its tools here: a call starts it again."""
|
||||
out = {}
|
||||
for s in self.servers.values():
|
||||
if s.status not in ("ready", "stopped"):
|
||||
continue
|
||||
for t in s.tools:
|
||||
name = f"{_clean(s.name)}__{_clean(t['name'])}"[:64]
|
||||
n = 2
|
||||
while name in out: # two names that differ only in cleaned characters
|
||||
name = f"{name[:60]}_{n}"
|
||||
n += 1
|
||||
out[name] = (s, t["name"])
|
||||
self._routes = out
|
||||
return out
|
||||
|
||||
def template_tools(self, exclude=()) -> list[dict]:
|
||||
"""The tools in the chat template's form ({name, description, parameters}); `exclude`: names the request
|
||||
brought itself (those win)."""
|
||||
out = []
|
||||
for name, (s, tool) in self.routes().items():
|
||||
if name in exclude:
|
||||
continue
|
||||
t = next(x for x in s.tools if x["name"] == tool)
|
||||
schema = t.get("inputSchema") if isinstance(t.get("inputSchema"), dict) else {}
|
||||
out.append({"name": name, "description": str(t.get("description") or t.get("title") or ""),
|
||||
"parameters": schema or {"type": "object", "properties": {}}})
|
||||
return out
|
||||
|
||||
def openai_tools(self) -> list[dict]:
|
||||
return [{"type": "function", "function": t} for t in self.template_tools()]
|
||||
|
||||
def call(self, name: str, arguments: dict, cancel: threading.Event | None = None) -> dict:
|
||||
"""Run one tool -> {"ok", "text" (what the model reads, capped), "chars" (its full length), "truncated",
|
||||
"ms", "server", "tool"}. Errors become text starting with "error:"; only McpCancelled is raised."""
|
||||
t0 = time.monotonic()
|
||||
server, tool = self._routes.get(name) or self.routes().get(name, (None, name))
|
||||
out = {"server": server.name if server else None, "tool": tool}
|
||||
if server is None:
|
||||
text, ok = f"error: there is no tool named {name!r}", False
|
||||
else:
|
||||
try:
|
||||
res = server.call(tool, arguments if isinstance(arguments, dict) else {},
|
||||
float(self.settings["timeout_s"]), cancel)
|
||||
text, ok = result_text(res), not res.get("isError")
|
||||
if not ok:
|
||||
text = "error: " + (text or "the tool reported an error")
|
||||
except McpCancelled:
|
||||
raise
|
||||
except McpError as e:
|
||||
text, ok = f"error: {e}", False
|
||||
cap = int(self.settings["max_result_chars"])
|
||||
full = len(text)
|
||||
if cap > 0 and full > cap:
|
||||
text = text[:cap] + (f"\n\n[... truncated: the tool returned {full:,} characters; only the first "
|
||||
f"{cap:,} are shown]")
|
||||
out.update(ok=ok, text=text, chars=full, truncated=cap > 0 and full > cap,
|
||||
ms=int((time.monotonic() - t0) * 1000))
|
||||
return out
|
||||
|
||||
def status(self) -> dict:
|
||||
"""GET /mcp: every server, its state and its tools (for the web app)."""
|
||||
routes = self.routes()
|
||||
servers = []
|
||||
for s in self.servers.values():
|
||||
names = {tool: n for n, (srv, tool) in routes.items() if srv is s}
|
||||
servers.append({"name": s.name, "transport": s.kind, "status": s.status, "error": s.error,
|
||||
"info": s.info,
|
||||
"tools": [{"name": names.get(t["name"], t["name"]), "tool": t["name"],
|
||||
"description": str(t.get("description") or "")[:300]} for t in s.tools]})
|
||||
return {"servers": servers, "tools": len(routes),
|
||||
"settings": {k: self.settings[k] for k in ("timeout_s", "max_result_chars", "max_rounds")}}
|
||||
|
||||
def close(self):
|
||||
for s in self.servers.values():
|
||||
try:
|
||||
s.close()
|
||||
except Exception: # noqa: BLE001 - closing is best effort
|
||||
pass
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ config
|
||||
def _check_server(name, cfg, where) -> dict:
|
||||
if not isinstance(cfg, dict):
|
||||
raise SystemExit(f"[strata] {where}: MCP server {name!r} must be an object with \"command\" or \"url\"")
|
||||
if cfg.get("url"):
|
||||
if cfg.get("type") == "sse":
|
||||
raise SystemExit(f"[strata] {where}: MCP server {name!r} uses the old SSE transport (\"type\": \"sse\"); "
|
||||
"Strata speaks Streamable HTTP - most servers offer it at /mcp")
|
||||
if not isinstance(cfg.get("headers") or {}, dict):
|
||||
raise SystemExit(f"[strata] {where}: MCP server {name!r}: \"headers\" must be an object")
|
||||
return cfg
|
||||
if not isinstance(cfg.get("command"), str) or not cfg["command"].strip():
|
||||
raise SystemExit(f"[strata] {where}: MCP server {name!r} needs \"command\" (a program to start) or \"url\"")
|
||||
if not isinstance(cfg.get("args") or [], list):
|
||||
raise SystemExit(f"[strata] {where}: MCP server {name!r}: \"args\" must be a list")
|
||||
if not isinstance(cfg.get("env") or {}, dict):
|
||||
raise SystemExit(f"[strata] {where}: MCP server {name!r}: \"env\" must be an object")
|
||||
return cfg
|
||||
|
||||
|
||||
def servers_from(block, where: str) -> dict[str, dict]:
|
||||
"""{"name": {...}} with the entries checked; `"disabled": true` leaves one out."""
|
||||
if block is None:
|
||||
return {}
|
||||
if not isinstance(block, dict):
|
||||
raise SystemExit(f"[strata] {where}: the MCP servers must be an object {{\"name\": {{...}}}}")
|
||||
return {str(n): _check_server(n, c, where) for n, c in block.items()
|
||||
if not (isinstance(c, dict) and c.get("disabled") is True)}
|
||||
|
||||
|
||||
def settings_from(cfg: dict) -> dict:
|
||||
"""The run config's optional `"mcp"` block: timeout_s, max_result_chars, max_rounds, start_timeout_s."""
|
||||
out = {}
|
||||
for key, value in (cfg.get("mcp") or {}).items():
|
||||
number = isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
if key in ("timeout_s", "start_timeout_s"):
|
||||
if not number or value <= 0:
|
||||
raise SystemExit(f"[strata] config mcp.{key}={value!r}: expected a number of seconds > 0")
|
||||
out[key] = float(value)
|
||||
elif key in ("max_result_chars", "max_rounds"):
|
||||
if not number or value != int(value) or value < 1:
|
||||
raise SystemExit(f"[strata] config mcp.{key}={value!r}: expected a positive integer")
|
||||
out[key] = int(value)
|
||||
else:
|
||||
print(f"[strata] config mcp.{key}={value!r}: unknown key, ignored", flush=True)
|
||||
return out
|
||||
|
||||
|
||||
def hub_from_config(cfg: dict, mcp_config_path: str | None = None) -> McpHub | None:
|
||||
"""The run config's `"mcp_servers"` (or `"mcpServers"`) plus the servers in the --mcp-config file (Claude
|
||||
Desktop's format: {"mcpServers": {...}}); a name in both takes the file's entry. None when there are none."""
|
||||
servers = {}
|
||||
servers.update(servers_from(cfg.get("mcp_servers"), "config mcp_servers"))
|
||||
servers.update(servers_from(cfg.get("mcpServers"), "config mcpServers"))
|
||||
if mcp_config_path:
|
||||
try:
|
||||
with open(mcp_config_path, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError) as e:
|
||||
raise SystemExit(f"[strata] --mcp-config {mcp_config_path}: {e}") from None
|
||||
block = data.get("mcpServers", data.get("mcp_servers")) if isinstance(data, dict) else None
|
||||
if block is None:
|
||||
raise SystemExit(f"[strata] --mcp-config {mcp_config_path}: expected {{\"mcpServers\": {{...}}}}")
|
||||
servers.update(servers_from(block, f"--mcp-config {mcp_config_path}"))
|
||||
if not servers:
|
||||
return None
|
||||
return McpHub(servers, settings_from(cfg))
|
||||
|
||||
|
||||
if __name__ == "__main__": # python -m serve.mcp config.json: list what a config offers
|
||||
hub = hub_from_config(json.load(open(sys.argv[1], encoding="utf-8-sig")) if len(sys.argv) > 1 else {},
|
||||
sys.argv[2] if len(sys.argv) > 2 else None)
|
||||
if hub is None:
|
||||
sys.exit("no MCP servers configured")
|
||||
hub.start(wait=True)
|
||||
print(json.dumps(hub.status(), indent=1))
|
||||
hub.close()
|
||||
@@ -0,0 +1,172 @@
|
||||
"""serve/mcp_fake_server.py - a small MCP server for serve/test_mcp.py (no MCP SDK, no network beyond localhost).
|
||||
|
||||
python serve/mcp_fake_server.py stdio: JSON-RPC lines on stdin/stdout
|
||||
python serve/mcp_fake_server.py --http 8765 Streamable HTTP on 127.0.0.1:8765/mcp
|
||||
|
||||
Tools: echo (returns its text), fail (a tool error, isError), sleep (answers after `seconds`), big (`n` characters),
|
||||
die (the process exits mid-call), add (two integers). tools/list pages two tools at a time (`--page N`), so the
|
||||
client's pagination is exercised. `--crash-at-start` exits before answering anything. With FAKE_MCP_LOG set, the
|
||||
server appends what it saw (calls, cancellations) to that file, one JSON line each.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
TOOLS = [
|
||||
{"name": "echo", "description": "Returns the text it is given.",
|
||||
"inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}},
|
||||
{"name": "fail", "description": "Always fails.", "inputSchema": {"type": "object", "properties": {}}},
|
||||
{"name": "sleep", "description": "Waits, then answers.",
|
||||
"inputSchema": {"type": "object", "properties": {"seconds": {"type": "number"}}}},
|
||||
{"name": "big", "description": "Returns n characters.",
|
||||
"inputSchema": {"type": "object", "properties": {"n": {"type": "integer"}}}},
|
||||
{"name": "die", "description": "Ends the server.", "inputSchema": {"type": "object", "properties": {}}},
|
||||
{"name": "add", "description": "Adds two integers.",
|
||||
"inputSchema": {"type": "object", "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}}},
|
||||
]
|
||||
PAGE = 2
|
||||
cancelled: set = set()
|
||||
|
||||
|
||||
def log(event: dict):
|
||||
path = os.environ.get("FAKE_MCP_LOG")
|
||||
if path:
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event) + "\n")
|
||||
|
||||
|
||||
def text(t, error=False):
|
||||
return {"content": [{"type": "text", "text": t}], "isError": error}
|
||||
|
||||
|
||||
def handle(msg: dict):
|
||||
"""-> the response for a request, None for a notification."""
|
||||
method, rid, params = msg.get("method"), msg.get("id"), msg.get("params") or {}
|
||||
if "id" not in msg:
|
||||
if method == "notifications/cancelled":
|
||||
cancelled.add(params.get("requestId"))
|
||||
log({"cancelled": params.get("requestId")})
|
||||
return None
|
||||
if method == "initialize":
|
||||
return {"jsonrpc": "2.0", "id": rid, "result": {
|
||||
"protocolVersion": params.get("protocolVersion", "2025-06-18"), "capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "fake", "version": "0.1"}}}
|
||||
if method == "tools/list":
|
||||
start = int(params.get("cursor") or 0)
|
||||
page = {"tools": TOOLS[start:start + PAGE]}
|
||||
if start + PAGE < len(TOOLS):
|
||||
page["nextCursor"] = str(start + PAGE)
|
||||
return {"jsonrpc": "2.0", "id": rid, "result": page}
|
||||
if method == "tools/call":
|
||||
name, args = params.get("name"), params.get("arguments") or {}
|
||||
log({"call": name, "arguments": args})
|
||||
if name == "echo":
|
||||
result = text(str(args.get("text", "")))
|
||||
elif name == "fail":
|
||||
result = text("it failed on purpose", error=True)
|
||||
elif name == "sleep":
|
||||
time.sleep(float(args.get("seconds", 1)))
|
||||
result = text("slept")
|
||||
elif name == "big":
|
||||
result = text("y" * int(args.get("n", 100000)))
|
||||
elif name == "die":
|
||||
sys.stderr.write("dying on purpose\n")
|
||||
sys.stderr.flush()
|
||||
os._exit(3)
|
||||
elif name == "add":
|
||||
result = text(str(int(args.get("a", 0)) + int(args.get("b", 0))))
|
||||
else:
|
||||
return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": f"unknown tool {name}"}}
|
||||
return {"jsonrpc": "2.0", "id": rid, "result": result}
|
||||
return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": f"no method {method}"}}
|
||||
|
||||
|
||||
def stdio():
|
||||
for f in (sys.stdin, sys.stdout, sys.stderr): # MCP's stdio is UTF-8, whatever the Windows code page is
|
||||
f.reconfigure(encoding="utf-8")
|
||||
out = threading.Lock()
|
||||
|
||||
def answer(msg):
|
||||
r = handle(msg)
|
||||
if r is not None and msg.get("id") not in cancelled:
|
||||
with out:
|
||||
sys.stdout.write(json.dumps(r) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
for line in sys.stdin:
|
||||
if not line.strip():
|
||||
continue
|
||||
msg = json.loads(line)
|
||||
if msg.get("method") == "tools/call": # calls on threads: a slow one must not block cancellation
|
||||
threading.Thread(target=answer, args=(msg,), daemon=True).start()
|
||||
else:
|
||||
answer(msg)
|
||||
|
||||
|
||||
def http_server(port: int = 0) -> ThreadingHTTPServer:
|
||||
"""Streamable HTTP at /mcp: JSON answers, except tools/call, which answers as an event stream (a progress
|
||||
notification first); a session id from initialize is required afterwards."""
|
||||
sessions = set()
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.0"
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
msg = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
|
||||
if msg.get("method") != "initialize" and self.headers.get("Mcp-Session-Id") not in sessions:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
return
|
||||
r = handle(msg)
|
||||
if r is None:
|
||||
self.send_response(202)
|
||||
self.end_headers()
|
||||
return
|
||||
if msg.get("method") == "tools/call":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.end_headers()
|
||||
note = {"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progress": 1}}
|
||||
self.wfile.write(b"event: message\ndata: " + json.dumps(note).encode() + b"\n\n")
|
||||
self.wfile.write(b"event: message\ndata: " + json.dumps(r).encode() + b"\n\n")
|
||||
return
|
||||
body = json.dumps(r).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
if msg.get("method") == "initialize":
|
||||
sid = f"s-{len(sessions) + 1}"
|
||||
sessions.add(sid)
|
||||
self.send_header("Mcp-Session-Id", sid)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_DELETE(self):
|
||||
sessions.discard(self.headers.get("Mcp-Session-Id"))
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", port), H)
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
return httpd
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--page" in sys.argv:
|
||||
PAGE = int(sys.argv[sys.argv.index("--page") + 1])
|
||||
if "--crash-at-start" in sys.argv:
|
||||
sys.stderr.write("cannot start: missing configuration\n")
|
||||
sys.exit(2)
|
||||
if "--http" in sys.argv:
|
||||
server = http_server(int(sys.argv[sys.argv.index("--http") + 1]))
|
||||
print(f"listening on http://127.0.0.1:{server.server_address[1]}/mcp", flush=True)
|
||||
threading.Event().wait()
|
||||
stdio()
|
||||
+177
-22
@@ -4,7 +4,9 @@
|
||||
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 /health. One sequence at a time behind a FIFO (plan: one resident sequence).
|
||||
non-stream), GET /v1/models, 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
|
||||
@@ -41,6 +43,7 @@ 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, openai_to_messages)
|
||||
from serve.mcp import McpCancelled, hub_from_config # noqa: E402
|
||||
|
||||
IM_END = "<|im_end|>"
|
||||
IMAGE_PAD = "<|image_pad|>"
|
||||
@@ -54,16 +57,23 @@ class Engine(Protocol):
|
||||
|
||||
|
||||
class MockEngine:
|
||||
"""Replays a scripted completion (text) as token ids, one per step, then the end-of-turn token."""
|
||||
"""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, max_context: int = 32768, delay_s: float = 0.0):
|
||||
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
|
||||
self.script = tokenizer.encode(script, parse_special=True) + tokenizer.encode(IM_END, parse_special=True)
|
||||
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
|
||||
@@ -234,8 +244,9 @@ class StrataEngine:
|
||||
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 1 <= tk <= 64:
|
||||
keys += f" top_k={tk}" # the engine's sampled path takes 1..64; outside it keeps its 20
|
||||
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}"
|
||||
@@ -261,6 +272,13 @@ class StrataEngine:
|
||||
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
|
||||
@@ -505,6 +523,7 @@ class Service:
|
||||
self.totals = {"since": time.time(), "requests": 0, "prompt_tokens": 0, "reused": 0, "output_tokens": 0,
|
||||
"prompt_ms": 0.0, "decode_ms": 0.0}
|
||||
self.status_lock = threading.Lock()
|
||||
self.mcp = None # serve/mcp.py's McpHub when MCP servers are configured
|
||||
self.stop_ids = set(tokenizer.encode(IM_END, parse_special=True) +
|
||||
tokenizer.encode("<|endoftext|>", parse_special=True))
|
||||
|
||||
@@ -787,8 +806,98 @@ def _debug_req(api, req, messages, tools, max_new, thinking, prompt_tokens):
|
||||
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):
|
||||
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())
|
||||
|
||||
def chunk(delta, finish=None):
|
||||
@@ -798,9 +907,13 @@ def openai_chunks(svc: Service, req: dict, ids, thinking, tools, max_new, cancel
|
||||
yield chunk({"role": "assistant", "content": ""})
|
||||
calls = 0
|
||||
streamed = {} # tool call id -> index, for calls sent piece by piece
|
||||
for kind, x in svc.run(ids, thinking, tools, max_new, req, cancel):
|
||||
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:
|
||||
@@ -824,16 +937,19 @@ def openai_chunks(svc: Service, req: dict, ids, thinking, tools, max_new, cancel
|
||||
else:
|
||||
finish = "tool_calls" if calls and x["finish"] == "stop" else {"cancel": "stop"}.get(x["finish"], x["finish"])
|
||||
last = chunk({}, finish)
|
||||
last["usage"] = {"prompt_tokens": len(ids), "completion_tokens": x["completion_tokens"],
|
||||
"total_tokens": len(ids) + x["completion_tokens"]}
|
||||
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"]}
|
||||
yield last
|
||||
|
||||
|
||||
def openai_collect(chunks) -> dict:
|
||||
content, reasoning, by_index, last = [], [], {}, None
|
||||
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 "")
|
||||
@@ -851,6 +967,8 @@ def openai_collect(chunks) -> dict:
|
||||
msg["reasoning_content"] = "".join(reasoning)
|
||||
if calls:
|
||||
msg["tool_calls"] = calls
|
||||
if mcp:
|
||||
msg["strata_mcp"] = mcp
|
||||
return {"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"]}
|
||||
@@ -1016,6 +1134,11 @@ def make_handler(svc: Service):
|
||||
if self._authorized():
|
||||
self._json(200, {"shared": bool(svc.shared), "defaults": svc.shared})
|
||||
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 == "":
|
||||
body = (ROOT / "serve" / "web" / "index.html").read_bytes()
|
||||
self.send_response(200)
|
||||
@@ -1063,17 +1186,22 @@ def make_handler(svc: Service):
|
||||
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"}})
|
||||
|
||||
def _settings(self):
|
||||
# They change what every client gets, so only the app's own page may set them: JSON only (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
|
||||
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||
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
|
||||
return False
|
||||
origin = self.headers.get("Origin")
|
||||
if origin and origin.split("://", 1)[-1] != self.headers.get("Host", ""):
|
||||
self._json(403, {"error": {"message": "settings can only be changed from Strata's own page"}})
|
||||
self._json(403, {"error": {"message": f"{what} only from Strata's own page"}})
|
||||
return False
|
||||
return True
|
||||
|
||||
def _settings(self):
|
||||
# They change what every client gets, so only the app's own page may set them
|
||||
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||
if not self._own_page("settings can be changed"):
|
||||
return
|
||||
try:
|
||||
req = json.loads(body or b"{}")
|
||||
@@ -1094,11 +1222,22 @@ def make_handler(svc: Service):
|
||||
def _openai(self, req):
|
||||
req = svc.with_shared(req, "openai")
|
||||
messages, tools, kw = openai_to_messages(req)
|
||||
max_new = int(req.get("max_completion_tokens") or req.get("max_tokens") or 0) # 0/-1: the rest of the context
|
||||
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 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
|
||||
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()
|
||||
chunks = openai_chunks(svc, req, ids, thinking, tools, max_new, cancel)
|
||||
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 not req.get("stream"):
|
||||
return self._json(200, openai_collect(chunks))
|
||||
self._sse()
|
||||
@@ -1311,7 +1450,9 @@ def main() -> int:
|
||||
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", default="Thinking about it.</think>\n\nHello from the mock engine.")
|
||||
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", type=int, help="the GPU to run on, as nvidia-smi numbers them (also \"gpu\" in the config)")
|
||||
ap.add_argument("--tokenizer", default=str(ROOT / "pack/full/tokenizer"),
|
||||
@@ -1322,6 +1463,9 @@ def main() -> int:
|
||||
"(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)")
|
||||
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:
|
||||
@@ -1347,6 +1491,7 @@ def main() -> int:
|
||||
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")
|
||||
@@ -1364,7 +1509,8 @@ def main() -> int:
|
||||
engine = StrataEngine(cfg["exe"], cfg["args"], cwd=cfg.get("cwd"), log=cfg.get("log"), env=env)
|
||||
warn_tight_ram(engine.info.get("arena_mib"))
|
||||
else:
|
||||
engine, vision, sampling_defaults = MockEngine(tok, a.script), 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"),
|
||||
@@ -1382,6 +1528,13 @@ def main() -> int:
|
||||
", ".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)
|
||||
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, "
|
||||
@@ -1414,6 +1567,8 @@ def main() -> int:
|
||||
engine.close()
|
||||
if vision:
|
||||
vision.close()
|
||||
if hub is not None:
|
||||
hub.close()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
"""serve/test_mcp.py - tools from MCP servers (serve/mcp.py) and the web app's tool loop, against the mock engine and
|
||||
the fake MCP server in serve/mcp_fake_server.py (no GPU, no pack, no MCP SDK).
|
||||
|
||||
python -m unittest serve.test_mcp -v
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from serve import mcp_fake_server as fake # noqa: E402
|
||||
from serve.frontend import ChatTemplate # noqa: E402
|
||||
from serve.mcp import McpCancelled, McpHub, hub_from_config # noqa: E402
|
||||
from serve.server import ByteTokenizer, MockEngine, Service, serve # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FAKE = str(ROOT / "serve" / "mcp_fake_server.py")
|
||||
CTX = 8192
|
||||
|
||||
|
||||
def stdio(*args, **extra):
|
||||
return {"command": sys.executable, "args": [FAKE, *args], **extra}
|
||||
|
||||
|
||||
def call_script(name, **params):
|
||||
"""The model's text for one tool call, as Qwen's template asks for it."""
|
||||
body = "".join(f"<parameter={k}>\n{v}\n</parameter>\n" for k, v in params.items())
|
||||
return f"</think>\n\nLet me check.\n\n<tool_call>\n<function={name}>\n{body}</function>\n</tool_call>"
|
||||
|
||||
|
||||
class ScriptedEngine(MockEngine):
|
||||
"""A mock engine with one script per call (the last one repeats): a tool call first, then the answer."""
|
||||
|
||||
def __init__(self, tok, scripts, max_context=CTX, delay_s=0.0):
|
||||
super().__init__(tok, list(scripts), max_context=max_context, delay_s=delay_s)
|
||||
self.prompts = []
|
||||
|
||||
def generate(self, ids, max_new, sampling, cancel, embeddings=None):
|
||||
self.prompts.append(list(ids))
|
||||
yield from super().generate(ids, max_new, sampling, cancel, embeddings)
|
||||
|
||||
def prompt_text(self, i):
|
||||
return bytes(t for t in self.prompts[i] if t < 256).decode("utf-8", "replace")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ the client
|
||||
class Client(unittest.TestCase):
|
||||
"""The stdio client against the fake server: start, pagination, calls, errors, crashes, timeouts, the cap."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.log = tempfile.NamedTemporaryFile(delete=False, suffix=".jsonl")
|
||||
cls.log.close()
|
||||
os.environ["FAKE_MCP_LOG"] = cls.log.name
|
||||
cls.hub = McpHub({"fake": stdio(), "paged": stdio("--page", "4"), "broken": stdio("--crash-at-start"),
|
||||
"missing": {"command": "strata-no-such-program"}},
|
||||
{"timeout_s": 1.5, "max_result_chars": 1000})
|
||||
cls.hub.start(wait=True)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.hub.close()
|
||||
del os.environ["FAKE_MCP_LOG"]
|
||||
os.unlink(cls.log.name)
|
||||
|
||||
def test_initialize_and_list_every_page(self):
|
||||
s = self.hub.servers["fake"]
|
||||
self.assertEqual(s.status, "ready")
|
||||
self.assertEqual(s.info["name"], "fake")
|
||||
self.assertEqual(s.info["protocol"], "2025-06-18")
|
||||
want = [t["name"] for t in fake.TOOLS]
|
||||
self.assertEqual([t["name"] for t in s.tools], want) # 3 pages of 2
|
||||
self.assertEqual([t["name"] for t in self.hub.servers["paged"].tools], want) # 2 pages of 4
|
||||
|
||||
def test_namespaced_openai_tools(self):
|
||||
tools = self.hub.openai_tools()
|
||||
names = [t["function"]["name"] for t in tools]
|
||||
self.assertIn("fake__echo", names)
|
||||
self.assertIn("paged__echo", names)
|
||||
echo = next(t for t in tools if t["function"]["name"] == "fake__echo")
|
||||
self.assertEqual(echo["type"], "function")
|
||||
self.assertEqual(echo["function"]["parameters"]["properties"]["text"]["type"], "string")
|
||||
|
||||
def test_a_server_that_fails_is_left_out(self):
|
||||
for name, why in (("broken", "missing configuration"), ("missing", "could not start")):
|
||||
with self.subTest(name=name):
|
||||
s = self.hub.servers[name]
|
||||
self.assertEqual(s.status, "failed")
|
||||
self.assertIn(why, s.error)
|
||||
self.assertFalse([t for t in self.hub.template_tools() if t["name"].startswith(name + "__")])
|
||||
st = {s["name"]: s for s in self.hub.status()["servers"]}
|
||||
self.assertEqual(st["broken"]["status"], "failed")
|
||||
self.assertEqual(st["fake"]["status"], "ready")
|
||||
|
||||
def test_call(self):
|
||||
self.hub.routes()
|
||||
r = self.hub.call("fake__echo", {"text": "hello wörld"})
|
||||
self.assertEqual((r["ok"], r["text"], r["server"], r["tool"]), (True, "hello wörld", "fake", "echo"))
|
||||
self.assertEqual(self.hub.call("fake__add", {"a": 2, "b": 40})["text"], "42")
|
||||
|
||||
def test_errors_become_text(self):
|
||||
self.hub.routes()
|
||||
r = self.hub.call("fake__fail", {}) # the tool's own error (isError)
|
||||
self.assertFalse(r["ok"])
|
||||
self.assertEqual(r["text"], "error: it failed on purpose")
|
||||
r = self.hub.call("nobody__nothing", {}) # not a tool at all
|
||||
self.assertTrue(r["text"].startswith("error: there is no tool"))
|
||||
# a JSON-RPC error from the server
|
||||
s = self.hub.servers["fake"]
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
s.call("no-such-tool", {}, 5)
|
||||
self.assertIn("unknown tool", str(ctx.exception))
|
||||
|
||||
def test_timeout(self):
|
||||
self.hub.routes()
|
||||
t0 = time.monotonic()
|
||||
r = self.hub.call("fake__sleep", {"seconds": 5})
|
||||
self.assertLess(time.monotonic() - t0, 4)
|
||||
self.assertFalse(r["ok"])
|
||||
self.assertIn("timed out after 1.5 s", r["text"])
|
||||
self.assertEqual(self.hub.call("fake__echo", {"text": "still here"})["text"], "still here")
|
||||
|
||||
def test_truncation(self):
|
||||
self.hub.routes()
|
||||
r = self.hub.call("fake__big", {"n": 50000})
|
||||
self.assertTrue(r["ok"])
|
||||
self.assertTrue(r["truncated"])
|
||||
self.assertEqual(r["chars"], 50000)
|
||||
self.assertTrue(r["text"].startswith("y" * 1000 + "\n\n[... truncated"))
|
||||
self.assertIn("50,000 characters", r["text"])
|
||||
self.assertLess(len(r["text"]), 1200)
|
||||
|
||||
def test_crash_mid_call_then_restart(self):
|
||||
hub = McpHub({"solo": stdio()}, {"timeout_s": 5})
|
||||
hub.start(wait=True)
|
||||
try:
|
||||
hub.routes()
|
||||
r = hub.call("solo__die", {})
|
||||
self.assertFalse(r["ok"])
|
||||
self.assertIn("the server stopped", r["text"])
|
||||
self.assertIn("dying on purpose", r["text"])
|
||||
self.assertEqual(hub.servers["solo"].status, "stopped")
|
||||
self.assertIn("solo__echo", hub.routes()) # its tools stay: the next call restarts it
|
||||
self.assertEqual(hub.call("solo__echo", {"text": "back"})["text"], "back")
|
||||
self.assertEqual(hub.servers["solo"].status, "ready")
|
||||
finally:
|
||||
hub.close()
|
||||
|
||||
def test_cancel(self):
|
||||
self.hub.routes()
|
||||
cancel = threading.Event()
|
||||
threading.Timer(0.3, cancel.set).start()
|
||||
t0 = time.monotonic()
|
||||
with self.assertRaises(McpCancelled):
|
||||
self.hub.call("fake__sleep", {"seconds": 1.2}, cancel)
|
||||
self.assertLess(time.monotonic() - t0, 1.0)
|
||||
time.sleep(0.3)
|
||||
seen = [json.loads(line) for line in Path(self.log.name).read_text().splitlines()]
|
||||
self.assertTrue(any("cancelled" in e for e in seen), seen) # notifications/cancelled reached the server
|
||||
|
||||
|
||||
class HttpClient(unittest.TestCase):
|
||||
"""Streamable HTTP: JSON answers, an event-stream answer, the session id."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.httpd = fake.http_server(0)
|
||||
url = f"http://127.0.0.1:{cls.httpd.server_address[1]}/mcp"
|
||||
cls.hub = McpHub({"web": {"url": url}, "down": {"url": "http://127.0.0.1:9/mcp"}}, {"timeout_s": 1.5})
|
||||
cls.hub.start(wait=True)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.hub.close()
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
|
||||
def test_list_and_call(self):
|
||||
s = self.hub.servers["web"]
|
||||
self.assertEqual(s.status, "ready", s.error)
|
||||
self.assertEqual(len(s.tools), len(fake.TOOLS)) # pages, with the session id on each request
|
||||
self.assertEqual(s.transport.session, "s-1")
|
||||
self.hub.routes()
|
||||
self.assertEqual(self.hub.call("web__echo", {"text": "over http"})["text"], "over http") # event stream
|
||||
self.assertEqual(self.hub.call("web__fail", {})["text"], "error: it failed on purpose")
|
||||
|
||||
def test_timeout_and_unreachable(self):
|
||||
self.hub.routes()
|
||||
r = self.hub.call("web__sleep", {"seconds": 4})
|
||||
self.assertIn("timed out", r["text"])
|
||||
self.assertEqual(self.hub.servers["down"].status, "failed")
|
||||
self.assertIn("could not reach", self.hub.servers["down"].error)
|
||||
|
||||
|
||||
class Config(unittest.TestCase):
|
||||
def test_both_spellings_and_the_file(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "claude_desktop_config.json")
|
||||
Path(path).write_text(json.dumps({"mcpServers": {"b": {"url": "http://x/mcp"}, "a": stdio()}}),
|
||||
encoding="utf-8")
|
||||
hub = hub_from_config({"mcp_servers": {"a": {"command": "old"}},
|
||||
"mcpServers": {"c": stdio(), "off": {"command": "x", "disabled": True}},
|
||||
"mcp": {"timeout_s": 5, "max_result_chars": 100, "max_rounds": 3}}, path)
|
||||
self.assertEqual(sorted(hub.servers), ["a", "b", "c"])
|
||||
self.assertEqual(hub.servers["a"].cfg["command"], sys.executable) # the file wins a name clash
|
||||
self.assertEqual(hub.servers["b"].kind, "http")
|
||||
self.assertEqual((hub.settings["timeout_s"], hub.settings["max_result_chars"], hub.settings["max_rounds"]),
|
||||
(5.0, 100, 3))
|
||||
self.assertIsNone(hub_from_config({}))
|
||||
self.assertEqual(McpHub({}).settings["max_result_chars"], 20000)
|
||||
|
||||
def test_bad_entries_stop_the_start(self):
|
||||
for cfg in ({"mcp_servers": {"x": {}}}, {"mcp_servers": {"x": {"command": "a", "args": "b"}}},
|
||||
{"mcp_servers": ["x"]}, {"mcpServers": {"x": {"url": "http://a", "type": "sse"}}},
|
||||
{"mcp_servers": {"x": {"command": "a"}}, "mcp": {"timeout_s": 0}},
|
||||
{"mcp_servers": {"x": {"command": "a"}}, "mcp": {"max_rounds": 1.5}}):
|
||||
with self.subTest(cfg=cfg), self.assertRaises(SystemExit):
|
||||
hub_from_config(cfg)
|
||||
with self.assertRaises(SystemExit):
|
||||
hub_from_config({}, os.path.join(tempfile.gettempdir(), "strata-no-such-mcp.json"))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ the tool loop
|
||||
class ToolLoop(unittest.TestCase):
|
||||
"""The web app's chat (`"strata_mcp": true`): the model calls an MCP tool, the server runs it and the model
|
||||
answers with its result; plain API requests never see the MCP tools."""
|
||||
|
||||
def setUp(self):
|
||||
self.log = tempfile.NamedTemporaryFile(delete=False, suffix=".jsonl")
|
||||
self.log.close()
|
||||
os.environ["FAKE_MCP_LOG"] = self.log.name
|
||||
self.hub = McpHub({"fake": stdio(), "broken": stdio("--crash-at-start")},
|
||||
{"timeout_s": 10, "max_result_chars": 500, "max_rounds": 2})
|
||||
self.hub.start(wait=True)
|
||||
self.httpd = None
|
||||
|
||||
def tearDown(self):
|
||||
if self.httpd:
|
||||
self.httpd.shutdown()
|
||||
self.httpd.server_close()
|
||||
self.hub.close()
|
||||
del os.environ["FAKE_MCP_LOG"]
|
||||
os.unlink(self.log.name)
|
||||
|
||||
def start(self, *scripts, delay_s=0.0):
|
||||
tok = ByteTokenizer()
|
||||
self.engine = ScriptedEngine(tok, list(scripts), delay_s=delay_s)
|
||||
self.svc = Service(self.engine, tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
|
||||
self.svc.mcp = self.hub
|
||||
self.httpd = serve(self.svc, port=0)
|
||||
self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
|
||||
|
||||
def post(self, body, headers=None, stream=True):
|
||||
body = {"model": "m", "messages": [{"role": "user", "content": "check it"}], "stream": stream, **body}
|
||||
req = urllib.request.Request(self.base + "/v1/chat/completions", data=json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json", **(headers or {})})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
text = r.read().decode()
|
||||
return r.status, text
|
||||
except urllib.error.HTTPError as e:
|
||||
with e:
|
||||
return e.code, e.read().decode()
|
||||
|
||||
@staticmethod
|
||||
def chunks(text):
|
||||
return [json.loads(line[6:]) for line in text.splitlines() if line.startswith("data: {")]
|
||||
|
||||
def test_tool_call_then_answer(self):
|
||||
self.start(call_script("fake__echo", text="hello from the tool"), "</think>\n\nThe tool said hello.")
|
||||
code, text = self.post({"strata_mcp": True})
|
||||
self.assertEqual(code, 200, text)
|
||||
cs = self.chunks(text)
|
||||
mcp = [c["strata_mcp"] for c in cs if "strata_mcp" in c]
|
||||
self.assertEqual([m["event"] for m in mcp], ["start", "call", "result"])
|
||||
self.assertEqual(mcp[1]["arguments"], {"text": "hello from the tool"})
|
||||
self.assertEqual((mcp[1]["server"], mcp[1]["tool"]), ("fake", "echo"))
|
||||
self.assertEqual((mcp[2]["ok"], mcp[2]["text"]), (True, "hello from the tool"))
|
||||
self.assertEqual(len({m["id"] for m in mcp}), 1)
|
||||
content = "".join((c["choices"][0]["delta"].get("content") or "") for c in cs)
|
||||
self.assertEqual(content, "Let me check.The tool said hello.")
|
||||
self.assertFalse([c for c in cs if c["choices"][0]["delta"].get("tool_calls")]) # nothing for the client to run
|
||||
self.assertEqual(cs[-1]["choices"][0]["finish_reason"], "stop")
|
||||
self.assertEqual(len(self.engine.prompts), 2)
|
||||
first, second = self.engine.prompt_text(0), self.engine.prompt_text(1)
|
||||
self.assertIn('"name": "fake__echo"', first) # the MCP tools are in the prompt
|
||||
self.assertNotIn("broken__", first) # the server that failed is left out
|
||||
self.assertIn("<function=fake__echo>\n<parameter=text>\nhello from the tool\n</parameter>", second)
|
||||
self.assertIn("<tool_response>\nhello from the tool\n</tool_response>", second)
|
||||
self.assertEqual(cs[-1]["usage"]["prompt_tokens"], len(self.engine.prompts[1]))
|
||||
|
||||
def test_non_stream(self):
|
||||
self.start(call_script("fake__add", a=2, b=3), "</think>\n\n5.")
|
||||
code, text = self.post({"strata_mcp": True}, stream=False)
|
||||
self.assertEqual(code, 200, text)
|
||||
msg = json.loads(text)["choices"][0]["message"]
|
||||
self.assertEqual(msg["content"], "Let me check.5.")
|
||||
self.assertEqual(msg["strata_mcp"][-1]["text"], "5")
|
||||
|
||||
def test_plain_requests_get_no_mcp_tools(self):
|
||||
self.start(call_script("fake__echo", text="x"), "</think>\n\nnever")
|
||||
code, text = self.post({})
|
||||
self.assertEqual(code, 200, text)
|
||||
self.assertNotIn("fake__echo", self.engine.prompt_text(0))
|
||||
cs = self.chunks(text)
|
||||
self.assertFalse([c for c in cs if "strata_mcp" in c])
|
||||
self.assertEqual(cs[-1]["choices"][0]["finish_reason"], "tool_calls") # returned to the client as always
|
||||
self.assertEqual(len(self.engine.prompts), 1)
|
||||
self.assertNotIn('"call"', Path(self.log.name).read_text()) # nothing ran
|
||||
for req in ({"strata_mcp": "yes"}, {"strata_mcp": 1}): # only a real true opts in
|
||||
self.post(req)
|
||||
self.assertNotIn("fake__echo", self.engine.prompt_text(-1))
|
||||
|
||||
def test_own_tools_still_go_to_the_client(self):
|
||||
own = [{"type": "function", "function": {"name": "lookup", "parameters": {"type": "object", "properties": {
|
||||
"q": {"type": "string"}}}}}]
|
||||
self.start(call_script("lookup", q="x"), "</think>\n\nnever")
|
||||
code, text = self.post({"strata_mcp": True, "tools": own})
|
||||
cs = self.chunks(text)
|
||||
calls = [tc for c in cs for tc in c["choices"][0]["delta"].get("tool_calls") or []]
|
||||
self.assertEqual(calls[0]["function"]["name"], "lookup")
|
||||
self.assertEqual(cs[-1]["choices"][0]["finish_reason"], "tool_calls")
|
||||
self.assertIn('"name": "lookup"', self.engine.prompt_text(0))
|
||||
self.assertIn('"name": "fake__echo"', self.engine.prompt_text(0))
|
||||
self.assertEqual(len(self.engine.prompts), 1)
|
||||
|
||||
def test_tool_error_is_a_result(self):
|
||||
self.start(call_script("fake__fail"), "</think>\n\nIt failed.")
|
||||
code, text = self.post({"strata_mcp": True})
|
||||
mcp = [c["strata_mcp"] for c in self.chunks(text) if "strata_mcp" in c]
|
||||
self.assertEqual((mcp[-1]["ok"], mcp[-1]["text"]), (False, "error: it failed on purpose"))
|
||||
self.assertIn("<tool_response>\nerror: it failed on purpose\n</tool_response>", self.engine.prompt_text(1))
|
||||
self.assertEqual(self.chunks(text)[-1]["choices"][0]["finish_reason"], "stop")
|
||||
|
||||
def test_truncated_for_the_model(self):
|
||||
self.start(call_script("fake__big", n=3000), "</think>\n\nLong.")
|
||||
self.post({"strata_mcp": True})
|
||||
second = self.engine.prompt_text(1)
|
||||
self.assertIn("y" * 500 + "\n\n[... truncated: the tool returned 3,000 characters", second)
|
||||
self.assertNotIn("y" * 501, second)
|
||||
|
||||
def test_max_rounds(self):
|
||||
self.start(call_script("fake__echo", text="again")) # the model never stops calling
|
||||
code, text = self.post({"strata_mcp": True})
|
||||
mcp = [c["strata_mcp"] for c in self.chunks(text) if "strata_mcp" in c]
|
||||
self.assertEqual(len(self.engine.prompts), 3) # 2 rounds of tools, then the limit
|
||||
self.assertEqual(sum(m["event"] == "call" for m in mcp), 2)
|
||||
self.assertIn({"event": "limit", "max_rounds": 2}, mcp)
|
||||
self.assertTrue(mcp[-1].get("skipped"))
|
||||
self.assertEqual(self.chunks(text)[-1]["choices"][0]["finish_reason"], "stop")
|
||||
|
||||
def test_stop_during_a_tool(self):
|
||||
"""Closing the connection while a slow tool runs stops the tool (notifications/cancelled) and the loop."""
|
||||
self.start(call_script("fake__sleep", seconds=8), "</think>\n\nnever")
|
||||
body = {"model": "m", "messages": [{"role": "user", "content": "x"}], "stream": True, "strata_mcp": True}
|
||||
req = urllib.request.Request(self.base + "/v1/chat/completions", data=json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
r = urllib.request.urlopen(req, timeout=30)
|
||||
for raw in r:
|
||||
if b'"event": "call"' in raw:
|
||||
break
|
||||
r.close() # the web app's Stop aborts the fetch
|
||||
t0 = time.monotonic()
|
||||
while time.monotonic() - t0 < 6 and "cancelled" not in Path(self.log.name).read_text():
|
||||
time.sleep(0.1)
|
||||
self.assertIn("cancelled", Path(self.log.name).read_text())
|
||||
self.assertLess(time.monotonic() - t0, 6)
|
||||
time.sleep(0.5)
|
||||
self.assertEqual(len(self.engine.prompts), 1) # no second round
|
||||
self.assertFalse(self.svc.status["busy"])
|
||||
|
||||
def test_only_from_the_app_s_own_page(self):
|
||||
self.start("</think>\n\nhi")
|
||||
req = urllib.request.Request(self.base + "/v1/chat/completions", data=json.dumps(
|
||||
{"model": "m", "messages": [{"role": "user", "content": "x"}], "strata_mcp": True}).encode(),
|
||||
headers={"Content-Type": "text/plain"}) # a cross-site "simple" request
|
||||
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
self.assertEqual(ctx.exception.code, 415)
|
||||
code, _ = self.post({"strata_mcp": True}, {"Origin": "http://evil.example"})
|
||||
self.assertEqual(code, 403)
|
||||
self.assertEqual(self.engine.prompts, [])
|
||||
host = self.base.split("://", 1)[1]
|
||||
code, _ = self.post({"strata_mcp": True}, {"Origin": "http://" + host})
|
||||
self.assertEqual(code, 200)
|
||||
|
||||
def test_get_mcp(self):
|
||||
self.start("</think>\n\nhi")
|
||||
with urllib.request.urlopen(self.base + "/mcp", timeout=10) as r:
|
||||
st = json.loads(r.read())
|
||||
servers = {s["name"]: s for s in st["servers"]}
|
||||
self.assertEqual(servers["fake"]["status"], "ready")
|
||||
self.assertEqual(servers["fake"]["transport"], "stdio")
|
||||
self.assertIn("fake__echo", [t["name"] for t in servers["fake"]["tools"]])
|
||||
self.assertEqual(servers["broken"]["status"], "failed")
|
||||
self.assertIn("missing configuration", servers["broken"]["error"])
|
||||
self.assertEqual(st["tools"], len(fake.TOOLS))
|
||||
self.assertEqual(st["settings"]["max_rounds"], 2)
|
||||
self.svc.api_key = "k"
|
||||
with self.assertRaises(urllib.error.HTTPError):
|
||||
urllib.request.urlopen(self.base + "/mcp", timeout=10)
|
||||
self.svc.api_key = ""
|
||||
|
||||
|
||||
class NoServers(unittest.TestCase):
|
||||
def test_opt_in_without_servers_is_a_plain_chat(self):
|
||||
tok = ByteTokenizer()
|
||||
svc = Service(ScriptedEngine(tok, ["</think>\n\nhi"]), tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
|
||||
httpd = serve(svc, port=0)
|
||||
try:
|
||||
base = f"http://127.0.0.1:{httpd.server_address[1]}"
|
||||
with urllib.request.urlopen(base + "/mcp", timeout=10) as r:
|
||||
self.assertEqual(json.loads(r.read()), {"servers": [], "tools": 0})
|
||||
req = urllib.request.Request(base + "/v1/chat/completions", data=json.dumps(
|
||||
{"model": "m", "messages": [{"role": "user", "content": "x"}], "strata_mcp": True}).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
self.assertEqual(json.loads(r.read())["choices"][0]["message"]["content"], "hi")
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+29
-1
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from serve.frontend import ChatTemplate # noqa: E402
|
||||
from serve.server import CTX_SLACK, ByteTokenizer, EngineDied, MockEngine, Service, serve # noqa: E402
|
||||
from serve.server import CTX_SLACK, ByteTokenizer, EngineDied, MockEngine, Service, StrataEngine, serve # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CTX = 4096
|
||||
@@ -236,6 +236,34 @@ class ClientShapes(unittest.TestCase):
|
||||
self.assertEqual([m["role"] for m in msgs], ["system", "user"])
|
||||
|
||||
|
||||
class SamplingKeys(unittest.TestCase):
|
||||
"""The GEN line's sampling keys: top_k 0 ("off") or wider than the engine's 64 get the widest list, 64 (they used
|
||||
to fall back to the engine default 20); a penalty always carries its window."""
|
||||
|
||||
def keys(self, **sampling):
|
||||
return StrataEngine.sampling_keys(sampling).split()
|
||||
|
||||
def test_top_k(self):
|
||||
self.assertIn("top_k=10", self.keys(temperature=0.7, top_k=10))
|
||||
self.assertIn("top_k=64", self.keys(temperature=0.7, top_k=64))
|
||||
self.assertIn("top_k=64", self.keys(temperature=0.7, top_k=0))
|
||||
self.assertIn("top_k=64", self.keys(temperature=0.7, top_k=100))
|
||||
for bad in (-1, True, 2.5, "20"):
|
||||
self.assertFalse([k for k in self.keys(temperature=0.7, top_k=bad) if k.startswith("top_k=")], bad)
|
||||
|
||||
def test_tune_keys(self):
|
||||
k = self.keys(temperature=0, strata_tune={"pcie_frac": 0.2, "spec_min_p": 0.7})
|
||||
self.assertIn("pcie_frac=0.2", k)
|
||||
self.assertIn("spec_min_p=0.7", k)
|
||||
bad = self.keys(strata_tune={"pcie_frac": 3, "spec_min_p": True, "pool_workers": 2})
|
||||
self.assertFalse([x for x in bad if x.split("=")[0] in ("pcie_frac", "spec_min_p", "pool_workers")])
|
||||
|
||||
def test_penalty_window(self):
|
||||
self.assertIn("penalty_last_n=64", self.keys(presence_penalty=1.5))
|
||||
self.assertIn("penalty_last_n=4096", self.keys(repetition_penalty=1.1, penalty_last_n=4096))
|
||||
self.assertFalse([k for k in self.keys(temperature=0.7) if k.startswith("penalty")])
|
||||
|
||||
|
||||
class GpuChoice(unittest.TestCase):
|
||||
"""Issue #51: the config's \"gpu\" reaches the engine as CUDA_VISIBLE_DEVICES, numbered like nvidia-smi."""
|
||||
|
||||
|
||||
@@ -51,6 +51,18 @@ main { flex: 1; min-height: 0; display: flex; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
.msg-error { color: var(--st-danger-text); background: var(--st-danger-tint); border-radius: var(--st-r-md); padding: 10px 14px;
|
||||
font-size: var(--st-fs-md); }
|
||||
/* MCP tool calls inside an answer: one compact collapsible block per call */
|
||||
.st-bubble .tool-call { margin: var(--st-s-3) 0; }
|
||||
.tool-call > summary { min-width: 0; padding: 8px 12px; }
|
||||
.tool-call__name { font: 500 var(--st-fs-sm)/1.2 var(--st-font-mono); color: var(--st-ink); white-space: nowrap; }
|
||||
.tool-call__preview { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--st-fs-xs); }
|
||||
.tool-call .st-badge, .tool-call summary .small { flex: none; }
|
||||
.tool-call[data-state="error"] { border-color: color-mix(in srgb, var(--st-danger) 35%, var(--st-line)); }
|
||||
.tool-call[data-state="running"] .st-badge::before, .tool-call[data-state="writing"] .st-badge::before { animation: blink 1s steps(2) infinite; }
|
||||
.tool-call__label { font-size: var(--st-fs-xs); font-weight: var(--st-fw-medium); color: var(--st-ink-muted); margin: var(--st-s-2) 0 var(--st-s-1); }
|
||||
.tool-call__pre { margin: 0; padding: 10px 12px; max-height: 320px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere;
|
||||
font: 400 12.5px/1.5 var(--st-font-mono); color: var(--st-ink-soft); background: var(--st-surface);
|
||||
border: 1px solid var(--st-line-soft); border-radius: var(--st-r-sm); }
|
||||
|
||||
.composer-wrap { flex: none; padding: 0 var(--st-s-6) var(--st-s-6); }
|
||||
.st-composer { max-width: 860px; margin: 0 auto; }
|
||||
@@ -98,6 +110,13 @@ main { flex: 1; min-height: 0; display: flex; }
|
||||
.table-wrap.all { max-height: 420px; overflow-y: auto; }
|
||||
.req-totals { margin: 0; font-size: var(--st-fs-sm); }
|
||||
.req-totals:empty { display: none; }
|
||||
.mcp-card { display: flex; flex-direction: column; gap: var(--st-s-3); }
|
||||
.mcp-server { display: flex; flex-direction: column; gap: var(--st-s-2); padding-top: var(--st-s-3); border-top: 1px solid var(--st-line-soft); }
|
||||
.mcp-server:first-child { padding-top: 0; border-top: 0; }
|
||||
.mcp-server__head { display: flex; align-items: center; gap: var(--st-s-2); flex-wrap: wrap; font-size: var(--st-fs-sm); }
|
||||
.mcp-server__tools { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.mcp-server__tools .chip { height: 26px; padding: 0 10px; font-family: var(--st-font-mono); }
|
||||
.mcp-note { margin: 0; }
|
||||
|
||||
/* ---------------------------------------------------------------- about */
|
||||
.about { max-width: 860px; }
|
||||
|
||||
+147
-15
@@ -78,6 +78,7 @@ function showTab(name) {
|
||||
for (const v of ["chat", "monitor", "about"]) $(`view-${v}`).hidden = v !== tab;
|
||||
if (location.hash.slice(1) !== tab) history.replaceState(null, "", tab === "chat" ? location.pathname : `#${tab}`);
|
||||
if (tab === "chat") $("input").focus();
|
||||
if (tab === "monitor") loadMcp();
|
||||
if (lastMetrics) render(lastMetrics);
|
||||
}
|
||||
for (const b of document.querySelectorAll(".st-tab")) b.onclick = () => showTab(b.dataset.tab);
|
||||
@@ -142,7 +143,7 @@ function setMetric(key, value, unit, sub) {
|
||||
$(`ms-${key}`).textContent = sub || "";
|
||||
}
|
||||
|
||||
let lastMetrics = null, metricsFailures = 0, keyWarned = false;
|
||||
let lastMetrics = null, metricsFailures = 0, keyWarned = false, mcpTick = 0;
|
||||
let reqShowAll = false; // the Monitor's request table: the last 12, or every one the server keeps (issue #35)
|
||||
async function poll() {
|
||||
try {
|
||||
@@ -160,6 +161,7 @@ async function poll() {
|
||||
} catch (e) {
|
||||
if (++metricsFailures === 3) setPill("error", "Server not reachable");
|
||||
}
|
||||
if (tab === "monitor" && ++mcpTick % 10 === 0) loadMcp(); // server states change rarely: every 10 s
|
||||
setTimeout(poll, 1000);
|
||||
}
|
||||
|
||||
@@ -338,6 +340,41 @@ document.addEventListener("click", (e) => {
|
||||
});
|
||||
$("req-all").addEventListener("click", () => { reqShowAll = !reqShowAll; if (lastMetrics) render(lastMetrics); });
|
||||
|
||||
// ------------------------------------------------------------------ MCP servers (GET /mcp)
|
||||
// Tools from the MCP servers in the run config: the chat offers them to the model (opt-in per request,
|
||||
// "strata_mcp": true, which only this page sends); the Monitor lists the servers and what they offer.
|
||||
let mcpInfo = {servers: [], tools: 0}, mcpRetry = null;
|
||||
async function loadMcp() {
|
||||
try {
|
||||
const r = await fetch("/mcp", {headers: headers()});
|
||||
if (!r.ok) return;
|
||||
mcpInfo = await r.json();
|
||||
} catch (e) { return; /* an older server: no MCP */ }
|
||||
renderMcp();
|
||||
clearTimeout(mcpRetry); // right after the start, servers may still be starting (npx downloads)
|
||||
if ((mcpInfo.servers || []).some((s) => s.status === "starting")) mcpRetry = setTimeout(loadMcp, 3000);
|
||||
}
|
||||
const MCP_STATE = {ready: ["st-badge--generating", "Connected"], starting: ["st-badge--reading", "Starting"],
|
||||
failed: ["st-badge--error", "Failed"], stopped: ["st-badge--queued", "Stopped"], idle: ["", "Waiting"]};
|
||||
function renderMcp() {
|
||||
const servers = mcpInfo.servers || [];
|
||||
$("mcp-card").hidden = !servers.length;
|
||||
$("mcp-row").hidden = !servers.length;
|
||||
const ready = servers.filter((s) => s.status === "ready" || s.status === "stopped");
|
||||
$("mcp-sum").textContent = servers.length ? `${fmt(mcpInfo.tools)} tools · ${ready.length} of ${servers.length} servers connected` : "";
|
||||
$("mcp-row-sub").textContent = mcpInfo.tools ? `${fmt(mcpInfo.tools)} tools from ${ready.map((s) => s.name).join(", ")}; the model calls them when it decides to`
|
||||
: "no server is connected yet (see the Monitor)";
|
||||
$("mcp-list").innerHTML = servers.map((s) => {
|
||||
const [cls, text] = MCP_STATE[s.status] || ["", s.status];
|
||||
const info = s.info && s.info.name ? ` · ${s.info.name}${s.info.version ? ` ${s.info.version}` : ""}` : "";
|
||||
return `<div class="mcp-server"><div class="mcp-server__head"><span class="st-badge ${cls}">${esc(text)}</span>` +
|
||||
`<strong>${esc(s.name)}</strong><span class="muted small">${esc(s.transport)} · ${fmt(s.tools.length)} tools${esc(info)}</span></div>` +
|
||||
(s.error ? `<div class="msg-error">${esc(s.error)}</div>` : "") +
|
||||
(s.tools.length ? `<div class="mcp-server__tools">${s.tools.map((t) => `<span class="chip" title="${esc(t.description || "")}">${esc(t.tool)}</span>`).join("")}</div>` : "") +
|
||||
`</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Markdown (escaped first, then formatted)
|
||||
function inline(s) {
|
||||
const codes = [];
|
||||
@@ -405,7 +442,7 @@ function markdown(text) {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Chat
|
||||
const DEFAULTS = {thinking: "high", temperature: 0.6, top_p: 0.95, top_k: 20, max: "", seed: "", show: true, esp: true};
|
||||
const DEFAULTS = {thinking: "high", temperature: 0.6, top_p: 0.95, top_k: 20, max: "", seed: "", show: true, esp: true, mcp: true};
|
||||
let settings = {...DEFAULTS, ...store.get("sampling", {})};
|
||||
let messages = store.get("chat", []);
|
||||
let attachments = []; // {name, url}
|
||||
@@ -452,7 +489,7 @@ function msgEl(m, i) {
|
||||
meta.textContent = `You · ${timeStr(m.time)}`;
|
||||
el.appendChild(meta);
|
||||
} else {
|
||||
el.innerHTML = `<details class="st-collapse" hidden><summary>${icon("thinking", "st-icon st-icon--sm")}<span class="think-title"></span>` +
|
||||
el.innerHTML = `<details class="st-collapse think" hidden><summary>${icon("thinking", "st-icon st-icon--sm")}<span class="think-title"></span>` +
|
||||
`${icon("chevron", "st-icon st-icon--sm st-chev")}</summary><div class="st-collapse__body thinking"></div></details>` +
|
||||
`<div class="st-bubble"></div><div class="st-msg__meta"><span class="meta-text"></span>` +
|
||||
`<button class="st-btn st-btn--icon" data-msg-copy aria-label="Copy the answer" title="Copy">${icon("copy")}</button></div>`;
|
||||
@@ -460,8 +497,56 @@ function msgEl(m, i) {
|
||||
}
|
||||
return el;
|
||||
}
|
||||
// One MCP tool call in the answer: a compact block (name, state, a one-line preview) that opens to the arguments and
|
||||
// the result as the model read it. Its body is built only while open: a result can be 20,000 characters.
|
||||
const TOOL_STATE = {writing: ["st-badge--reading", "Writing"], running: ["st-badge--generating", "Running"], done: ["", "Done"],
|
||||
error: ["st-badge--error", "Error"], skipped: ["st-badge--queued", "Not run"]};
|
||||
function toolHtml(t, k) {
|
||||
const [cls, label] = TOOL_STATE[t.state] || ["", t.state];
|
||||
const args = t.arguments == null ? "" : JSON.stringify(t.arguments, null, 2);
|
||||
const preview = t.result != null ? t.result : args.replace(/\s+/g, " ");
|
||||
let body = "";
|
||||
if (t.open) {
|
||||
body = `<div class="tool-call__label">Arguments</div><pre class="tool-call__pre">${esc(args || "(being written)")}</pre>`;
|
||||
if (t.result != null) {
|
||||
body += `<div class="tool-call__label">${t.ok ? "Result" : "Error"}${t.chars ? ` · ${fmt(t.chars)} characters` : ""}` +
|
||||
`${t.truncated ? ", cut for the model" : ""}</div><pre class="tool-call__pre">${esc(t.result)}</pre>`;
|
||||
}
|
||||
}
|
||||
return `<details class="st-collapse tool-call" data-tool="${k}" data-state="${esc(t.state)}"${t.open ? " open" : ""}>` +
|
||||
`<summary>${icon("tool", "st-icon st-icon--sm")}<span class="tool-call__name" title="${esc(t.name || "")}">${esc(t.tool || t.name || "tool")}</span>` +
|
||||
(t.server ? `<span class="muted small">${esc(t.server)}</span>` : "") +
|
||||
`<span class="tool-call__preview muted">${esc(preview.slice(0, 200))}</span>` +
|
||||
`<span class="st-badge ${cls}">${esc(label)}</span>${t.ms != null && t.state !== "skipped" ? `<span class="muted small">${fmt(t.ms / 1000, 1)} s</span>` : ""}` +
|
||||
`${icon("chevron", "st-icon st-icon--sm st-chev")}</summary><div class="st-collapse__body">${body}</div></details>`;
|
||||
}
|
||||
// the answer's text with the tool blocks where the model called them
|
||||
function answerHtml(m) {
|
||||
if (!m.tools || !m.tools.length) return markdown(m.text || "");
|
||||
let html = "", pos = 0;
|
||||
m.tools.forEach((t, k) => {
|
||||
const at = Math.min(Math.max(t.at || 0, pos), m.text.length);
|
||||
if (at > pos) html += markdown(m.text.slice(pos, at));
|
||||
pos = at;
|
||||
html += toolHtml(t, k);
|
||||
});
|
||||
return html + markdown(m.text.slice(pos));
|
||||
}
|
||||
// a tool event from the stream (the `strata_mcp` field of a chunk)
|
||||
function onTool(m, x) {
|
||||
if (x.event === "limit") { m.limit = x.max_rounds; return; }
|
||||
m.tools = m.tools || [];
|
||||
let t = m.tools.find((y) => y.id === x.id);
|
||||
if (!t) { t = {id: x.id, name: x.name, at: m.text.length, rat: m.reasoning.length, state: "writing"}; m.tools.push(t); }
|
||||
if (x.event === "call") {
|
||||
Object.assign(t, {name: x.name, server: x.server, tool: x.tool, arguments: x.arguments, round: x.round, state: "running"});
|
||||
} else if (x.event === "result") {
|
||||
Object.assign(t, {result: x.text, ok: x.ok, chars: x.chars, truncated: x.truncated, ms: x.ms,
|
||||
state: x.skipped ? "skipped" : x.ok ? "done" : "error"});
|
||||
}
|
||||
}
|
||||
function updateAssistant(el, m, streaming) {
|
||||
const det = el.querySelector("details");
|
||||
const det = el.querySelector("details.think");
|
||||
if (m.reasoning) {
|
||||
det.hidden = false;
|
||||
const thinkingNow = streaming && !m.text;
|
||||
@@ -478,10 +563,10 @@ function updateAssistant(el, m, streaming) {
|
||||
if (m.error) {
|
||||
bubble.innerHTML = `<div class="msg-error"></div>`;
|
||||
bubble.firstChild.textContent = m.error;
|
||||
} else if (!m.text && streaming) {
|
||||
} else if (!m.text && streaming && !(m.tools && m.tools.length)) {
|
||||
bubble.innerHTML = m.reasoning ? `<span class="muted cursor">Writing</span>` : `<span class="cursor"></span>`;
|
||||
} else {
|
||||
bubble.innerHTML = markdown(m.text || "");
|
||||
bubble.innerHTML = answerHtml(m);
|
||||
if (streaming) bubble.classList.add("cursor"); else bubble.classList.remove("cursor");
|
||||
}
|
||||
el.querySelector(".meta-text").textContent = m.meta || (streaming ? "" : m.stopped ? "Stopped" : "");
|
||||
@@ -501,11 +586,20 @@ $("chat").addEventListener("click", (e) => {
|
||||
const cc = e.target.closest("[data-code-copy]");
|
||||
if (cc) { copyText(cc.closest(".st-code").querySelector("pre").textContent, cc); return; }
|
||||
const mc = e.target.closest("[data-msg-copy]");
|
||||
if (mc) { const i = +mc.closest(".st-msg").dataset.i; copyText(messages[i].text, mc); }
|
||||
if (mc) { const i = +mc.closest(".st-msg").dataset.i; copyText(messages[i].text, mc); return; }
|
||||
// a tool block: its open state lives in the message (the answer is rebuilt while it streams), so the click sets it
|
||||
const sum = e.target.closest(".tool-call > summary");
|
||||
if (sum) {
|
||||
e.preventDefault();
|
||||
const el = sum.closest(".st-msg"), m = messages[+el.dataset.i], t = m && m.tools && m.tools[+sum.parentElement.dataset.tool];
|
||||
if (!t) return;
|
||||
t.open = !t.open;
|
||||
updateAssistant(el, m, !!busy && busy.msg === m);
|
||||
}
|
||||
});
|
||||
$("chat").addEventListener("toggle", (e) => {
|
||||
const d = e.target;
|
||||
if (d.tagName !== "DETAILS") return;
|
||||
if (d.tagName !== "DETAILS" || !d.classList.contains("think")) return;
|
||||
if (d._auto) { d._auto = false; return; } // our own open/close, not the user's
|
||||
d.dataset.touched = "1";
|
||||
const body = d.querySelector(".thinking");
|
||||
@@ -520,12 +614,31 @@ function apiMessages() {
|
||||
const text = userText(m);
|
||||
out.push({role: "user", content: imgs.length ? [{type: "text", text},
|
||||
...imgs.map((i) => ({type: "image_url", image_url: {url: i.url}}))] : text});
|
||||
} else if (m.text && !m.error) {
|
||||
out.push({role: "assistant", content: m.text});
|
||||
} else if (!m.error) {
|
||||
out.push(...assistantMessages(m));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// An answer that used MCP tools goes back as the model wrote it: per round the text before the calls, the calls and
|
||||
// their results (as the model read them), then the rest - so the next question can build on what the tools found.
|
||||
function assistantMessages(m) {
|
||||
const ran = (m.tools || []).filter((t) => t.round != null && t.result != null && t.state !== "skipped");
|
||||
if (!ran.length) return m.text ? [{role: "assistant", content: m.text}] : [];
|
||||
const out = [];
|
||||
let pos = 0;
|
||||
for (const r of [...new Set(ran.map((t) => t.round))]) {
|
||||
const calls = ran.filter((t) => t.round === r);
|
||||
const at = Math.min(Math.max(pos, calls[0].at || 0), m.text.length);
|
||||
out.push({role: "assistant", content: m.text.slice(pos, at).trim(),
|
||||
tool_calls: calls.map((t) => ({id: t.id, type: "function", function: {name: t.name, arguments: JSON.stringify(t.arguments || {})}}))});
|
||||
for (const t of calls) out.push({role: "tool", tool_call_id: t.id, content: t.result});
|
||||
pos = at;
|
||||
}
|
||||
const rest = m.text.slice(pos).trim();
|
||||
if (rest) out.push({role: "assistant", content: rest});
|
||||
return out;
|
||||
}
|
||||
|
||||
function setBusy(on) {
|
||||
$("stop-btn").hidden = !on;
|
||||
@@ -560,6 +673,7 @@ async function send() {
|
||||
if (settings.seed) body.seed = +settings.seed;
|
||||
if (settings.max) body.max_tokens = +settings.max;
|
||||
if (projectionLoaded()) body.experimental_speed_projection = !!settings.esp;
|
||||
if (settings.mcp !== false && mcpInfo.tools > 0) body.strata_mcp = true; // this server may run MCP tools for it
|
||||
|
||||
let firstAt = null, thinkStart = null, usage = null, frame = 0;
|
||||
const paint = () => { frame = 0; updateAssistant(el, m, true); scrollDown(); };
|
||||
@@ -589,11 +703,19 @@ async function send() {
|
||||
try { j = JSON.parse(data); } catch (e) { continue; }
|
||||
if (j.error) throw new Error(j.error.message || "the engine reported an error");
|
||||
if (j.usage) usage = j.usage;
|
||||
if (j.strata_mcp) onTool(m, j.strata_mcp);
|
||||
const d = (j.choices && j.choices[0] && j.choices[0].delta) || {};
|
||||
if (d.reasoning_content) { if (!firstAt) firstAt = performance.now(); if (!thinkStart) thinkStart = performance.now(); m.reasoning += d.reasoning_content; }
|
||||
const lastTool = m.tools && m.tools.length ? m.tools[m.tools.length - 1] : null; // a new round after a tool
|
||||
if (d.reasoning_content) {
|
||||
if (!firstAt) firstAt = performance.now();
|
||||
if (!thinkStart) thinkStart = performance.now();
|
||||
if (lastTool && m.reasoning && lastTool.rat === m.reasoning.length) m.reasoning += "\n\n";
|
||||
m.reasoning += d.reasoning_content;
|
||||
}
|
||||
if (d.content) {
|
||||
if (!firstAt) firstAt = performance.now();
|
||||
if (thinkStart && m.thinkSecs == null) m.thinkSecs = (performance.now() - thinkStart) / 1000;
|
||||
if (lastTool && m.text && lastTool.at === m.text.length) m.text += "\n\n";
|
||||
m.text += d.content;
|
||||
}
|
||||
if (!frame) frame = requestAnimationFrame(paint);
|
||||
@@ -612,6 +734,10 @@ async function send() {
|
||||
} else if (m.stopped) {
|
||||
m.meta = "Stopped";
|
||||
}
|
||||
for (const t of m.tools || []) if (t.state === "writing" || t.state === "running") { t.state = "skipped"; t.ms = null; }
|
||||
const ran = (m.tools || []).filter((t) => t.state === "done" || t.state === "error").length;
|
||||
if (ran) m.meta = `${m.meta ? `${m.meta} · ` : ""}${ran} tool call${ran > 1 ? "s" : ""}`;
|
||||
if (m.limit) m.meta = `${m.meta || ""} · stopped at the limit of ${m.limit} tool rounds (mcp.max_rounds)`;
|
||||
busy = null;
|
||||
setBusy(false);
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
@@ -639,8 +765,11 @@ $("new-btn").onclick = () => {
|
||||
};
|
||||
$("export-btn").onclick = () => {
|
||||
if (!messages.length) { toast("info", "Nothing to save yet"); return; }
|
||||
const tools = (m) => (m.tools || []).filter((t) => t.result != null).map((t) =>
|
||||
`<details><summary>Tool ${t.server ? `${t.server} / ` : ""}${t.tool || t.name}${t.ok ? "" : " (error)"}</summary>\n\n` +
|
||||
`\`\`\`json\n${JSON.stringify(t.arguments || {}, null, 2)}\n\`\`\`\n\n\`\`\`\n${t.result}\n\`\`\`\n\n</details>\n\n`).join("");
|
||||
const md = messages.map((m) => m.role === "user" ? `## You\n\n${m.text}\n` :
|
||||
`## ${health.model}\n\n${m.reasoning ? `<details><summary>Thinking</summary>\n\n${m.reasoning}\n\n</details>\n\n` : ""}${m.text || m.error || ""}\n`).join("\n");
|
||||
`## ${health.model}\n\n${m.reasoning ? `<details><summary>Thinking</summary>\n\n${m.reasoning}\n\n</details>\n\n` : ""}${tools(m)}${m.text || m.error || ""}\n`).join("\n");
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(new Blob([md], {type: "text/markdown"}));
|
||||
a.download = `strata-chat-${new Date().toISOString().slice(0, 16).replace(/[:T]/g, "-")}.md`;
|
||||
@@ -734,7 +863,7 @@ function openDrawer(open) {
|
||||
$("drawer").dataset.open = String(open);
|
||||
$("drawer").setAttribute("aria-hidden", String(!open));
|
||||
$("scrim").hidden = !open;
|
||||
if (open) { loadDrawer(); loadShared(); }
|
||||
if (open) { loadDrawer(); loadShared(); loadMcp(); }
|
||||
}
|
||||
function loadDrawer(s = settings) {
|
||||
for (const b of $("s-thinking").children) b.setAttribute("aria-checked", String(b.dataset.v === s.thinking));
|
||||
@@ -743,6 +872,7 @@ function loadDrawer(s = settings) {
|
||||
$("s-show").setAttribute("aria-checked", String(!!s.show));
|
||||
$("s-esp").setAttribute("aria-checked", String(s.esp !== false));
|
||||
$("esp-row").hidden = !projectionLoaded();
|
||||
$("s-mcp").setAttribute("aria-checked", String(s.mcp !== false));
|
||||
$("s-share").setAttribute("aria-checked", String(sharedOn));
|
||||
outputs();
|
||||
}
|
||||
@@ -791,6 +921,7 @@ for (const b of $("s-thinking").children) b.onclick = () => { for (const x of $(
|
||||
for (const id of ["s-temp", "s-topp", "s-topk"]) $(id).oninput = outputs;
|
||||
$("s-show").onclick = () => $("s-show").setAttribute("aria-checked", String($("s-show").getAttribute("aria-checked") !== "true"));
|
||||
$("s-esp").onclick = () => $("s-esp").setAttribute("aria-checked", String($("s-esp").getAttribute("aria-checked") !== "true"));
|
||||
$("s-mcp").onclick = () => $("s-mcp").setAttribute("aria-checked", String($("s-mcp").getAttribute("aria-checked") !== "true"));
|
||||
$("s-share").onclick = () => $("s-share").setAttribute("aria-checked", String($("s-share").getAttribute("aria-checked") !== "true"));
|
||||
$("s-reset").onclick = () => loadDrawer(DEFAULTS);
|
||||
$("s-apply").onclick = async () => {
|
||||
@@ -798,7 +929,8 @@ $("s-apply").onclick = async () => {
|
||||
settings = {thinking: sel ? sel.dataset.v : "high", temperature: +$("s-temp").value, top_p: +$("s-topp").value,
|
||||
top_k: +$("s-topk").value, max: $("s-max").value.trim(), seed: $("s-seed").value.trim(),
|
||||
show: $("s-show").getAttribute("aria-checked") === "true",
|
||||
esp: $("s-esp").getAttribute("aria-checked") === "true"};
|
||||
esp: $("s-esp").getAttribute("aria-checked") === "true",
|
||||
mcp: $("s-mcp").getAttribute("aria-checked") === "true"};
|
||||
store.set("sampling", settings);
|
||||
const share = $("s-share").getAttribute("aria-checked") === "true";
|
||||
openDrawer(false);
|
||||
@@ -824,6 +956,6 @@ setBusy(false);
|
||||
renderChat();
|
||||
const startQuestion = new URLSearchParams(location.search).get("q"); // /?q=... starts a chat (a shortcut)
|
||||
if (startQuestion) history.replaceState(null, "", location.pathname + location.hash);
|
||||
loadHealth().then(() => { if (startQuestion) { $("input").value = startQuestion; send(); } });
|
||||
loadHealth().then(loadMcp).then(() => { if (startQuestion) { $("input").value = startQuestion; send(); } });
|
||||
showTab(location.hash.slice(1) || "chat");
|
||||
poll();
|
||||
@@ -124,6 +124,13 @@
|
||||
<p class="muted req-totals" id="req-totals"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="st-card mcp-card" id="mcp-card" hidden>
|
||||
<div class="req-head"><span class="card-title">MCP servers</span><span class="muted small" id="mcp-sum"></span></div>
|
||||
<div class="mcp-list" id="mcp-list"></div>
|
||||
<p class="muted small mcp-note">Their tools run on this PC with your rights, when the model decides to call them
|
||||
(in this page's chat only; switch it off in Sampling).</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -194,6 +201,9 @@
|
||||
</div>
|
||||
<div class="toggle-row"><span>Show thinking<br><span class="muted small">expanded while it streams</span></span>
|
||||
<button class="st-toggle" id="s-show" role="switch" aria-checked="true" aria-label="Show thinking"></button></div>
|
||||
<div class="toggle-row" id="mcp-row" hidden><span>Use tools from MCP servers<br><span class="muted small"
|
||||
id="mcp-row-sub">the model may call them while it answers</span></span>
|
||||
<button class="st-toggle" id="s-mcp" role="switch" aria-checked="true" aria-label="Use tools from MCP servers"></button></div>
|
||||
<div class="toggle-row" id="esp-row" hidden><span>Experimental speed projection<br><span class="muted small">the
|
||||
engine's control vector; off = the stock model. Switching reads the chat again once</span></span>
|
||||
<button class="st-toggle" id="s-esp" role="switch" aria-checked="true" aria-label="Experimental speed projection"></button></div>
|
||||
|
||||
@@ -57,7 +57,7 @@ PREBUILT_ASSET = "strata-windows-x64.zip" if WIN else "strata-linux-x64.zip"
|
||||
# the CUDA libraries the ready-made engine loads (the same CUDA 13.0 it is built with), from NVIDIA's pip packages
|
||||
CUDA_WHEELS = ["nvidia-cublas==13.0.2.14", "nvidia-cuda-runtime==13.0.96"]
|
||||
MIN_DRIVER = 580 # CUDA 13.0
|
||||
MIN_ENGINE = (0, 1, 17) # v0.1.17: one sampler penalties stage (#53); v0.1.16: the Coder (PR #54)
|
||||
MIN_ENGINE = (0, 1, 19) # v0.1.19: penalties on every drafted token; v0.1.17: one penalties stage (#53)
|
||||
PY_PACKAGES = ["numpy", "jinja2", "regex", "pyyaml", "tqdm", "requests", "cmake", "ninja", "pillow", "psutil"]
|
||||
|
||||
MODELS = {
|
||||
@@ -181,24 +181,39 @@ def mark(path: Path, text=""):
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ the PC
|
||||
def _memory_status():
|
||||
"""Windows' GlobalMemoryStatusEx: RAM, and the commit limit (ullTotalPageFile = RAM + page file)."""
|
||||
class MS(ctypes.Structure):
|
||||
_fields_ = [("dwLength", ctypes.c_ulong), ("dwMemoryLoad", ctypes.c_ulong),
|
||||
("ullTotalPhys", ctypes.c_ulonglong), ("ullAvailPhys", ctypes.c_ulonglong),
|
||||
("ullTotalPageFile", ctypes.c_ulonglong), ("ullAvailPageFile", ctypes.c_ulonglong),
|
||||
("ullTotalVirtual", ctypes.c_ulonglong), ("ullAvailVirtual", ctypes.c_ulonglong),
|
||||
("ullAvailExtendedVirtual", ctypes.c_ulonglong)]
|
||||
m = MS()
|
||||
m.dwLength = ctypes.sizeof(MS)
|
||||
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m))
|
||||
return m
|
||||
|
||||
|
||||
def ram_gb():
|
||||
if WIN:
|
||||
class MS(ctypes.Structure):
|
||||
_fields_ = [("dwLength", ctypes.c_ulong), ("dwMemoryLoad", ctypes.c_ulong),
|
||||
("ullTotalPhys", ctypes.c_ulonglong), ("ullAvailPhys", ctypes.c_ulonglong),
|
||||
("ullTotalPageFile", ctypes.c_ulonglong), ("ullAvailPageFile", ctypes.c_ulonglong),
|
||||
("ullTotalVirtual", ctypes.c_ulonglong), ("ullAvailVirtual", ctypes.c_ulonglong),
|
||||
("ullAvailExtendedVirtual", ctypes.c_ulonglong)]
|
||||
m = MS()
|
||||
m.dwLength = ctypes.sizeof(MS)
|
||||
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m))
|
||||
return m.ullTotalPhys / 2**30
|
||||
return _memory_status().ullTotalPhys / 2**30
|
||||
for line in open("/proc/meminfo"):
|
||||
if line.startswith("MemTotal"):
|
||||
return int(line.split()[1]) * 1024 / 2**30
|
||||
return 0.0
|
||||
|
||||
|
||||
def page_file_gb():
|
||||
"""The page file's current size (GB) on Windows, None elsewhere. The graphics card's memory needs room there
|
||||
too: under Windows' driver model every allocation on the card is also charged to the commit (RAM + page file),
|
||||
so with the page file off or tiny the engine cannot use the free VRAM (issue #60)."""
|
||||
if not WIN:
|
||||
return None
|
||||
m = _memory_status()
|
||||
return max(0.0, (m.ullTotalPageFile - m.ullTotalPhys) / 2**30)
|
||||
|
||||
|
||||
def cpu_info():
|
||||
"""(name, avx2, avx512): avx512 means everything Strata's fast AVX-512 kernels use (F, BW, VL, VNNI, VBMI),
|
||||
the same test the engine makes (cpu_avx512_ok), not just AVX-512F."""
|
||||
@@ -928,6 +943,50 @@ def is_wsl() -> bool:
|
||||
return sys.platform.startswith("linux") and "microsoft" in platform.uname().release.lower()
|
||||
|
||||
|
||||
def hardware_key(cfg: dict) -> str:
|
||||
"""What a calibration is valid for: this GPU, CPU and RAM, and the model with its context and images setting
|
||||
(the context's KV cache and the image encoder take VRAM from the expert cache)."""
|
||||
g = gpu_info(cfg.get("gpu")) or {}
|
||||
a = cfg.get("args", [])
|
||||
ctx = a[a.index("--max-context") + 1] if "--max-context" in a else "?"
|
||||
return "|".join([g.get("name", "?"), f"{g.get('vram_gb', 0):.0f}GB", cpu_info()[0], f"{ram_gb():.0f}GB",
|
||||
cfg.get("model_name", "?"), ctx, "images" if "--vision" in a else "text"])
|
||||
|
||||
|
||||
def calibrate_config(cfg_path: Path) -> bool:
|
||||
"""Measure the engine's hardware-dependent settings on this PC (tools/calibrate.py), write them into the run
|
||||
config and remember them per PC and model in the settings file, so an update or a reinstall keeps them."""
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import calibrate as CAL
|
||||
cfg = json.loads(cfg_path.read_text(encoding="utf-8-sig"))
|
||||
say()
|
||||
say(" Tuning Strata for this PC: the output speed is measured with a few engine settings (the PCIe share, the")
|
||||
say(" draft depth, the CPU threads). It takes about 5-10 minutes; the PC is busy meanwhile.")
|
||||
try:
|
||||
res = CAL.run(cfg, say=say)
|
||||
except Exception as e: # never stops an install: the defaults stay
|
||||
warn(f"the tuning did not finish ({e}): the default settings stay")
|
||||
return False
|
||||
cfg["args"] = CAL.apply(cfg["args"], res["settings"])
|
||||
cfg_path.write_text(json.dumps(cfg, indent=1), encoding="utf-8")
|
||||
st = load_settings()
|
||||
st.setdefault("calibration", {})[hardware_key(cfg)] = {"settings": res["settings"], "tok_s": res["report"].get("tok_s"),
|
||||
"date": time.strftime("%Y-%m-%d")}
|
||||
save_settings(st)
|
||||
if res["settings"]:
|
||||
ok("tuned for this PC: " + ", ".join(f"{k} {v}" for k, v in res["settings"].items())
|
||||
+ (f" ({res['report']['tok_s']} tok/s)" if res["report"].get("tok_s") else ""))
|
||||
else:
|
||||
ok("tuned for this PC: the default settings are already the fastest here"
|
||||
+ (f" ({res['report']['tok_s']} tok/s)" if res["report"].get("tok_s") else ""))
|
||||
return True
|
||||
|
||||
|
||||
def saved_calibration(cfg: dict) -> dict | None:
|
||||
"""The settings an earlier calibration found for this PC and model, if any."""
|
||||
return (load_settings().get("calibration") or {}).get(hardware_key(cfg))
|
||||
|
||||
|
||||
def upgrade_config(cfg_path: Path, cfg: dict) -> dict:
|
||||
"""Configs written before v0.1.13 read prompts in fixed 2048-token chunks; the engine now picks the chunk
|
||||
itself (`--prefill auto`: up to 8192, as the free VRAM allows - about 2x faster on long prompts). Under WSL,
|
||||
@@ -1029,6 +1088,8 @@ def main() -> int:
|
||||
ap.add_argument("--prebuilt", default=os.environ.get("STRATA_PREBUILT_URL", PREBUILT_URL),
|
||||
help="where the ready-made engine is (a URL folder or a local folder)")
|
||||
ap.add_argument("--check", action="store_true", help="only check this PC and exit")
|
||||
ap.add_argument("--calibrate", action="store_true",
|
||||
help="tune the engine's settings for this PC (about 5-10 minutes), then start the model")
|
||||
ap.add_argument("--skip-build", action="store_true", help=argparse.SUPPRESS)
|
||||
a = ap.parse_args()
|
||||
say("Strata - Qwen3.8-Flash-Next on a normal PC (NVIDIA GPU + system RAM + CPU)")
|
||||
@@ -1058,6 +1119,17 @@ def main() -> int:
|
||||
global GPU_PICK
|
||||
GPU_PICK = a.gpu
|
||||
port = a.port or 8080 # a new install's port (issue #32: --port for an existing one)
|
||||
if have and a.calibrate and not (a.setup or a.model or a.family or a.check):
|
||||
if not a.build:
|
||||
update_installed_engine(a.prebuilt)
|
||||
pick_cfg = have[0]
|
||||
if len(have) > 1:
|
||||
say()
|
||||
for i, c in enumerate(have, 1):
|
||||
say(f" {i}) {json.loads(c.read_text(encoding='utf-8-sig')).get('model_name', c.stem)}")
|
||||
pick_cfg = have[int(ask("Tune which one?", [str(i) for i in range(1, len(have) + 1)], "1", a.yes)) - 1]
|
||||
calibrate_config(pick_cfg)
|
||||
return 0 if a.no_start else start(pick_cfg, a.port, a.gpu)
|
||||
if have and not (a.setup or a.model or a.family or a.check or a.no_start):
|
||||
if not a.build:
|
||||
update_installed_engine(a.prebuilt)
|
||||
@@ -1101,6 +1173,11 @@ def main() -> int:
|
||||
"Strata keeps all of the model's experts in RAM (23-50 GB, whatever the GPU) and the GPU holds a copy "
|
||||
"of the most-used ones: it needs 32 GB of RAM or more (48 GB for the full model)")
|
||||
ok(f"RAM: {ram:.0f} GB" if ram >= need - 4 else f"RAM: {ram:.0f} GB (less than the {need} GB the smallest model needs)")
|
||||
pf = page_file_gb()
|
||||
if pf is not None and pf < 4:
|
||||
warn(f"Windows' page file is {pf:.1f} GB: the graphics card's memory needs room there too (issue #60), so "
|
||||
"the model may not start or may use less VRAM. Set it to \"System managed\": System > About > "
|
||||
"Advanced system settings > Performance > Advanced > Virtual memory")
|
||||
ok(f"CPU: {cpu} ({'AVX-512' if avx512 else 'AVX2' if avx2 else 'no AVX2'})")
|
||||
if not avx2:
|
||||
fail("this CPU has no AVX2; Strata needs at least AVX2")
|
||||
@@ -1337,8 +1414,19 @@ def main() -> int:
|
||||
if vision == "cpu":
|
||||
cfg["vision"]["threads"] = max(1, (os.cpu_count() or 8) // 2)
|
||||
cfg_path = ROOT / f"strata-{tag.lower()}.json"
|
||||
cal = saved_calibration(cfg)
|
||||
if cal is not None:
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import calibrate as CAL
|
||||
cfg["args"] = CAL.apply(cfg["args"], cal.get("settings") or {})
|
||||
ok("the settings tuned for this PC earlier are used" + (f" ({cal['date']})" if cal.get("date") else ""))
|
||||
cfg_path.write_text(json.dumps(cfg, indent=1), encoding="utf-8")
|
||||
script = write_run_script(tag, cfg_path, port)
|
||||
# offered only when someone answers: --yes installs and adopted earlier installs are not held up by it
|
||||
if cal is None and not a.no_start and not a.yes and ask(
|
||||
"Tune Strata for this PC now? It measures a few engine settings (about 5-10 minutes; the PC is busy "
|
||||
"meanwhile; later: START-HERE --calibrate)", ["y", "n"], "y", a.yes) == "y":
|
||||
calibrate_config(cfg_path)
|
||||
ok(f"start script: {script.name}")
|
||||
|
||||
say()
|
||||
|
||||
+1
-1
@@ -845,7 +845,7 @@ bool Verifier::run(int T, const int32_t* tokens, int64_t pos0, PoolMultiFn pool,
|
||||
// the POSITION it samples, not to how the text was cut into windows, so a seed replays the same text whatever
|
||||
// the drafts were. Exact: a rejected row's draw is discarded, and no kept decision depends on a reused draw.
|
||||
const bool sampled = !sampling_.greedy && sampling_.temperature > 0.0f;
|
||||
if (sampled || hist_d_ != nullptr) {
|
||||
if (head_sampling_ && (sampled || hist_d_ != nullptr)) {
|
||||
SamplerParams sp = sampling_;
|
||||
sp.counter = (uint64_t) pos0;
|
||||
sample_tokens(head_logits_, T, (int) n_vocab_, hist_d_, hist_len_, sp, m_out_, cs_);
|
||||
|
||||
@@ -116,7 +116,8 @@ __global__ void sampler_greedy_kernel(const float* __restrict__ logits, int n_vo
|
||||
for (int w = threadIdx.x; w < bits_words; w += blockDim.x) penal_bits[w] = 0u;
|
||||
__syncthreads();
|
||||
for (int i = threadIdx.x; i < hlen; i += blockDim.x)
|
||||
if (hrow[i] >= 0) atomicOr(&penal_bits[hrow[i] >> 5], 1u << (hrow[i] & 31));
|
||||
if (hrow[i] >= 0 && hrow[i] < n_vocab) // an id outside the vocabulary is never a candidate
|
||||
atomicOr(&penal_bits[hrow[i] >> 5], 1u << (hrow[i] & 31));
|
||||
__syncthreads();
|
||||
}
|
||||
auto hit_count = [&](int v) -> int {
|
||||
@@ -200,7 +201,8 @@ __global__ void sampler_kernel(const float* __restrict__ logits, int n_vocab, in
|
||||
for (int w = threadIdx.x; w < bits_words; w += blockDim.x) penal_bits[w] = 0u;
|
||||
__syncthreads();
|
||||
for (int i = threadIdx.x; i < hlen; i += blockDim.x)
|
||||
if (hrow[i] >= 0) atomicOr(&penal_bits[hrow[i] >> 5], 1u << (hrow[i] & 31));
|
||||
if (hrow[i] >= 0 && hrow[i] < n_vocab) // an id outside the vocabulary is never a candidate
|
||||
atomicOr(&penal_bits[hrow[i] >> 5], 1u << (hrow[i] & 31));
|
||||
__syncthreads();
|
||||
}
|
||||
auto hit_count = [&](int v) -> int {
|
||||
@@ -208,14 +210,11 @@ __global__ void sampler_kernel(const float* __restrict__ logits, int n_vocab, in
|
||||
return history_count(hrow, hlen, v);
|
||||
};
|
||||
|
||||
// top_k in 1..64 is taken as given; 0 ("off") and anything wider mean the widest shortlist the kernel
|
||||
// keeps, 64. Every row writes out[t]: a verify window reads all of them.
|
||||
const int KMAX = 64;
|
||||
int k = p.top_k > 0 ? (p.top_k < KMAX ? p.top_k : KMAX) : 0;
|
||||
if (k <= 0) {
|
||||
if (threadIdx.x == 0)
|
||||
std::printf("sampler: the sampled path needs top_k in 1..%d (got %d); greedy needs no filters\n",
|
||||
KMAX, p.top_k);
|
||||
return; // leave out[t] unwritten rather than returning an uninitialised token
|
||||
}
|
||||
int k = (p.top_k > 0 && p.top_k < KMAX) ? p.top_k : KMAX;
|
||||
if (k > n_vocab) k = n_vocab;
|
||||
|
||||
// ---- top_k: k rounds of a block argmax over the not-yet-taken. `sel_*` holds the kept ids and their
|
||||
// raw logits in selection order: descending by value, ties to the lower index, which is the order the
|
||||
|
||||
@@ -107,6 +107,9 @@ int run(const char* name, const std::vector<float>& logits, int n_tokens, const
|
||||
check(cudaMalloc(&d_l, logits.size() * sizeof(float)), "malloc logits");
|
||||
check(cudaMalloc(&d_o, (size_t) n_tokens * sizeof(int)), "malloc out");
|
||||
check(cudaMemcpy(d_l, logits.data(), logits.size() * sizeof(float), cudaMemcpyHostToDevice), "copy");
|
||||
// -1 in every output slot first: a row the kernel leaves unwritten can never match (a verify window reads
|
||||
// every row, so "no output" is a wrong answer, not a skipped one)
|
||||
check(cudaMemset(d_o, 0xFF, (size_t) n_tokens * sizeof(int)), "fill out");
|
||||
int* d_h = nullptr;
|
||||
if (hist_len > 0) {
|
||||
check(cudaMalloc(&d_h, hist.size() * sizeof(int)), "malloc hist");
|
||||
@@ -167,8 +170,8 @@ int sampled_reference(const std::vector<float>& l, const std::vector<int>& hist,
|
||||
};
|
||||
auto count = [&](int v) { int c = 0; for (int h : hist) if (h == v) ++c; return c; };
|
||||
const int nv = (int) l.size();
|
||||
const int KMAX = 64;
|
||||
const int k = p.top_k > 0 ? (p.top_k < KMAX ? p.top_k : KMAX) : 0;
|
||||
const int KMAX = 64; // 1..64 as given; 0 (off) and wider keep the widest list, 64
|
||||
const int k = std::min(nv, (p.top_k > 0 && p.top_k < KMAX) ? p.top_k : KMAX);
|
||||
std::vector<int> sel_ids;
|
||||
std::vector<float> sel_logit;
|
||||
std::vector<char> taken((size_t) nv, 0);
|
||||
@@ -230,7 +233,7 @@ int sampled_cut(const std::vector<float>& l, const std::vector<int>& hist, const
|
||||
auto count = [&](int v) { int c = 0; for (int h : hist) if (h == v) ++c; return c; };
|
||||
const int nv = (int) l.size();
|
||||
const int KMAX = 64;
|
||||
const int k = p.top_k > 0 ? (p.top_k < KMAX ? p.top_k : KMAX) : 0;
|
||||
const int k = std::min(nv, (p.top_k > 0 && p.top_k < KMAX) ? p.top_k : KMAX);
|
||||
std::vector<float> sel;
|
||||
std::vector<char> taken((size_t) nv, 0);
|
||||
for (int i = 0; i < k; ++i) {
|
||||
@@ -647,6 +650,173 @@ int main(int argc, char** argv) {
|
||||
bad += run("stale history, last_n=0 (greedy)", l, NT, gp, gwant, hist, 8);
|
||||
}
|
||||
|
||||
// ---- fixture 12: ONE HISTORY PER ROW (engine 0.1.19). A verify window samples T rows, and row t's pick
|
||||
// follows the drafts 1..t: its penalties must count them. The engine used to stage row 0 alone, so rows
|
||||
// 1..T-1 read slots nobody wrote. Here every row gets `penalty_rows`' history and is pinned against a
|
||||
// per-row scalar reference; the fixture first asserts it can SEE the difference - with row 0's history
|
||||
// copied to every row (the nearest well-defined stand-in for the old staging) some row must pick differently.
|
||||
// Row t's own newest token (window[t]) is its favourite by a margin the presence penalty overturns.
|
||||
{
|
||||
auto greedy_pen = [](const std::vector<float>& l, const std::vector<int>& hist,
|
||||
const strata::kernels::SamplerParams& p) {
|
||||
int best = 0; float bv = 0; bool first = true;
|
||||
for (int v = 0; v < (int) l.size(); ++v) {
|
||||
int c = 0; for (int h : hist) if (h == v) ++c;
|
||||
float s = l[(size_t) v];
|
||||
if (c > 0) {
|
||||
if (s <= 0.0f) s *= p.penalty_repeat; else s /= p.penalty_repeat;
|
||||
s -= (float) c * p.penalty_freq + p.penalty_present;
|
||||
}
|
||||
if (first || s > bv) { bv = s; best = v; first = false; }
|
||||
}
|
||||
return best;
|
||||
};
|
||||
std::mt19937 rng(12); std::normal_distribution<float> g(0.0f, 1.0f);
|
||||
const int TAIL = 5000; // longer than the widest window below
|
||||
std::vector<int32_t> tail((size_t) TAIL);
|
||||
for (auto& v : tail) v = (int32_t) (rng() % NV);
|
||||
int observable = 0, rows_checked = 0;
|
||||
for (int T : {1, 2, 4, 8}) {
|
||||
for (int H : {1, 64, 1024, 4096}) {
|
||||
std::vector<int32_t> window((size_t) T);
|
||||
for (int t = 0; t < T; ++t) window[(size_t) t] = (int32_t) (100 + 37 * t); // distinct, in range
|
||||
std::vector<int32_t> rows((size_t) T * H);
|
||||
strata::kernels::penalty_rows(tail.data(), TAIL, window.data(), T, H, rows.data());
|
||||
std::vector<float> l((size_t) T * NV);
|
||||
for (auto& v : l) v = g(rng);
|
||||
for (int t = 0; t < T; ++t) l[(size_t) t * NV + window[(size_t) t]] = 5.0f;
|
||||
strata::kernels::SamplerParams gp;
|
||||
gp.greedy = true; gp.temperature = 0.0f; gp.top_k = 0; gp.top_p = 1.0f;
|
||||
gp.penalty_last_n = H; gp.penalty_present = 4.0f;
|
||||
strata::kernels::SamplerParams sp2;
|
||||
sp2.top_k = 20; sp2.top_p = 0.9f; sp2.temperature = 0.7f; sp2.seed = 1000 + (uint64_t) H;
|
||||
sp2.counter = 77; sp2.penalty_last_n = H; sp2.penalty_present = 4.0f; sp2.penalty_repeat = 1.1f;
|
||||
std::vector<int> gwant((size_t) T), swant((size_t) T), rows_int(rows.begin(), rows.end());
|
||||
for (int t = 0; t < T; ++t) {
|
||||
const std::vector<float> lr(l.begin() + (size_t) t * NV, l.begin() + (size_t) (t + 1) * NV);
|
||||
const std::vector<int> own(rows.begin() + (size_t) t * H, rows.begin() + (size_t) (t + 1) * H);
|
||||
const std::vector<int> row0(rows.begin(), rows.begin() + H);
|
||||
gwant[(size_t) t] = greedy_pen(lr, own, gp);
|
||||
swant[(size_t) t] = sampled_reference(lr, own, sp2, t);
|
||||
if (t > 0 && greedy_pen(lr, row0, gp) != gwant[(size_t) t]) ++observable;
|
||||
++rows_checked;
|
||||
}
|
||||
char name[64];
|
||||
std::snprintf(name, sizeof name, "per-row history T=%d H=%d greedy", T, H);
|
||||
bad += run(name, l, T, gp, gwant, rows_int, H);
|
||||
std::snprintf(name, sizeof name, "per-row history T=%d H=%d sampled", T, H);
|
||||
bad += run(name, l, T, sp2, swant, rows_int, H);
|
||||
}
|
||||
}
|
||||
std::printf(" %-34s %s (%d drafted rows pick differently with row 0's history, of %d rows)\n",
|
||||
"per-row histories are observable", observable > 0 ? "yes" : "*** NO ***", observable,
|
||||
rows_checked);
|
||||
if (observable == 0) ++bad;
|
||||
}
|
||||
|
||||
// ---- fixture 13: HISTORY IDS OUTSIDE THE VOCABULARY ARE IGNORED. The bitmap is sized for n_vocab bits;
|
||||
// an id >= n_vocab used to set a bit past its end (a shared-memory write out of bounds - compute-sanitizer
|
||||
// memcheck reports it). They can never be a candidate, so the result equals the reference without them.
|
||||
{
|
||||
std::mt19937 rng(13); std::normal_distribution<float> g(0.0f, 1.0f);
|
||||
const int H = 16;
|
||||
std::vector<float> l((size_t) NV * NT);
|
||||
for (auto& v : l) v = g(rng);
|
||||
std::vector<int> hist((size_t) NT * H, -1), valid_only((size_t) NT * H, -1);
|
||||
const int junk[] = {NV, NV + 1000, 0x7fffffff, -5, 1 << 20};
|
||||
for (int t = 0; t < NT; ++t) {
|
||||
for (int j = 0; j < H; ++j) {
|
||||
const bool bogus = j % 3 == 0;
|
||||
const int v = bogus ? junk[(size_t) (j / 3) % 5] : (int) (rng() % NV);
|
||||
hist[(size_t) t * H + j] = v;
|
||||
if (!bogus) valid_only[(size_t) t * H + j] = v;
|
||||
}
|
||||
l[(size_t) t * NV + hist[(size_t) t * H + 1]] = 4.0f; // a penalised favourite, so penalties matter
|
||||
}
|
||||
strata::kernels::SamplerParams gp;
|
||||
gp.greedy = true; gp.temperature = 0.0f; gp.top_k = 0; gp.top_p = 1.0f;
|
||||
gp.penalty_last_n = H; gp.penalty_present = 3.0f;
|
||||
strata::kernels::SamplerParams sp2 = gp;
|
||||
sp2.greedy = false; sp2.temperature = 0.8f; sp2.top_k = 20; sp2.top_p = 0.95f; sp2.seed = 13;
|
||||
std::vector<int> gwant((size_t) NT), swant((size_t) NT);
|
||||
for (int t = 0; t < NT; ++t) {
|
||||
const std::vector<float> lr(l.begin() + (size_t) t * NV, l.begin() + (size_t) (t + 1) * NV);
|
||||
const std::vector<int> ok(valid_only.begin() + (size_t) t * H, valid_only.begin() + (size_t) (t + 1) * H);
|
||||
{ // the greedy reference with the penalty (reference_pick has none)
|
||||
int best = 0; float bv = 0; bool first = true;
|
||||
for (int v = 0; v < NV; ++v) {
|
||||
int c = 0; for (int h : ok) if (h == v) ++c;
|
||||
float s = lr[(size_t) v];
|
||||
if (c > 0) { if (s <= 0.0f) s *= gp.penalty_repeat; else s /= gp.penalty_repeat; s -= gp.penalty_present; }
|
||||
if (first || s > bv) { bv = s; best = v; first = false; }
|
||||
}
|
||||
gwant[(size_t) t] = best;
|
||||
}
|
||||
swant[(size_t) t] = sampled_reference(lr, ok, sp2, t);
|
||||
}
|
||||
bad += run("out-of-vocab history ids (greedy)", l, NT, gp, gwant, hist, H);
|
||||
bad += run("out-of-vocab history ids (sampled)", l, NT, sp2, swant, hist, H);
|
||||
}
|
||||
|
||||
// ---- fixture 14: THE top_k CONTRACT. 1..64 as given; 0 ("off") and anything wider use the widest list the
|
||||
// kernel keeps, 64 - and every row is written (the sampled kernel used to print an error for 0 and leave the
|
||||
// row unwritten, which a verify window then read as a token; `run` pre-fills -1 so that fails here).
|
||||
{
|
||||
std::mt19937 rng(14); std::normal_distribution<float> g(0.0f, 1.0f);
|
||||
std::vector<float> l((size_t) NV * NT);
|
||||
for (auto& v : l) v = g(rng) * 0.3f; // flat: the 64-wide list matters to the draw
|
||||
strata::kernels::SamplerParams p64;
|
||||
p64.top_k = 64; p64.top_p = 1.0f; p64.temperature = 1.5f; p64.seed = 14;
|
||||
std::vector<int> want((size_t) NT);
|
||||
for (int t = 0; t < NT; ++t)
|
||||
want[(size_t) t] = sampled_reference({l.begin() + (size_t) t * NV, l.begin() + (size_t) (t + 1) * NV},
|
||||
{}, p64, t);
|
||||
bad += run("sampled top_k=64", l, NT, p64, want);
|
||||
strata::kernels::SamplerParams p0 = p64; p0.top_k = 0;
|
||||
bad += run("sampled top_k=0 means 64", l, NT, p0, want);
|
||||
strata::kernels::SamplerParams p100 = p64; p100.top_k = 100;
|
||||
bad += run("sampled top_k=100 means 64", l, NT, p100, want);
|
||||
strata::kernels::SamplerParams pneg = p64; pneg.top_k = -3;
|
||||
bad += run("sampled top_k=-3 means 64", l, NT, pneg, want);
|
||||
}
|
||||
|
||||
// ---- fixture 15: `penalty_rows`, host only, against the plain definition: row t = the last h tokens of
|
||||
// tail + window[0..t], -1 padded in front. Covers a tail shorter than, equal to and longer than h, and row 0
|
||||
// equal to the single row the engine staged before 0.1.19.
|
||||
{
|
||||
int wrong = 0, cases = 0;
|
||||
for (int n_tail : {0, 1, 5, 63, 64, 65, 300}) {
|
||||
for (int T : {1, 3, 8}) {
|
||||
for (int h : {1, 4, 64, 100}) {
|
||||
std::vector<int32_t> tail((size_t) n_tail), window((size_t) T);
|
||||
for (int i = 0; i < n_tail; ++i) tail[(size_t) i] = 1000 + i;
|
||||
for (int i = 0; i < T; ++i) window[(size_t) i] = 5000 + i;
|
||||
std::vector<int32_t> rows((size_t) T * h, 12345);
|
||||
strata::kernels::penalty_rows(tail.data(), n_tail, window.data(), T, h, rows.data());
|
||||
for (int t = 0; t < T; ++t) {
|
||||
std::vector<int32_t> seq(tail);
|
||||
seq.insert(seq.end(), window.begin(), window.begin() + t + 1);
|
||||
std::vector<int32_t> expect((size_t) h, -1);
|
||||
const int take = (int) std::min<size_t>((size_t) h, seq.size());
|
||||
for (int j = 0; j < take; ++j) expect[(size_t) (h - take + j)] = seq[seq.size() - take + j];
|
||||
if (!std::equal(expect.begin(), expect.end(), rows.begin() + (size_t) t * h)) ++wrong;
|
||||
++cases;
|
||||
}
|
||||
// row 0 = the old single-row staging: consumed tail, then the fed-back head last
|
||||
std::vector<int32_t> old((size_t) h, -1);
|
||||
const int take0 = (int) std::min<int64_t>(h, (int64_t) n_tail + 1);
|
||||
for (int j = 0; j < take0 - 1; ++j) old[(size_t) (h - take0 + j)] = tail[(size_t) (n_tail - (take0 - 1) + j)];
|
||||
old[(size_t) (h - 1)] = window[0];
|
||||
if (!std::equal(old.begin(), old.end(), rows.begin())) ++wrong;
|
||||
++cases;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::printf(" %-34s %s (%d of %d rows differ)\n", "penalty_rows layout", wrong ? "*** WRONG ***" : "matches",
|
||||
wrong, cases);
|
||||
bad += wrong;
|
||||
}
|
||||
|
||||
// A continuous stream and individual decode calls consume the same draw counters.
|
||||
{
|
||||
constexpr int count = 32, vocab = 16;
|
||||
|
||||
+111
-39
@@ -26,6 +26,7 @@
|
||||
#include "strata/kernels/ngram.hpp"
|
||||
#include "strata/kernels/s2_expert_grouped.hpp"
|
||||
#include "strata/kernels/sampler.hpp"
|
||||
#include "strata/kernels/verify_kernels.hpp"
|
||||
#include "strata/kernels/shared_expert.hpp"
|
||||
#include "strata/kernels/native_moe.hpp"
|
||||
#include "strata/kernels/native_gdn.hpp"
|
||||
@@ -1502,20 +1503,73 @@ int main(int argc, char** argv) {
|
||||
o.expert_cache = (int) sized_slots.size();
|
||||
}
|
||||
if (o.expert_cache > 0) {
|
||||
// keep the first `keep_bytes` of the cache (the profile's hottest experts first); false when nothing is left
|
||||
auto shrink_to = [&](int64_t keep_bytes) -> bool {
|
||||
if (keep_bytes <= 0) { o.expert_cache = 0; sized_slots.clear(); return false; }
|
||||
if (!sized_slots.empty()) {
|
||||
int64_t used = 0;
|
||||
size_t keep = 0;
|
||||
while (keep < sized_slots.size() && used + (sized_slots[keep] + 255) / 256 * 256 <= keep_bytes)
|
||||
used += (sized_slots[keep++] + 255) / 256 * 256;
|
||||
sized_slots.resize(keep);
|
||||
o.expert_cache = (int) keep;
|
||||
} else {
|
||||
o.expert_cache = (int) (keep_bytes / (int64_t) strata::kernels::cpu::expert_layout().max_blob);
|
||||
}
|
||||
if (o.expert_cache <= 0) { o.expert_cache = 0; sized_slots.clear(); return false; }
|
||||
return true;
|
||||
};
|
||||
auto cache_bytes = [&]() -> int64_t {
|
||||
if (sized_slots.empty()) return (int64_t) o.expert_cache * (int64_t) strata::kernels::cpu::expert_layout().max_blob;
|
||||
int64_t b = 0;
|
||||
for (const int64_t s : sized_slots) b += (s + 255) / 256 * 256;
|
||||
return b;
|
||||
};
|
||||
// With `--expert-cache auto` the reserve must still be free once the slots are WRITTEN: under WDDM an
|
||||
// allocation is not resident until it is touched, and the free figure read before it can be ~1 GB too
|
||||
// high. A cache sized from it filled the card to 0 MiB, the driver then paged, and a request that needed a
|
||||
// page back while the verify graph spun on a host flag never finished. So the slots are zeroed and the
|
||||
// free figure read again; while it is short of the reserve the cache is reopened smaller.
|
||||
// STRATA_TEST_CACHE_FAIL=N: the first N opens fail as an out-of-commit cudaMalloc does (tests the retry)
|
||||
int fake_fails = std::getenv("STRATA_TEST_CACHE_FAIL") ? std::atoi(std::getenv("STRATA_TEST_CACHE_FAIL")) : 0;
|
||||
int failed = 0;
|
||||
for (int attempt = 0;; ++attempt) {
|
||||
const bool ok = sized_slots.empty()
|
||||
? xcache.open(o.expert_cache, g.n_layers, g.n_expert, (int64_t) strata::kernels::cpu::expert_layout().max_blob, err)
|
||||
: xcache.open_sized(sized_slots, g.n_layers, g.n_expert, err);
|
||||
bool ok = false;
|
||||
if (fake_fails > 0) {
|
||||
--fake_fails;
|
||||
err = "ExpertCache: cudaMalloc failed: out of memory (STRATA_TEST_CACHE_FAIL)";
|
||||
} else {
|
||||
ok = sized_slots.empty()
|
||||
? xcache.open(o.expert_cache, g.n_layers, g.n_expert, (int64_t) strata::kernels::cpu::expert_layout().max_blob, err)
|
||||
: xcache.open_sized(sized_slots, g.n_layers, g.n_expert, err);
|
||||
}
|
||||
if (!ok) {
|
||||
// Issue #60: on Windows a device allocation is also charged to the system commit (RAM + page file),
|
||||
// so with a small page file the cache's one big cudaMalloc fails while the VRAM is free. An auto
|
||||
// cache then tries three quarters of the size, a few times, instead of stopping the engine.
|
||||
char commit[96] = "";
|
||||
#if defined(_WIN32)
|
||||
MEMORYSTATUSEX ms{};
|
||||
ms.dwLength = sizeof ms;
|
||||
if (GlobalMemoryStatusEx(&ms))
|
||||
std::snprintf(commit, sizeof commit, " (Windows has %.1f GiB of commit left: RAM + page file)",
|
||||
(double) ms.ullAvailPageFile / 1073741824.0);
|
||||
#endif
|
||||
if (auto_cache && failed < 8 && shrink_to(cache_bytes() / 4 * 3)) {
|
||||
++failed;
|
||||
std::fprintf(stderr, "strata generate: %s%s; trying a smaller expert cache: %d slots\n", err.c_str(),
|
||||
commit, o.expert_cache);
|
||||
continue;
|
||||
}
|
||||
std::fprintf(stderr, "strata generate: %s\n", err.c_str());
|
||||
#if defined(_WIN32)
|
||||
std::fprintf(stderr, "strata generate: on Windows the graphics card's memory also needs room in the page "
|
||||
"file: set it to \"System managed\" (System > About > Advanced system settings > "
|
||||
"Performance > Advanced > Virtual memory), or lower --expert-cache\n");
|
||||
#endif
|
||||
return 1;
|
||||
}
|
||||
if (!auto_cache || attempt >= 6) break;
|
||||
if (!auto_cache || attempt - failed >= 6) break;
|
||||
cudaMemset(xcache.device_slot(0), 0, (size_t) xcache.bytes());
|
||||
cudaDeviceSynchronize();
|
||||
size_t free_b = 0, total_b = 0;
|
||||
@@ -1529,19 +1583,12 @@ int main(int argc, char** argv) {
|
||||
std::fprintf(stderr, "strata generate: only %lld MiB free once the slots are written (reserve %d MiB); "
|
||||
"shrinking the expert cache\n", (long long) (free_b >> 20), o.vram_reserve_mib);
|
||||
xcache.close();
|
||||
if (keep_bytes <= 0) { o.expert_cache = 0; sized_slots.clear(); break; }
|
||||
if (!sized_slots.empty()) {
|
||||
int64_t used = 0;
|
||||
size_t keep = 0;
|
||||
while (keep < sized_slots.size() && used + (sized_slots[keep] + 255) / 256 * 256 <= keep_bytes)
|
||||
used += (sized_slots[keep++] + 255) / 256 * 256;
|
||||
sized_slots.resize(keep);
|
||||
o.expert_cache = (int) keep;
|
||||
} else {
|
||||
o.expert_cache = (int) (keep_bytes / (int64_t) strata::kernels::cpu::expert_layout().max_blob);
|
||||
}
|
||||
if (o.expert_cache <= 0) { o.expert_cache = 0; sized_slots.clear(); break; }
|
||||
if (!shrink_to(keep_bytes)) break;
|
||||
}
|
||||
if (failed > 0 && o.expert_cache > 0)
|
||||
std::fprintf(stderr, "strata generate: expert cache: %d slots (%.2f GiB) after %d smaller tries - a bigger "
|
||||
"page file lets it use more of the free VRAM\n",
|
||||
o.expert_cache, (double) xcache.bytes() / 1073741824.0, failed);
|
||||
}
|
||||
if (o.expert_cache > 0) {
|
||||
std::fprintf(stderr, "strata generate: expert cache %lld slots, %.2f GiB of VRAM; policy is\n",
|
||||
@@ -1825,6 +1872,16 @@ int main(int argc, char** argv) {
|
||||
sp.top_k = o.top_k;
|
||||
sp.top_p = o.top_p;
|
||||
sp.temperature = o.temperature;
|
||||
// what this run actually samples with (the speculative loop below gets the same parameters); serve samples
|
||||
// per request instead
|
||||
if (!o.serve) {
|
||||
if (sp.greedy || sp.temperature <= 0.0f)
|
||||
std::fprintf(stderr, "strata generate: sampling greedy\n");
|
||||
else
|
||||
std::fprintf(stderr, "strata generate: sampling temperature=%g top_k=%d top_p=%g seed=%llu\n",
|
||||
(double) sp.temperature, sp.top_k > 0 && sp.top_k < 64 ? sp.top_k : 64, (double) sp.top_p,
|
||||
(unsigned long long) sp.seed);
|
||||
}
|
||||
|
||||
std::FILE* dump = nullptr;
|
||||
const int64_t dump_positions = (int64_t) o.tokens.size() - 1 + o.max_new;
|
||||
@@ -2337,13 +2394,15 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
mem_mark("the head and the prompt path");
|
||||
// the penalty-history buffer: ONE row, the request's last `penalty_last_n` tokens, -1 padded in
|
||||
// front. Allocated once at the cap; a request without penalties gets a null row and takes the
|
||||
// byte-for-byte neutral path (no upload, no buffer handed to the sampler).
|
||||
// the penalty-history buffer: one row per verify-window row (`penalty_rows`), each the last
|
||||
// `penalty_last_n` tokens that row's pick follows, -1 padded in front. Allocated once at the cap for
|
||||
// the widest window; a request without penalties gets a null buffer and takes the byte-for-byte
|
||||
// neutral path (no upload, no buffer handed to the sampler).
|
||||
constexpr int kPenaltyWindowCap = 4096;
|
||||
constexpr size_t kHistSlots = (size_t) kPenaltyWindowCap * (size_t) strata::kernels::kVerifyMaxT;
|
||||
int32_t* d_hist = nullptr;
|
||||
std::vector<int32_t> hist_stage((size_t) kPenaltyWindowCap, -1);
|
||||
if (cudaMalloc(&d_hist, (size_t) kPenaltyWindowCap * sizeof(int32_t)) != cudaSuccess) {
|
||||
std::vector<int32_t> hist_stage(kHistSlots, -1);
|
||||
if (cudaMalloc(&d_hist, kHistSlots * sizeof(int32_t)) != cudaSuccess) {
|
||||
std::fprintf(stderr, "strata serve: the penalty-history allocation failed\n");
|
||||
return 1;
|
||||
}
|
||||
@@ -2557,13 +2616,15 @@ int main(int argc, char** argv) {
|
||||
size_t free_b = 0, total_b = 0;
|
||||
cudaMemGetInfo(&free_b, &total_b);
|
||||
std::printf("INFO context=%lld kv=%s kv_resident=%lld expert_slots=%lld expert_cache_mib=%lld spec=%d "
|
||||
"mtp_max=%d lookup=%d vram_free_mib=%lld cvec=%s arena_mib=%lld engine=" STRATA_VERSION "\n",
|
||||
"mtp_max=%d lookup=%d vram_free_mib=%lld cvec=%s arena_mib=%lld pool_workers=%d pcie_frac=%.2f "
|
||||
"spec_min_p=%.2f engine=" STRATA_VERSION "\n",
|
||||
(long long) o.max_context, o.kv.c_str(),
|
||||
(long long) (g.n_qsa_layers() > 0 && ss.qsa_states[0].kv_mode == 1
|
||||
? ss.qsa_states[0].n_slots * 4 : 0),
|
||||
(long long) xcache.slots(), (long long) (xcache.bytes() >> 20), o.spec, o.mtp_max_t,
|
||||
o.suffix_draft, (long long) (free_b >> 20), cvec_summary.c_str(),
|
||||
(long long) (strata::kernels::cpu::expert_layout().total >> 20));
|
||||
(long long) (strata::kernels::cpu::expert_layout().total >> 20), pool.workers(), o.pcie_frac,
|
||||
o.spec_min_p);
|
||||
}
|
||||
// issue #29: a request whose heartbeat (tokens, prompt chunks, verify windows) stops for this long is stuck on
|
||||
// a flag nobody will raise - end the engine with where it was, so the server starts it again instead of the
|
||||
@@ -2633,6 +2694,9 @@ int main(int argc, char** argv) {
|
||||
float req_min_p = 0.0f, req_penalty_repeat = 1.0f, req_penalty_freq = 0.0f, req_penalty_present = 0.0f;
|
||||
int req_penalty_last_n = 0;
|
||||
int req_cvec = 1; // cvec=0|1: a loaded control vector for this request (on when absent)
|
||||
// tuning keys (setup's calibration measures settings without restarting the engine): the PCIe share of
|
||||
// the missed experts and the draft-probability floor, for this request only
|
||||
double req_pcie_frac = o.pcie_frac, req_spec_min_p = o.spec_min_p;
|
||||
if (endp != nullptr) { // GENI takes only cvec=; its file path is the first token without an =
|
||||
for (;;) {
|
||||
while (*endp == ' ') ++endp;
|
||||
@@ -2655,6 +2719,8 @@ int main(int argc, char** argv) {
|
||||
else if (key == "penalty_freq") req_penalty_freq = fv;
|
||||
else if (key == "penalty_present") req_penalty_present = fv;
|
||||
else if (key == "seed") req_seed = std::strtoull(tok.c_str() + eq + 1, nullptr, 10);
|
||||
else if (key == "pcie_frac") req_pcie_frac = std::clamp((double) fv, 0.0, 1.0);
|
||||
else if (key == "spec_min_p") req_spec_min_p = std::clamp((double) fv, 0.0, 1.0);
|
||||
// unknown keys are skipped: the ids start at the first token without '='
|
||||
}
|
||||
}
|
||||
@@ -2854,6 +2920,12 @@ int main(int argc, char** argv) {
|
||||
};
|
||||
// tokens [a, b) through the windows: commit all of them, then give the draft layer their residuals
|
||||
auto read_windows = [&](int64_t a, int64_t b, std::string& e) -> bool {
|
||||
// every token is committed and the picks are discarded: no head sampling (see set_head_sampling)
|
||||
struct NoHeadSampling {
|
||||
strata::core::Verifier& v;
|
||||
explicit NoHeadSampling(strata::core::Verifier& x) : v(x) { v.set_head_sampling(false); }
|
||||
~NoHeadSampling() { v.set_head_sampling(true); }
|
||||
} no_head_sampling(ver);
|
||||
std::vector<int32_t> win((size_t) S), outw((size_t) S), nxt((size_t) S);
|
||||
for (int64_t q = a; q < b;) {
|
||||
if (stop_req.load()) { e = "cancelled"; return false; }
|
||||
@@ -2937,6 +3009,7 @@ int main(int argc, char** argv) {
|
||||
req_sp.penalty_present = req_penalty_present;
|
||||
req_sp.counter = 0;
|
||||
ver.set_sampling(req_sp);
|
||||
drive.d.pcie_num = std::max(0, std::min(256, (int) (req_pcie_frac * 256.0 + 0.5)));
|
||||
const int hist_n = std::min(req_sp.penalty_last_n, kPenaltyWindowCap);
|
||||
ver.set_history(hist_n > 0 ? d_hist : nullptr, hist_n);
|
||||
bool cancelled = false;
|
||||
@@ -3014,9 +3087,9 @@ int main(int argc, char** argv) {
|
||||
if (cancelled) finish = "cancel";
|
||||
while (!cancelled && produced_n < max_new) {
|
||||
int T = S_mtp;
|
||||
if (o.spec_min_p > 0.0) {
|
||||
if (req_spec_min_p > 0.0) {
|
||||
T = 1;
|
||||
while (T < S_mtp && dprob[(size_t) T - 1] >= (float) o.spec_min_p) ++T;
|
||||
while (T < S_mtp && dprob[(size_t) T - 1] >= (float) req_spec_min_p) ++T;
|
||||
}
|
||||
if (first_window) T = 1;
|
||||
// a repeat of earlier context (prompt lookup) where the MTP's own first guess agrees: the policy takes it
|
||||
@@ -3041,17 +3114,14 @@ int main(int argc, char** argv) {
|
||||
drive.d.failed = false;
|
||||
apply_pending(false);
|
||||
if (hist_n > 0) {
|
||||
// the tail the penalties count over: the tokens the state has consumed plus the fed-back
|
||||
// head `x` (it joins `consumed` only after this window commits). Most recent LAST,
|
||||
// -1 in the unused front slots - the sampler's row layout.
|
||||
const int64_t avail = (int64_t) consumed.size() + 1;
|
||||
const int take = (int) std::min<int64_t>(hist_n, avail);
|
||||
std::fill(hist_stage.begin(), hist_stage.begin() + hist_n, -1);
|
||||
for (int j = 0; j < take - 1; ++j)
|
||||
hist_stage[(size_t) (hist_n - take + j)] =
|
||||
consumed[(size_t) ((int64_t) consumed.size() - (take - 1) + j)];
|
||||
hist_stage[(size_t) (hist_n - 1)] = (int32_t) x;
|
||||
cudaMemcpy(d_hist, hist_stage.data(), (size_t) hist_n * sizeof(int32_t), cudaMemcpyHostToDevice);
|
||||
// the tails the penalties count over, ONE PER ROW: the tokens the state has consumed, the
|
||||
// fed-back head `x` (it joins `consumed` only after this window commits), then the drafts
|
||||
// before that row - what plain decode would have counted there. (Until 0.1.19 only row 0
|
||||
// was staged, and the drafted rows read unwritten slots.)
|
||||
strata::kernels::penalty_rows(consumed.data(), (int64_t) consumed.size(), window.data(), T,
|
||||
hist_n, hist_stage.data());
|
||||
cudaMemcpy(d_hist, hist_stage.data(), (size_t) T * (size_t) hist_n * sizeof(int32_t),
|
||||
cudaMemcpyHostToDevice);
|
||||
}
|
||||
tr("window", p, T);
|
||||
if (!ver.run(T, window.data(), p, &drive_pool_multi, &drive, outv.data(), err) || drive.d.failed) {
|
||||
@@ -3086,7 +3156,7 @@ int main(int argc, char** argv) {
|
||||
std::fflush(stdout);
|
||||
++rounds;
|
||||
const bool drafted = eos || produced_n >= max_new ||
|
||||
mtp.draft(T, outv.data(), p, a, drafts.data(), err, dprob.data(), (float) o.spec_min_p);
|
||||
mtp.draft(T, outv.data(), p, a, drafts.data(), err, dprob.data(), (float) req_spec_min_p);
|
||||
if (adapt_thr.joinable()) adapt_thr.join();
|
||||
if (!adapt_ok) {
|
||||
std::printf("ERR an adaptive refill failed\n");
|
||||
@@ -3475,8 +3545,9 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
|
||||
int next = 0;
|
||||
// Teacher-forced prompt rows consume no generation draws.
|
||||
sp.counter = (uint64_t) produced.size();
|
||||
// The draw is Philox(seed, position), as in a verify window (row t at pos0 draws pos0 + t): a seed gives
|
||||
// the same text whether a token comes from this path or from the speculative loop below.
|
||||
sp.counter = (uint64_t) pos;
|
||||
strata::kernels::sample_tokens(d_logits, 1, (int) n_vocab, nullptr, 0, sp, d_next, token_stream);
|
||||
if (cudaMemcpyAsync(&next, d_next, sizeof(int), cudaMemcpyDeviceToHost,
|
||||
(cudaStream_t) token_stream) != cudaSuccess ||
|
||||
@@ -3572,6 +3643,7 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
mem_mark("the verifier and the drafter's binding");
|
||||
ver.set_sampling(sp); // the CLI's own sampling (until 0.1.19 this loop was always greedy); no penalties here
|
||||
ver.set_split(o.spec_split);
|
||||
// auto: the copy kernel for every pack. DMA (the native packs' default until 0.1.13) has the host call
|
||||
// cudaMemcpyAsync + cudaLaunchHostFunc inside a verify window while the GPU spins on the flag they raise;
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Tune the engine's hardware-dependent settings on this PC (setup's --calibrate).
|
||||
|
||||
Three settings depend on the machine more than on the model, and the defaults are right for the PC they were
|
||||
measured on (a Ryzen 5 7600 + RTX 5070 on PCIe 5):
|
||||
--pcie-frac the share of the experts missing from VRAM that are copied over PCIe and run on the GPU instead of
|
||||
on the CPU. A fast PCIe link and a slow CPU want more; a laptop's x8 link or a fast CPU want less.
|
||||
--spec-min-p how sure the draft layer must be to extend a verify window by another guess. A slower CPU pays more
|
||||
per extra window row (more experts per window), so it wants a higher floor.
|
||||
--pool-workers the CPU threads that compute experts. Every physical core is not always best: on hybrid CPUs the
|
||||
efficiency cores can make the whole window wait for them.
|
||||
The first two are measured through one engine (per-request `strata_tune` keys); the worker count needs a restart
|
||||
per value. Decode speed only: the prompt path streams every expert whatever these settings say.
|
||||
|
||||
A setting is kept only when it beats the default by more than MIN_GAIN in an interleaved re-measurement - the
|
||||
adaptive expert tier and the OS make single measurements noisy by a few percent.
|
||||
|
||||
python tools/calibrate.py strata-q2_0.json # measure and print; setup.py --calibrate also saves it
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import statistics
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
MIN_GAIN = 0.03 # a setting must beat the default by this much to be kept
|
||||
PCIE_FRACS = (0.0, 0.2, 0.35, 0.55, 0.75)
|
||||
SPEC_MIN_PS = (0.3, 0.5, 0.7)
|
||||
MAX_NEW = 128
|
||||
PROMPTS = (
|
||||
"Write a Python function that merges two sorted lists into one sorted list, with a docstring and two tests.",
|
||||
"Explain in two paragraphs how a refrigerator moves heat from inside to outside.",
|
||||
"List twelve European capitals with one sentence about each.",
|
||||
)
|
||||
|
||||
|
||||
def chat_ids(tok, text: str) -> list[int]:
|
||||
return tok.encode(f"<|im_start|>user\n{text}<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n",
|
||||
parse_special=True)
|
||||
|
||||
|
||||
def arg_value(args: list[str], flag: str) -> str | None:
|
||||
return args[args.index(flag) + 1] if flag in args and args.index(flag) + 1 < len(args) else None
|
||||
|
||||
|
||||
def with_arg(args: list[str], flag: str, value: str | None) -> list[str]:
|
||||
"""`args` with `flag value` set (replaced if present), or removed when value is None."""
|
||||
out = list(args)
|
||||
if flag in out:
|
||||
i = out.index(flag)
|
||||
del out[i:i + 2]
|
||||
if value is not None:
|
||||
out += [flag, value]
|
||||
return out
|
||||
|
||||
|
||||
def worker_candidates(default: int) -> list[int]:
|
||||
"""The engine's own count, and fewer: two thirds and a half (at least 2), without repeats."""
|
||||
c = [default]
|
||||
for w in (round(default * 2 / 3), round(default / 2)):
|
||||
if w >= 2 and w not in c:
|
||||
c.append(w)
|
||||
return c
|
||||
|
||||
|
||||
def pick(measured: dict, default_key, min_gain: float = MIN_GAIN):
|
||||
"""The key with the best median tok/s, or `default_key` unless the best beats it by more than min_gain."""
|
||||
med = {k: statistics.median(v) for k, v in measured.items() if v}
|
||||
if not med or default_key not in med:
|
||||
return default_key
|
||||
best = max(med, key=med.get)
|
||||
return best if med[best] > med[default_key] * (1.0 + min_gain) else default_key
|
||||
|
||||
|
||||
class Session:
|
||||
"""One running engine: measure decode tok/s for a setting (the median of the prompts' rates)."""
|
||||
|
||||
def __init__(self, engine, ids_list):
|
||||
self.engine = engine
|
||||
self.ids_list = ids_list
|
||||
|
||||
def rate(self, tune: dict | None = None) -> float:
|
||||
rates = []
|
||||
for ids in self.ids_list:
|
||||
sampling = {"temperature": 0}
|
||||
if tune:
|
||||
sampling["strata_tune"] = tune
|
||||
n = sum(1 for t in self.engine.generate(ids, MAX_NEW, sampling, threading.Event()) if t is not None)
|
||||
ms = (self.engine.last or {}).get("decode_ms") or 0.0
|
||||
if n > 8 and ms > 0:
|
||||
rates.append(n / (ms / 1000.0))
|
||||
return statistics.median(rates) if rates else 0.0
|
||||
|
||||
def warm_up(self, rounds: int = 2):
|
||||
for _ in range(rounds):
|
||||
self.rate()
|
||||
|
||||
|
||||
def run(cfg: dict, say=print, start_engine=None) -> dict:
|
||||
"""Measure on the engine `cfg` describes; returns {"settings": {flag: value}, "report": {...}}.
|
||||
`start_engine(args)` returns a started engine (serve.server.StrataEngine or a stand-in in tests)."""
|
||||
if start_engine is None:
|
||||
from serve.server import StrataEngine, child_env
|
||||
|
||||
def start_engine(args):
|
||||
return StrataEngine(cfg["exe"], args, cwd=cfg.get("cwd"), log=cfg.get("log"), env=child_env(cfg))
|
||||
import strata_tokenizer as ST
|
||||
tpath = Path(cfg["tokenizer"])
|
||||
vocab = json.loads((tpath / "vocab.json").read_text(encoding="utf-8"))
|
||||
toks = [None] * len(vocab)
|
||||
for t, i in vocab.items():
|
||||
toks[i] = t
|
||||
tok = ST.Tokenizer(toks, (tpath / "merges.txt").read_text(encoding="utf-8").split("\n"),
|
||||
json.loads((tpath / "token_type.json").read_text()))
|
||||
ids_list = [chat_ids(tok, p) for p in PROMPTS]
|
||||
return measure(cfg["args"], ids_list, start_engine, say)
|
||||
|
||||
|
||||
def measure(base_args: list[str], ids_list, start_engine, say=print) -> dict:
|
||||
t0 = time.time()
|
||||
report: dict = {}
|
||||
say(" Loading the model for the measurements ...")
|
||||
base_args = apply(base_args, {}) # the product defaults: what the measurements must beat
|
||||
eng = start_engine(base_args)
|
||||
try:
|
||||
info = dict(getattr(eng, "info", {}) or {})
|
||||
d_pcie = float(info.get("pcie_frac", 0.55))
|
||||
d_minp = float(info.get("spec_min_p", 0.5))
|
||||
d_workers = int(info.get("pool_workers", 0)) or None
|
||||
s = Session(eng, ids_list)
|
||||
s.warm_up()
|
||||
# 1. the PCIe share, at the default draft floor
|
||||
by_pcie = {}
|
||||
for f in sorted(set(PCIE_FRACS) | {round(d_pcie, 2)}):
|
||||
by_pcie[f] = [s.rate({"pcie_frac": f, "spec_min_p": d_minp})]
|
||||
say(f" PCIe share {f:.2f}: {by_pcie[f][0]:.1f} tok/s")
|
||||
best_pcie = max(by_pcie, key=lambda k: by_pcie[k][0])
|
||||
# 2. the draft floor, at that share
|
||||
by_minp = {}
|
||||
for p in sorted(set(SPEC_MIN_PS) | {round(d_minp, 2)}):
|
||||
by_minp[p] = [s.rate({"pcie_frac": best_pcie, "spec_min_p": p})]
|
||||
say(f" draft floor {p:.2f}: {by_minp[p][0]:.1f} tok/s")
|
||||
best_minp = max(by_minp, key=lambda k: by_minp[k][0])
|
||||
# 3. the winner against the default, interleaved, three times each
|
||||
dflt, cand = (round(d_pcie, 2), round(d_minp, 2)), (best_pcie, best_minp)
|
||||
confirm = {dflt: [], cand: []}
|
||||
if cand != dflt:
|
||||
for _ in range(3):
|
||||
for k in (dflt, cand):
|
||||
confirm[k].append(s.rate({"pcie_frac": k[0], "spec_min_p": k[1]}))
|
||||
chosen = pick(confirm, dflt) if cand != dflt else dflt
|
||||
report.update(default={"pcie_frac": dflt[0], "spec_min_p": dflt[1], "pool_workers": d_workers},
|
||||
pcie_sweep={str(k): v for k, v in by_pcie.items()},
|
||||
min_p_sweep={str(k): v for k, v in by_minp.items()},
|
||||
confirm={f"{k[0]}/{k[1]}": v for k, v in confirm.items()})
|
||||
finally:
|
||||
close(eng)
|
||||
settings = {}
|
||||
if chosen != dflt:
|
||||
settings["--pcie-frac"] = f"{chosen[0]:.2f}"
|
||||
settings["--spec-min-p"] = f"{chosen[1]:.2f}"
|
||||
base_rate = statistics.median(confirm[chosen]) if confirm.get(chosen) else None
|
||||
# 4. fewer CPU workers (a restart each), with the chosen settings
|
||||
if d_workers and len(worker_candidates(d_workers)) > 1:
|
||||
tuned = with_arg(with_arg(base_args, "--pcie-frac", f"{chosen[0]:.2f}"), "--spec-min-p", f"{chosen[1]:.2f}")
|
||||
by_workers = {}
|
||||
for w in worker_candidates(d_workers):
|
||||
say(f" Measuring with {w} CPU workers (restarts the engine) ...")
|
||||
e = start_engine(with_arg(tuned, "--pool-workers", None if w == d_workers else str(w)))
|
||||
try:
|
||||
sw = Session(e, ids_list)
|
||||
sw.warm_up(1)
|
||||
by_workers[w] = [sw.rate(), sw.rate()]
|
||||
say(f" {w} workers: {statistics.median(by_workers[w]):.1f} tok/s")
|
||||
finally:
|
||||
close(e)
|
||||
w_best = pick(by_workers, d_workers)
|
||||
report["workers"] = {str(k): v for k, v in by_workers.items()}
|
||||
if w_best != d_workers:
|
||||
settings["--pool-workers"] = str(w_best)
|
||||
base_rate = statistics.median(by_workers[w_best])
|
||||
elif by_workers.get(d_workers):
|
||||
base_rate = statistics.median(by_workers[d_workers])
|
||||
report["seconds"] = round(time.time() - t0)
|
||||
report["tok_s"] = round(base_rate, 1) if base_rate else None
|
||||
return {"settings": settings, "report": report}
|
||||
|
||||
|
||||
def close(eng):
|
||||
proc = getattr(eng, "proc", None)
|
||||
if proc is None:
|
||||
return
|
||||
try:
|
||||
proc.stdin.write("QUIT\n")
|
||||
proc.stdin.flush()
|
||||
proc.stdin.close()
|
||||
proc.wait(60)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
|
||||
|
||||
DEFAULTS = {"--pcie-frac": None, "--spec-min-p": "0.5", "--pool-workers": None} # None: the engine's own choice
|
||||
|
||||
|
||||
def apply(args: list[str], settings: dict) -> list[str]:
|
||||
"""`args` with the calibrated settings; a setting the calibration did not change goes back to the product
|
||||
default (setup's --spec-min-p 0.5, the engine's own PCIe share and worker count), so an older calibration's
|
||||
values never linger."""
|
||||
out = list(args)
|
||||
for flag, default in DEFAULTS.items():
|
||||
out = with_arg(out, flag, settings.get(flag, default))
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
sys.exit("usage: calibrate.py <strata-*.json>")
|
||||
res = run(json.loads(Path(sys.argv[1]).read_text(encoding="utf-8-sig")))
|
||||
print(json.dumps(res, indent=1))
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Tests for tools/calibrate.py and setup's use of it, without a GPU: a stand-in engine whose decode speed is a
|
||||
function of the settings it runs with.
|
||||
|
||||
python -m unittest tools.test_calibrate
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
sys.path.insert(0, str(ROOT))
|
||||
import calibrate as CAL # noqa: E402
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
"""Speed = f(pcie_frac, spec_min_p, workers): the GEN line's tune keys arrive as `strata_tune`."""
|
||||
|
||||
def __init__(self, args, speed, info_workers=6, starts=None):
|
||||
self.args = list(args)
|
||||
w = CAL.arg_value(args, "--pool-workers")
|
||||
self.workers = int(w) if w else info_workers
|
||||
self.info = {"pool_workers": info_workers, "pcie_frac": 0.55, "spec_min_p": float(CAL.arg_value(args, "--spec-min-p") or 0)}
|
||||
self.speed = speed
|
||||
self.last = {}
|
||||
self.proc = None
|
||||
if starts is not None:
|
||||
starts.append(list(args))
|
||||
|
||||
def generate(self, ids, max_new, sampling, cancel):
|
||||
tune = sampling.get("strata_tune") or {}
|
||||
rate = self.speed(tune.get("pcie_frac", 0.55), tune.get("spec_min_p", self.info["spec_min_p"]), self.workers)
|
||||
for _ in range(max_new):
|
||||
yield 1
|
||||
self.last = {"generated": max_new, "decode_ms": max_new / rate * 1000.0}
|
||||
|
||||
|
||||
BASE = ["--pack", "p", "--spec", "4", "--spec-min-p", "0.5", "--max-context", "8192"]
|
||||
|
||||
|
||||
class Calibrate(unittest.TestCase):
|
||||
def run_with(self, speed, workers=6, base=BASE):
|
||||
starts = []
|
||||
res = CAL.measure(base, [[1, 2, 3]] * 3, lambda a: FakeEngine(a, speed, workers, starts), say=lambda *_: None)
|
||||
return res, starts
|
||||
|
||||
def test_defaults_kept_when_flat(self):
|
||||
res, _ = self.run_with(lambda f, p, w: 50.0)
|
||||
self.assertEqual(res["settings"], {})
|
||||
|
||||
def test_small_gain_is_noise(self):
|
||||
# 2% better at pcie 0.35: below MIN_GAIN, so the default stays
|
||||
res, _ = self.run_with(lambda f, p, w: 51.0 if abs(f - 0.35) < 1e-6 else 50.0)
|
||||
self.assertEqual(res["settings"], {})
|
||||
|
||||
def test_finds_pcie_and_min_p(self):
|
||||
def speed(f, p, w):
|
||||
return 50.0 + (10.0 if abs(f - 0.2) < 1e-6 else 0.0) + (5.0 if abs(p - 0.7) < 1e-6 else 0.0)
|
||||
res, _ = self.run_with(speed)
|
||||
self.assertEqual(res["settings"].get("--pcie-frac"), "0.20")
|
||||
self.assertEqual(res["settings"].get("--spec-min-p"), "0.70")
|
||||
self.assertNotIn("--pool-workers", res["settings"])
|
||||
|
||||
def test_fewer_workers(self):
|
||||
# a hybrid CPU: half the workers is 20% faster
|
||||
res, starts = self.run_with(lambda f, p, w: 60.0 if w == 3 else 50.0, workers=6)
|
||||
self.assertEqual(res["settings"].get("--pool-workers"), "3")
|
||||
self.assertEqual(len(starts), 1 + len(CAL.worker_candidates(6))) # one start per worker count, plus the sweep
|
||||
self.assertEqual(CAL.arg_value(starts[0], "--spec-min-p"), "0.5") # measured against the product default
|
||||
|
||||
def test_old_calibration_is_the_baseline_reset(self):
|
||||
# a config tuned earlier: the measurement starts from the product defaults, not from those values
|
||||
base = CAL.apply(BASE, {"--pcie-frac": "0.20", "--pool-workers": "3", "--spec-min-p": "0.70"})
|
||||
_, starts = self.run_with(lambda f, p, w: 50.0, base=base)
|
||||
self.assertIsNone(CAL.arg_value(starts[0], "--pcie-frac"))
|
||||
self.assertIsNone(CAL.arg_value(starts[0], "--pool-workers"))
|
||||
self.assertEqual(CAL.arg_value(starts[0], "--spec-min-p"), "0.5")
|
||||
|
||||
def test_apply(self):
|
||||
a = CAL.apply(BASE, {"--pcie-frac": "0.35", "--pool-workers": "4"})
|
||||
self.assertEqual(CAL.arg_value(a, "--pcie-frac"), "0.35")
|
||||
self.assertEqual(CAL.arg_value(a, "--pool-workers"), "4")
|
||||
self.assertEqual(CAL.arg_value(a, "--spec-min-p"), "0.5")
|
||||
b = CAL.apply(a, {}) # back to the defaults
|
||||
self.assertIsNone(CAL.arg_value(b, "--pcie-frac"))
|
||||
self.assertIsNone(CAL.arg_value(b, "--pool-workers"))
|
||||
self.assertEqual(b.count("--spec-min-p"), 1)
|
||||
|
||||
def test_worker_candidates(self):
|
||||
self.assertEqual(CAL.worker_candidates(6), [6, 4, 3])
|
||||
self.assertEqual(CAL.worker_candidates(23), [23, 15, 12])
|
||||
self.assertEqual(CAL.worker_candidates(3), [3, 2])
|
||||
self.assertEqual(CAL.worker_candidates(1), [1])
|
||||
|
||||
def test_pick(self):
|
||||
self.assertEqual(CAL.pick({"a": [50, 51, 49], "b": [53, 52, 60]}, "a"), "b")
|
||||
self.assertEqual(CAL.pick({"a": [50, 51, 49], "b": [51, 51.5, 51]}, "a"), "a")
|
||||
self.assertEqual(CAL.pick({}, "a"), "a")
|
||||
|
||||
|
||||
class SetupIntegration(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.old = {k: os.environ.get(k) for k in ("APPDATA", "XDG_CONFIG_HOME")}
|
||||
os.environ["APPDATA"] = self.tmp.name
|
||||
os.environ["XDG_CONFIG_HOME"] = self.tmp.name
|
||||
import setup as S
|
||||
self.S = S
|
||||
self.saved = (S.gpu_info, S.cpu_info, S.ram_gb, CAL.run)
|
||||
S.gpu_info = lambda pick=None: {"name": "RTX Test", "vram_gb": 12.0, "arch": "120", "count": 1, "index": 0}
|
||||
S.cpu_info = lambda: ("Test CPU", True, True)
|
||||
S.ram_gb = lambda: 64.0
|
||||
|
||||
def tearDown(self):
|
||||
self.S.gpu_info, self.S.cpu_info, self.S.ram_gb, CAL.run = self.saved
|
||||
for k, v in self.old.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_calibrate_config_writes_and_remembers(self):
|
||||
cfg_path = Path(self.tmp.name) / "strata-q2_0.json"
|
||||
cfg = {"exe": "x", "args": list(BASE), "model_name": "qwen3.8-flash-next-q2_0"}
|
||||
cfg_path.write_text(json.dumps(cfg))
|
||||
CAL.run = lambda c, say=print, start_engine=None: {"settings": {"--pcie-frac": "0.20"}, "report": {"tok_s": 61.2}}
|
||||
self.assertTrue(self.S.calibrate_config(cfg_path))
|
||||
written = json.loads(cfg_path.read_text())
|
||||
self.assertEqual(CAL.arg_value(written["args"], "--pcie-frac"), "0.20")
|
||||
saved = self.S.saved_calibration(written)
|
||||
self.assertEqual(saved["settings"], {"--pcie-frac": "0.20"})
|
||||
# another model on the same PC has no calibration yet
|
||||
self.assertIsNone(self.S.saved_calibration({**written, "model_name": "swift-iq2_xs"}))
|
||||
# another context size is another key (its KV cache changes the expert cache)
|
||||
other = dict(written, args=CAL.with_arg(written["args"], "--max-context", "131072"))
|
||||
self.assertIsNone(self.S.saved_calibration(other))
|
||||
|
||||
def test_failed_calibration_keeps_defaults(self):
|
||||
cfg_path = Path(self.tmp.name) / "strata-q2_0.json"
|
||||
cfg_path.write_text(json.dumps({"exe": "x", "args": list(BASE), "model_name": "m"}))
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("the engine exited before it was ready")
|
||||
CAL.run = boom
|
||||
self.assertFalse(self.S.calibrate_config(cfg_path))
|
||||
self.assertEqual(json.loads(cfg_path.read_text())["args"], BASE)
|
||||
self.assertIsNone(self.S.saved_calibration({"args": BASE, "model_name": "m"}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user