booper-chat / app.py
ProCreations's picture
booper-chat: streaming chat UI over the 34M Discord model
086024b verified
Raw History Blame Contribute Delete
5.36 kB
"""booper-chat, served.
A 34M-parameter model answers in well under a second on CPU, so the whole
design here is about not squandering that: weights are baked into the image
(no cold download), the page is one static file with no framework to boot,
and tokens go out over SSE as they are produced rather than after the reply
is finished.
"""
from __future__ import annotations
import json
import os
import threading
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from booper.engine import Booper, SamplingParams, Turn
ROOT = Path(__file__).parent
MODEL_DIR = Path(os.environ.get("BOOPER_MODEL_DIR", ROOT / "weights"))
MODEL_ID = "ProCreations/booper-chat"
# One generation at a time. The Space runs on 2 vCPUs and torch already uses
# both for a single decode, so overlapping two requests makes each of them
# slower rather than making the pair faster; queueing keeps every reply fast
# and the tok/s number honest.
_generation_lock = threading.Lock()
MAX_QUEUE = int(os.environ.get("BOOPER_MAX_QUEUE", "24"))
_waiting = threading.Semaphore(MAX_QUEUE)
# Input caps. The 1024-token window truncates anything longer anyway, but the
# tokenizer would still have to chew through whatever arrives first.
MAX_TURNS = 400
MAX_CHARS = 60_000
booper = Booper(MODEL_DIR / "model.pt", MODEL_DIR / "tokenizer.json")
print(
f"[booper] {booper.n_params:,} params · block {booper.block_size} · "
f"vocab {booper.tok.vocab_size} · backend {booper.backend}",
flush=True,
)
app = FastAPI(title="booper-chat", docs_url=None, redoc_url=None)
class Message(BaseModel):
role: str = "user"
content: str = ""
class ChatRequest(BaseModel):
messages: list[Message] = Field(default_factory=list)
max_new_tokens: int = 96
temperature: float = 0.7
top_k: int = 40
top_p: float = 0.9
repetition_penalty: float = 1.15
frequency_penalty: float = 0.12
presence_penalty: float = 0.0
no_repeat_ngram_size: int = 3
seed: int | None = None
def _sse(event: str, payload: dict) -> str:
return f"event: {event}\ndata: {json.dumps(payload, separators=(',', ':'))}\n\n"
@app.get("/api/meta")
def meta() -> JSONResponse:
return JSONResponse(
{
"model": MODEL_ID,
"params": booper.n_params,
"context": booper.block_size,
"vocab": booper.tok.vocab_size,
"layers": booper.config.n_layer,
"width": booper.config.n_embd,
"heads": booper.config.n_head,
"quantized": booper.quantized,
"defaults": SamplingParams().__dict__,
}
)
@app.post("/api/chat")
def chat(request: ChatRequest) -> StreamingResponse:
turns = [
Turn("assistant" if m.role == "assistant" else "user", m.content)
for m in request.messages[-MAX_TURNS:]
if m.content and m.content.strip()
]
total = sum(len(t.content) for t in turns)
while total > MAX_CHARS and len(turns) > 1:
total -= len(turns.pop(0).content)
if turns:
turns[-1] = Turn(turns[-1].role, turns[-1].content[:MAX_CHARS])
params = SamplingParams(
max_new_tokens=request.max_new_tokens,
temperature=request.temperature,
top_k=request.top_k,
top_p=request.top_p,
repetition_penalty=request.repetition_penalty,
frequency_penalty=request.frequency_penalty,
presence_penalty=request.presence_penalty,
no_repeat_ngram_size=request.no_repeat_ngram_size,
seed=request.seed,
)
def body():
if not turns:
yield _sse("error", {"message": "say something first"})
return
if not _waiting.acquire(blocking=False):
yield _sse("error", {"message": "too many people talking to booper at once — try again"})
return
try:
queued = not _generation_lock.acquire(blocking=False)
if queued:
yield _sse("queued", {})
_generation_lock.acquire()
try:
for kind, payload in booper.stream(turns, params):
if kind == "token":
yield _sse("token", {"t": payload})
else:
yield _sse("done", payload)
finally:
_generation_lock.release()
except Exception as exc: # never leave the client hanging on a dead stream
yield _sse("error", {"message": f"{type(exc).__name__}: {exc}"})
finally:
_waiting.release()
return StreamingResponse(
body(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
# Spaces sit behind a proxy that will happily buffer a whole SSE
# stream and deliver it as one lump, which would silently undo
# every bit of the streaming above.
"X-Accel-Buffering": "no",
},
)
@app.get("/")
def index() -> FileResponse:
return FileResponse(ROOT / "static" / "index.html", headers={"Cache-Control": "no-cache"})
app.mount("/", StaticFiles(directory=ROOT / "static"), name="static")