prain: web UI with sessions and drafts
This commit is contained in:
+274
@@ -0,0 +1,274 @@
|
|||||||
|
"""prain web: a one-page browser chat that warms experts up while you type.
|
||||||
|
|
||||||
|
A small stdlib proxy in front of vLLM. GET / serves the page. POST /warm takes
|
||||||
|
{"session", "messages", "draft"} and returns at once. POST /chat takes {"session",
|
||||||
|
"messages"} and streams server-sent events: vLLM's chunks, plus {"prain": "draft" |
|
||||||
|
"refine" | "error"} events of its own. Binds to 127.0.0.1; use an SSH tunnel from other
|
||||||
|
machines.
|
||||||
|
|
||||||
|
Several browsers at once ([serve] and [draft] tables of the placement file):
|
||||||
|
- At most draft.full_requests answers run with all their experts. An answer that arrives
|
||||||
|
while they are taken runs as a draft right away, on the experts already in VRAM (see
|
||||||
|
prain.drafts), instead of waiting or evicting the other answers' experts.
|
||||||
|
- With draft.refine = "regenerate", the oldest waiting draft takes the next free full slot:
|
||||||
|
its draft request is cancelled and the answer is generated again with all experts. The
|
||||||
|
page shows the draft in grey and the full answer over it.
|
||||||
|
- Every session has its own Warmer. warmup="idle": no warm-ups while any answer streams;
|
||||||
|
"always": warm-ups while vLLM has a free request slot (serve.max_requests). Sending cancels
|
||||||
|
that session's warm-up. A browser that goes away closes the upstream request, and vLLM
|
||||||
|
cancels it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import urllib.error
|
||||||
|
from collections import OrderedDict, deque
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
from prain.chat import Warmer, chat_body, draft_body, first_model, post, send_request, warm_body
|
||||||
|
from prain.config import Draft, Serve
|
||||||
|
|
||||||
|
MAX_SESSIONS = 64
|
||||||
|
|
||||||
|
PAGE = """<!doctype html>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>prain</title>
|
||||||
|
<style>
|
||||||
|
body { font: 15px/1.45 system-ui, sans-serif; max-width: 52rem; margin: 2rem auto; padding: 0 1rem; }
|
||||||
|
.user { color: #666; margin-top: 1rem; }
|
||||||
|
.assistant { white-space: pre-wrap; }
|
||||||
|
.draft { color: #999; }
|
||||||
|
textarea { width: 100%; height: 5rem; font: inherit; margin-top: 1rem; }
|
||||||
|
#status { color: #888; font-size: 13px; }
|
||||||
|
</style>
|
||||||
|
<div id="log"></div>
|
||||||
|
<textarea id="draft" autofocus
|
||||||
|
placeholder="Experts load while you type. Enter sends, Shift+Enter adds a line."></textarea>
|
||||||
|
<div id="status"></div>
|
||||||
|
<script>
|
||||||
|
const session = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||||
|
const messages = [];
|
||||||
|
const log = document.getElementById("log");
|
||||||
|
const draft = document.getElementById("draft");
|
||||||
|
const status = document.getElementById("status");
|
||||||
|
let streaming = false;
|
||||||
|
|
||||||
|
function show(role, text) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = role;
|
||||||
|
div.textContent = text;
|
||||||
|
log.appendChild(div);
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
draft.addEventListener("input", () => {
|
||||||
|
if (!streaming && draft.value.trim())
|
||||||
|
fetch("/warm", {method: "POST", body: JSON.stringify({session, messages, draft: draft.value})});
|
||||||
|
});
|
||||||
|
|
||||||
|
draft.addEventListener("keydown", async (event) => {
|
||||||
|
if (event.key !== "Enter" || event.shiftKey || streaming || !draft.value.trim()) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const text = draft.value.trim();
|
||||||
|
messages.push({role: "user", content: text});
|
||||||
|
show("user", "you> " + text);
|
||||||
|
draft.value = "";
|
||||||
|
streaming = true;
|
||||||
|
const out = show("assistant", "");
|
||||||
|
const finalSpan = out.appendChild(document.createElement("span"));
|
||||||
|
const draftSpan = out.appendChild(document.createElement("span"));
|
||||||
|
draftSpan.className = "draft";
|
||||||
|
let mode = "full", draftText = "", finalText = "", refined = false;
|
||||||
|
const render = () => { // the full text, then the grey rest of the draft while they agree
|
||||||
|
finalSpan.textContent = finalText;
|
||||||
|
draftSpan.textContent = mode === "draft" ? draftText
|
||||||
|
: mode === "refine" && draftText.startsWith(finalText) ? draftText.slice(finalText.length) : "";
|
||||||
|
};
|
||||||
|
let start = performance.now(), first = 0, pieces = 0, buffer = "";
|
||||||
|
const response = await fetch("/chat", {method: "POST", body: JSON.stringify({session, messages})});
|
||||||
|
if (!response.ok) {
|
||||||
|
out.textContent = "error " + response.status + ": " + await response.text();
|
||||||
|
messages.pop();
|
||||||
|
streaming = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
for (;;) {
|
||||||
|
const {done, value} = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, {stream: true});
|
||||||
|
const lines = buffer.split("\\n");
|
||||||
|
buffer = lines.pop();
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
|
||||||
|
const data = JSON.parse(line.slice(6));
|
||||||
|
if (data.prain === "draft") {
|
||||||
|
mode = "draft";
|
||||||
|
status.textContent = "GPU busy: drafting from the experts in VRAM, refined when it is free";
|
||||||
|
} else if (data.prain === "refine") {
|
||||||
|
mode = "refine";
|
||||||
|
refined = true;
|
||||||
|
start = performance.now(), first = 0, pieces = 0;
|
||||||
|
status.textContent = "refining with all experts";
|
||||||
|
} else if (data.prain === "error") {
|
||||||
|
mode = "draft";
|
||||||
|
refined = false;
|
||||||
|
status.textContent = "refining failed (HTTP " + data.status + "), keeping the draft";
|
||||||
|
} else {
|
||||||
|
const piece = data.choices[0].delta.content;
|
||||||
|
if (!piece) continue;
|
||||||
|
if (pieces++ === 0) first = performance.now();
|
||||||
|
if (mode === "draft") draftText += piece; else finalText += piece;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages.push({role: "assistant", content: mode === "draft" ? draftText : finalText});
|
||||||
|
let same = 0;
|
||||||
|
while (same < draftText.length && draftText[same] === finalText[same]) same++;
|
||||||
|
const seconds = (performance.now() - first) / 1000;
|
||||||
|
status.textContent = (refined ? `draft kept ${same} of ${draftText.length} chars; refined: `
|
||||||
|
: mode === "draft" ? "draft, not refined: " : "") +
|
||||||
|
`first token ${((first - start) / 1000).toFixed(1)} s, ` +
|
||||||
|
`${pieces > 1 ? ((pieces - 1) / seconds).toFixed(1) : "0.0"} tok/s`;
|
||||||
|
streaming = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def make_server(base_url, port, host="127.0.0.1", warmup="idle", priority=1, serve=Serve(),
|
||||||
|
draft=Draft()):
|
||||||
|
model = first_model(base_url)
|
||||||
|
lock = threading.Condition()
|
||||||
|
sessions = OrderedDict() # session -> Warmer, least recently used first
|
||||||
|
streams = [0] # answers streaming right now, all sessions, full or draft
|
||||||
|
full = [0] # answers holding one of the draft.full_requests full slots
|
||||||
|
waiting = deque() # drafts waiting for a full slot to refine, oldest first
|
||||||
|
refine = draft.refine == "regenerate"
|
||||||
|
|
||||||
|
def warmer(session):
|
||||||
|
with lock:
|
||||||
|
if session not in sessions:
|
||||||
|
sessions[session] = Warmer(lambda job: send_request(
|
||||||
|
base_url, "/v1/chat/completions", warm_body(model, *job, priority)))
|
||||||
|
if len(sessions) > MAX_SESSIONS:
|
||||||
|
sessions.popitem(last=False)[1].cancel()
|
||||||
|
sessions.move_to_end(session)
|
||||||
|
return sessions[session]
|
||||||
|
|
||||||
|
def my_turn(ticket): # call with the lock held
|
||||||
|
return full[0] < draft.full_requests and bool(waiting) and waiting[0] is ticket
|
||||||
|
|
||||||
|
def turn_now(ticket):
|
||||||
|
with lock:
|
||||||
|
return my_turn(ticket)
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
self.reply(200, "text/html; charset=utf-8", PAGE.encode())
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||||
|
session = str(body.get("session", ""))
|
||||||
|
if self.path == "/warm":
|
||||||
|
with lock:
|
||||||
|
busy = streams[0]
|
||||||
|
if body["draft"].strip() and ((warmup == "idle" and not busy) or
|
||||||
|
(warmup == "always" and busy < serve.max_requests)):
|
||||||
|
warmer(session).update((body["messages"], body["draft"]))
|
||||||
|
return self.reply(204, "text/plain", b"")
|
||||||
|
if self.path != "/chat":
|
||||||
|
return self.reply(404, "text/plain", b"not found")
|
||||||
|
warmer(session).cancel()
|
||||||
|
messages, ticket = body["messages"], object()
|
||||||
|
with lock:
|
||||||
|
streams[0] += 1
|
||||||
|
holds_full = full[0] < draft.full_requests and not waiting
|
||||||
|
if holds_full:
|
||||||
|
full[0] += 1
|
||||||
|
elif refine:
|
||||||
|
waiting.append(ticket)
|
||||||
|
try:
|
||||||
|
if holds_full:
|
||||||
|
if not self.relay(chat_body(model, messages), opens=True):
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
if not self.relay(draft_body(model, messages), opens=True, event="draft",
|
||||||
|
stop=(lambda: turn_now(ticket)) if refine else None):
|
||||||
|
return
|
||||||
|
if refine:
|
||||||
|
with lock:
|
||||||
|
lock.wait_for(lambda: my_turn(ticket))
|
||||||
|
waiting.popleft()
|
||||||
|
full[0] += 1
|
||||||
|
holds_full = True
|
||||||
|
self.relay(chat_body(model, messages), opens=False, event="refine")
|
||||||
|
self.send_event("[DONE]")
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
pass # the browser left; closing upstream makes vLLM cancel the request
|
||||||
|
finally:
|
||||||
|
with lock:
|
||||||
|
streams[0] -= 1
|
||||||
|
if holds_full:
|
||||||
|
full[0] -= 1
|
||||||
|
if ticket in waiting:
|
||||||
|
waiting.remove(ticket)
|
||||||
|
lock.notify_all()
|
||||||
|
|
||||||
|
def relay(self, body, opens, event=None, stop=None):
|
||||||
|
"""Stream one vLLM answer to the browser, without its [DONE]. opens: this starts
|
||||||
|
the response, so a vLLM error becomes the HTTP status (returns False). stop():
|
||||||
|
checked after every chunk; True closes the upstream request early."""
|
||||||
|
try:
|
||||||
|
upstream = post(base_url, "/v1/chat/completions", body)
|
||||||
|
except urllib.error.HTTPError as e: # vLLM rejected it: pass its answer on
|
||||||
|
with e:
|
||||||
|
data = e.read()
|
||||||
|
if opens:
|
||||||
|
self.reply(e.code, "application/json", data)
|
||||||
|
return False
|
||||||
|
self.send_event({"prain": "error", "status": e.code})
|
||||||
|
return True
|
||||||
|
with upstream:
|
||||||
|
if opens:
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.end_headers()
|
||||||
|
if event:
|
||||||
|
self.send_event({"prain": event})
|
||||||
|
for line in upstream:
|
||||||
|
if line.strip() in (b"", b"data: [DONE]"):
|
||||||
|
continue
|
||||||
|
self.wfile.write(line.rstrip(b"\n") + b"\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
if stop is not None and stop():
|
||||||
|
break
|
||||||
|
return True
|
||||||
|
|
||||||
|
def send_event(self, data):
|
||||||
|
text = data if isinstance(data, str) else json.dumps(data)
|
||||||
|
self.wfile.write(f"data: {text}\n\n".encode())
|
||||||
|
self.wfile.flush()
|
||||||
|
|
||||||
|
def reply(self, code, kind, data):
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", kind)
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer((host, port), Handler)
|
||||||
|
server.sessions, server.streams, server.full = sessions, streams, full # for tests
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
def serve(base_url, port, warmup="idle", priority=1, serve=Serve(), draft=Draft()):
|
||||||
|
server = make_server(base_url, port, warmup=warmup, priority=priority, serve=serve, draft=draft)
|
||||||
|
print(f"prain web: http://127.0.0.1:{server.server_port} (vLLM at {base_url}; "
|
||||||
|
f"{draft.full_requests} full of {serve.max_requests} parallel answers)")
|
||||||
|
server.serve_forever()
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import prain.chat as chat
|
||||||
|
from prain.config import Draft, Serve
|
||||||
|
from prain.web import make_server
|
||||||
|
from tests.helpers import StubVllm, wait_for
|
||||||
|
|
||||||
|
|
||||||
|
def events(response):
|
||||||
|
"""The proxy's stream as a list: content pieces, and "<draft>"-style markers."""
|
||||||
|
out = []
|
||||||
|
for raw in response:
|
||||||
|
line = raw.decode().strip()
|
||||||
|
if line == "data: [DONE]":
|
||||||
|
out.append("<done>")
|
||||||
|
elif line.startswith("data: "):
|
||||||
|
data = json.loads(line[6:])
|
||||||
|
out.append(f"<{data['prain']}>" if "prain" in data
|
||||||
|
else data["choices"][0]["delta"]["content"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class Web(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.stub = StubVllm()
|
||||||
|
self.addCleanup(self.stub.close)
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
def start(self, **options):
|
||||||
|
self.server = make_server(self.stub.url, 0, **options)
|
||||||
|
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||||||
|
self.addCleanup(self.server.server_close)
|
||||||
|
self.addCleanup(self.server.shutdown)
|
||||||
|
self.url = f"http://127.0.0.1:{self.server.server_port}"
|
||||||
|
|
||||||
|
def warm(self, session, draft):
|
||||||
|
chat.post(self.url, "/warm", {"session": session, "messages": [], "draft": draft}).read()
|
||||||
|
|
||||||
|
def ask(self, session, text):
|
||||||
|
return chat.post(self.url, "/chat", {"session": session,
|
||||||
|
"messages": [{"role": "user", "content": text}]})
|
||||||
|
|
||||||
|
def test_page_warm_up_and_chat(self):
|
||||||
|
with urllib.request.urlopen(self.url) as response:
|
||||||
|
self.assertIn(b'<textarea id="draft"', response.read())
|
||||||
|
self.warm("s1", "Why is ")
|
||||||
|
wait_for(lambda: self.stub.bodies)
|
||||||
|
self.assertEqual(self.stub.bodies[0]["messages"], [{"role": "user", "content": "Why is"}])
|
||||||
|
self.assertEqual(self.stub.bodies[0]["priority"], 1)
|
||||||
|
with self.ask("s1", "Why is") as r:
|
||||||
|
self.assertEqual(events(r), ["Hel", "lo", "!", "<done>"])
|
||||||
|
self.assertEqual(self.stub.bodies[1]["model"], "stub-model")
|
||||||
|
self.assertNotIn("vllm_xargs", self.stub.bodies[1])
|
||||||
|
|
||||||
|
def test_sessions_warm_up_independently(self):
|
||||||
|
self.stub.delay = 0.3
|
||||||
|
self.warm("alice", "first")
|
||||||
|
self.warm("bob", "second") # alice's warm-up is in flight; bob's is not dropped
|
||||||
|
wait_for(lambda: len(self.stub.bodies) == 2)
|
||||||
|
self.assertEqual({b["messages"][-1]["content"] for b in self.stub.bodies},
|
||||||
|
{"first", "second"})
|
||||||
|
self.assertEqual(set(self.server.sessions), {"alice", "bob"})
|
||||||
|
|
||||||
|
def test_no_warm_up_while_another_answer_streams(self):
|
||||||
|
self.server.streams[0] = 1 # someone else's answer is streaming
|
||||||
|
self.warm("bob", "typing along")
|
||||||
|
self.server.streams[0] = 0
|
||||||
|
self.warm("bob", "now")
|
||||||
|
wait_for(lambda: self.stub.bodies)
|
||||||
|
self.assertEqual([b["messages"][-1]["content"] for b in self.stub.bodies], ["now"])
|
||||||
|
|
||||||
|
def test_always_mode_warms_up_while_a_request_slot_is_free(self):
|
||||||
|
self.start(warmup="always", serve=Serve(max_requests=3))
|
||||||
|
self.server.streams[0] = 3 # every vLLM request slot is busy
|
||||||
|
self.warm("bob", "no slot")
|
||||||
|
self.server.streams[0] = 2
|
||||||
|
self.warm("bob", "one slot")
|
||||||
|
wait_for(lambda: self.stub.bodies)
|
||||||
|
self.assertEqual([b["messages"][-1]["content"] for b in self.stub.bodies], ["one slot"])
|
||||||
|
|
||||||
|
def test_vllm_errors_reach_the_browser(self):
|
||||||
|
self.stub.fail = 400
|
||||||
|
with self.assertRaises(urllib.error.HTTPError) as caught:
|
||||||
|
chat.post(self.url, "/chat", {"session": "s", "messages": []})
|
||||||
|
self.assertEqual(caught.exception.code, 400)
|
||||||
|
with caught.exception as error:
|
||||||
|
self.assertIn(b"rejected", error.read())
|
||||||
|
wait_for(lambda: self.server.streams[0] == 0)
|
||||||
|
|
||||||
|
def test_second_answer_drafts_now_and_refines_when_the_first_one_ends(self):
|
||||||
|
self.stub.delay = lambda body: 0.0 if "vllm_xargs" in body else 0.5 # full answers are slow
|
||||||
|
alice = []
|
||||||
|
thread = threading.Thread(target=lambda: alice.extend(events(self.ask("alice", "a"))))
|
||||||
|
thread.start()
|
||||||
|
wait_for(lambda: self.server.full[0] == 1)
|
||||||
|
with self.ask("bob", "b") as r:
|
||||||
|
bob = events(r)
|
||||||
|
thread.join()
|
||||||
|
self.assertEqual(alice, ["Hel", "lo", "!", "<done>"])
|
||||||
|
self.assertEqual(bob, ["<draft>", "Hel", "lo", "!", "<refine>", "Hel", "lo", "!", "<done>"])
|
||||||
|
self.assertEqual([(b["messages"][0]["content"], b.get("vllm_xargs"), b.get("cache_salt"))
|
||||||
|
for b in self.stub.bodies],
|
||||||
|
[("a", None, None), ("b", {"prain": "draft"}, "prain-draft"),
|
||||||
|
("b", None, None)])
|
||||||
|
wait_for(lambda: self.server.full[0] == 0 and self.server.streams[0] == 0)
|
||||||
|
|
||||||
|
def test_a_running_draft_is_cut_short_once_a_full_slot_frees(self):
|
||||||
|
self.stub.delay = lambda body: 0.0 if "vllm_xargs" in body else 0.3
|
||||||
|
self.stub.pause = lambda body: 0.6 if "vllm_xargs" in body else 0.0 # drafts trickle
|
||||||
|
thread = threading.Thread(target=lambda: self.ask("alice", "a").read())
|
||||||
|
thread.start()
|
||||||
|
wait_for(lambda: self.server.full[0] == 1)
|
||||||
|
with self.ask("bob", "b") as r: # alice ends at 0.3 s, between bob's "Hel" and "lo"
|
||||||
|
self.assertEqual(events(r), ["<draft>", "Hel", "lo", "<refine>", "Hel", "lo", "!",
|
||||||
|
"<done>"])
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
def test_refine_none_keeps_the_draft_and_more_full_slots_mean_no_draft(self):
|
||||||
|
self.start(draft=Draft(refine="none"))
|
||||||
|
self.stub.delay = lambda body: 0.0 if "vllm_xargs" in body else 0.5
|
||||||
|
thread = threading.Thread(target=lambda: self.ask("alice", "a").read())
|
||||||
|
thread.start()
|
||||||
|
wait_for(lambda: self.server.full[0] == 1)
|
||||||
|
with self.ask("bob", "b") as r:
|
||||||
|
self.assertEqual(events(r), ["<draft>", "Hel", "lo", "!", "<done>"])
|
||||||
|
thread.join()
|
||||||
|
self.start(serve=Serve(max_requests=2), draft=Draft(full_requests=2))
|
||||||
|
thread = threading.Thread(target=lambda: self.ask("alice", "a").read())
|
||||||
|
thread.start()
|
||||||
|
wait_for(lambda: self.server.full[0] == 1)
|
||||||
|
with self.ask("bob", "b") as r:
|
||||||
|
self.assertEqual(events(r), ["Hel", "lo", "!", "<done>"])
|
||||||
|
thread.join()
|
||||||
|
self.assertEqual(sum("vllm_xargs" in b for b in self.stub.bodies), 1)
|
||||||
Reference in New Issue
Block a user