File size: 34,337 Bytes
b446ce4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 | """The replica router (D-031): one public Space in front of two private model Spaces.
Design and limits: docs/SERVING.md "Scaling beyond one server". No torch here: the router
holds no model, so it has a small image and starts quickly.
The decisions do no I/O: `classify` says what a replica's reply means, `Picker` chooses
where a request goes. `ReplicaRouter` carries a visitor's request to a replica and back.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import math
import os
import re
import time
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from dataclasses import asdict, dataclass
from enum import Enum
import httpx
from fastapi import FastAPI
from fastapi import Request as HTTPRequest
from fastapi.responses import HTMLResponse, Response, StreamingResponse
from starlette.requests import ClientDisconnect
from tinyllm.obs import EngineSnapshot, Metrics, RequestRecord, itl_stats, setup_logging
from tinyllm.web_common import (CLIENT_HEADER, HeadersMiddleware, RateLimiter, client_hash, error_response, read_page,
resolve_salt)
log = logging.getLogger("tinyllm.router")
class Verdict(Enum):
"""What one reply from a replica means. See `classify`."""
OK = "ok" # our server answered 2xx
NOT_READY = "not_ready" # 503 JSON: starting, draining or engine failed
ASLEEP = "asleep" # HF's paused page: wake it if traffic needs it
DOWN = "down" # 502, connection error, timeout, or a page that isn't ours
MISCONFIGURED = "misconfigured" # token or URL is wrong: never wake, log, show on the status page
BUSY = "busy" # 429 server_busy: queue full, try another replica
PASS = "pass" # any other answer from our server: the visitor should see it
# A request that gets one of these never reached a working model, so another replica may try it.
RETRYABLE = frozenset({Verdict.NOT_READY, Verdict.ASLEEP, Verdict.DOWN, Verdict.MISCONFIGURED, Verdict.BUSY})
@dataclass(frozen=True)
class Reply:
"""A replica's answer. `status` is None for a connection error or timeout."""
status: int | None
content_type: str = ""
text: str = "" # the body; only read when the status isn't 2xx
def _error_type(text: str) -> str | None:
try:
return json.loads(text)["error"]["type"]
except (ValueError, KeyError, TypeError):
return None
def classify(reply: Reply) -> Verdict:
"""The reply table in docs/SERVING.md, as code. Anything unrecognised is DOWN: never ready,
never woken, and never shown to a visitor, because it didn't come from our server."""
status = reply.status
if status is None or status == 502 or 300 <= status < 400: # redirects aren't followed: the token stays here
return Verdict.DOWN
if status in (401, 403): # HF refused the token, whatever the format of its reply
return Verdict.MISCONFIGURED
kind = reply.content_type.split(";")[0].strip().lower()
if kind == "text/html": # our API never answers with HTML, so this is HF's own page
if status == 404:
return Verdict.MISCONFIGURED
if status == 503 and "paused" in reply.text.lower():
return Verdict.ASLEEP
return Verdict.DOWN
if kind not in ("application/json", "text/event-stream"):
return Verdict.DOWN
if 200 <= status < 300:
return Verdict.OK
if status == 503:
return Verdict.NOT_READY
if status == 429 and _error_type(reply.text) == "server_busy":
return Verdict.BUSY
return Verdict.PASS
@dataclass(eq=False)
class Replica:
"""What the router knows about one replica."""
name: str # shown to visitors in X-Replica; never the private URL
url: str
verdict: Verdict | None = None # from the last poll; None = not polled yet
outstanding: int = 0 # from the last poll
n_slots: int = 0
sent_since_poll: int = 0 # a poll can be 5 s old, so count what we have sent since
polled_at: float | None = None
space_id: str = "" # owner/name on the Hub, for waking it
woken_at: float | None = None
model: dict | None = None # id, version and norm_backend, from the last readable /stats
@property
def ready(self) -> bool:
return self.verdict is Verdict.OK
@property
def load(self) -> int:
return self.outstanding + self.sent_since_poll
def refused(self, verdict: Verdict) -> None:
"""A request came back with a RETRYABLE verdict. Only a full queue leaves it ready."""
self.sent_since_poll = max(0, self.sent_since_poll - 1)
if verdict is not Verdict.BUSY:
self.verdict = verdict
def polled(self, verdict: Verdict, now: float, outstanding: int = 0, n_slots: int = 0) -> None:
self.verdict, self.polled_at = verdict, now
self.outstanding, self.n_slots, self.sent_since_poll = outstanding, n_slots, 0
class Picker:
"""Shortest queue among the ready replicas; ties go round-robin. Event loop only."""
def __init__(self, replicas: list[Replica]):
self.replicas = replicas
self._last = -1
def pick(self, exclude: tuple[Replica, ...] = ()) -> Replica | None:
"""The replica to send to, counted as sent; None if no ready replica is left."""
n = len(self.replicas)
in_turn = [self.replicas[(self._last + 1 + i) % n] for i in range(n)]
ready = [r for r in in_turn if r.ready and r not in exclude]
if not ready:
return None
choice = min(ready, key=lambda r: r.load) # min keeps the first of equals: the next in turn
self._last = self.replicas.index(choice)
choice.sent_since_poll += 1
return choice
# -- the request path -------------------------------------------------------------------
MAX_ATTEMPTS = 2 # the first try and at most one retry
NO_REPLICA = "none" # a record's `variant` when no replica was reached
_PASSED_HEADERS = ("content-type", "cache-control", "retry-after") # all a visitor sees of a replica's headers
_STREAM_ERROR = (b'data: {"error": {"message": "the replica failed", "type": "server_error", "code": 502}}\n\n'
b"data: [DONE]\n\n")
@dataclass
class RouterConfig:
rate_per_min: float = 20.0
rate_burst: int = 5
trust_proxy: bool = False # visitor address from X-Forwarded-For (on a Space)
proxy_hops: int = 1
salt: str | None = None
connect_timeout_s: float = 3.0
read_timeout_s: float = 65.0 # longer than a replica's own 60 s limit, so the replica answers first
max_body_bytes: int = 1 << 20
waking_retry_s: int = 30 # Retry-After when no replica is ready; a wake measured 35 s
poll_interval_s: float = 5.0
poll_timeout_s: float = 2.0 # for a whole poll of one replica
idle_stop_s: float = 600.0 # polling stops this long after the last visitor request
wake_cooldown_s: float = 300.0 # per replica
model_label: str | None = None # for /v1/models and the status page before any replica has been polled
drain_s: float = 30.0
class _ClientGone(Exception):
"""The visitor disconnected."""
class _BodyTooLarge(Exception):
pass
class _NoAnswer(Exception):
"""A replica accepted the request and then went silent. It keeps the request; no retry."""
def _json(data: bytes) -> dict:
try:
out = json.loads(data)
except ValueError:
return {}
return out if isinstance(out, dict) else {}
def _wants_stream(body: bytes) -> bool:
return _json(body).get("stream") is True
async def _read_body(request: HTTPRequest, limit: int) -> bytes:
declared = request.headers.get("content-length", "")
if declared.isdigit() and int(declared) > limit:
raise _BodyTooLarge
parts, size = [], 0
async for chunk in request.stream():
size += len(chunk)
if size > limit:
raise _BodyTooLarge
parts.append(chunk)
return b"".join(parts)
async def _unless_gone(request: HTTPRequest, work: Awaitable):
"""Await `work`; if the visitor leaves first, cancel it (which closes the replica's connection)."""
task = asyncio.ensure_future(work)
try:
while True:
done, _ = await asyncio.wait({task}, timeout=0.25)
if done:
return task.result()
if await request.is_disconnected():
raise _ClientGone
finally:
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
class _StreamMeter:
"""Reads the events of a stream as they pass through, for the request record."""
def __init__(self):
self.buffer = b""
self.times: list[float] = []
self.prompt_tokens = self.completion_tokens = 0
self.finish: str | None = None
self.error: dict | None = None
self.done = False
def feed(self, chunk: bytes, now: float) -> None:
self.buffer += chunk
*events, self.buffer = self.buffer.split(b"\n\n")
for event in events:
self._event(event, now)
def _event(self, event: bytes, now: float) -> None:
if not event.startswith(b"data: "):
return
payload = event[len(b"data: "):]
if payload == b"[DONE]":
self.done = True
return
d = _json(payload)
if isinstance(d.get("error"), dict):
self.error = d["error"]
elif d.get("choices"):
self.times.append(now)
self.finish = d["choices"][0].get("finish_reason") or self.finish
usage = d.get("usage") or {}
self.prompt_tokens = usage.get("prompt_tokens", self.prompt_tokens)
self.completion_tokens = usage.get("completion_tokens", self.completion_tokens)
def _reply(resp: httpx.Response | None) -> Reply:
return Reply(None) if resp is None else Reply(resp.status_code, resp.headers.get("content-type", ""), resp.text)
def _load(stats: bytes) -> tuple[int, int] | None:
"""(outstanding, n_slots) from a replica's /stats, or None if it doesn't have them."""
variants = _json(stats).get("variants")
if not isinstance(variants, dict) or not variants:
return None
try:
return sum(int(v["outstanding"]) for v in variants.values()), sum(int(v["n_slots"]) for v in variants.values())
except (KeyError, TypeError, ValueError):
return None
def _model(stats: bytes) -> dict | None:
"""What a replica serves, from its /stats. A replica has one variant."""
variants = _json(stats).get("variants")
if not isinstance(variants, dict) or not variants:
return None
name, v = next(iter(variants.items()))
return {"id": name, "version": v.get("version"), "norm_backend": v.get("norm_backend")} if isinstance(v, dict) else None
class ReplicaRouter:
def __init__(self, replicas: list[Replica], cfg: RouterConfig, token: str | None = None,
metrics: Metrics | None = None, wake: Callable[[Replica], None] | None = None):
self.cfg = cfg
self.replicas = replicas
self.picker = Picker(replicas)
self.limiter = RateLimiter(cfg.rate_per_min, cfg.rate_burst)
self.salt, self.salt_source = resolve_salt(cfg.salt)
self.metrics = metrics or Metrics()
self.http: httpx.AsyncClient | None = None # opened at startup
self._token = token
self.wake = wake # None: never wake
self.last_traffic = 0.0
self._poller: asyncio.Task | None = None # running only while visitors are active
self._fresh = asyncio.Event() # set after the first poll round since idle
self._background: set[asyncio.Task] = set()
self._tokens_since_snap = 0
self._last_snap = time.monotonic()
def record(self, rec: RequestRecord, arrival: float) -> None:
rec.latency_s = time.monotonic() - arrival
self.metrics.observe(rec)
self._tokens_since_snap += rec.completion_tokens
log.info("request", extra={"fields": rec.to_dict()})
def error(self, rec: RequestRecord, arrival: float, status: int, message: str, err_type: str,
headers: dict | None = None) -> Response:
rec.status, rec.error, rec.finish_reason = status, message, "timeout" if status == 504 else "error"
self.record(rec, arrival)
return error_response(status, message, err_type, headers)
def gone(self, rec: RequestRecord, arrival: float) -> Response:
rec.status, rec.finish_reason = 499, "abort"
self.record(rec, arrival)
return Response(status_code=499)
def _auth(self) -> dict:
return {"authorization": f"Bearer {self._token}"} if self._token else {}
async def traffic(self, request: HTTPRequest) -> None:
"""A visitor's request is going to be routed. Only this starts polling, so an open status tab
can't keep replicas awake. After an idle spell what we know is stale: wait for one poll round."""
self.last_traffic = time.monotonic()
if self._poller is None:
self._fresh = asyncio.Event()
self._poller = asyncio.create_task(self._poll_loop())
if not self._fresh.is_set():
await _unless_gone(request, self._fresh.wait())
async def _poll_loop(self) -> None:
try:
while True:
await self.poll_round()
self._fresh.set()
if time.monotonic() - self.last_traffic >= self.cfg.idle_stop_s:
return
await asyncio.sleep(self.cfg.poll_interval_s)
finally:
self._poller = None # no await since the check above, so no request can slip in between
self._fresh.set()
async def poll_round(self) -> None:
results = await asyncio.gather(*(self.poll(r) for r in self.replicas), return_exceptions=True)
for replica, result in zip(self.replicas, results):
if isinstance(result, Exception):
log.error("poll failed", extra={"fields": {"replica": replica.name, "error": type(result).__name__}})
replica.polled(Verdict.DOWN, time.monotonic())
self._snapshot()
async def _get(self, replica: Replica, path: str) -> httpx.Response | None:
"""None if there was no answer. The exception's text names the private URL, so it's dropped."""
try:
return await self.http.get(f"{replica.url}{path}", headers=self._auth(), timeout=self.cfg.poll_timeout_s)
except httpx.TransportError:
return None
async def poll(self, replica: Replica) -> None:
try:
ready, stats = await asyncio.wait_for(asyncio.gather(self._get(replica, "/ready"), self._get(replica, "/stats")),
self.cfg.poll_timeout_s)
except asyncio.TimeoutError:
ready = stats = None
before, verdict, now = replica.verdict, classify(_reply(ready)), time.monotonic()
if verdict is Verdict.OK:
readable = classify(_reply(stats)) is Verdict.OK
load = _load(stats.content) if readable else None
replica.model = (_model(stats.content) if readable else None) or replica.model
# It said it's ready but not how full: keep our estimate rather than make it look idle.
replica.polled(verdict, now, *(load or (replica.load, replica.n_slots)))
else:
replica.polled(verdict, now)
if verdict is not before:
level = logging.ERROR if verdict is Verdict.MISCONFIGURED else logging.INFO
log.log(level, "replica state", extra={"fields": {"replica": replica.name, "state": verdict.value}})
def _snapshot(self) -> None:
"""For the status page's load chart. A gap in the chart means no replica was ready."""
now = time.monotonic()
elapsed = max(now - self._last_snap, 1e-9)
tokens, self._tokens_since_snap, self._last_snap = self._tokens_since_snap, 0, now
up = [r for r in self.replicas if r.ready]
if not up:
return
busy = sum(min(r.outstanding, r.n_slots) for r in up)
queued = sum(r.outstanding for r in up) - busy
snap = EngineSnapshot(time.time(), queued, busy, sum(r.n_slots for r in up), round(tokens / elapsed, 2),
0.0) # the router holds no KV cache
self.metrics.snapshot(snap)
if busy or queued or tokens:
log.info("engine", extra={"fields": {"variant": "router", **asdict(snap)}})
def _known_model(self) -> dict | None:
return next((r.model for r in self.replicas if r.model), None)
def models(self) -> dict:
model, label = self._known_model(), self.cfg.model_label
data = []
if model or label:
data = [{"id": model["id"] if model else label, "object": "model", "owned_by": "tinyllm",
"version": model["version"] if model else label}]
return {"object": "list", "data": data}
def describe(self, out: dict) -> dict:
"""Adds the fields the pages read. Names and states only: never a URL or a Space id."""
model, up, now = self._known_model() or {}, [r for r in self.replicas if r.ready], time.monotonic()
name = model.get("id") or self.cfg.model_label or "router"
out["variants"] = {name: {"version": model.get("version") or self.cfg.model_label,
"outstanding": sum(r.load for r in up), "n_slots": sum(r.n_slots for r in up),
"failed": None, "norm_backend": model.get("norm_backend")}}
out["polling"] = self._poller is not None
out["replicas"] = [{"name": r.name, "state": r.verdict.value if r.verdict else None, "queue": r.load,
"n_slots": r.n_slots,
"polled_s_ago": None if r.polled_at is None else round(now - r.polled_at, 1)}
for r in self.replicas]
return out
def wake_asleep(self, now: float) -> None:
"""Only a replica HF says is paused: restarting a running one would kill its requests."""
if self.wake is None:
return
for r in self.replicas:
if r.verdict is Verdict.ASLEEP and (r.woken_at is None or now - r.woken_at >= self.cfg.wake_cooldown_s):
r.woken_at = now
task = asyncio.create_task(self._wake(r))
self._background.add(task)
task.add_done_callback(self._background.discard)
async def _wake(self, replica: Replica) -> None:
log.info("waking replica", extra={"fields": {"replica": replica.name}})
try:
await asyncio.to_thread(self.wake, replica)
except Exception as e: # its text may name the private Space
log.error("wake failed", extra={"fields": {"replica": replica.name, "error": type(e).__name__}})
async def stop(self) -> None:
tasks = [*self._background, *([self._poller] if self._poller else [])]
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
async def completions(self, request: HTTPRequest) -> Response:
arrival = time.monotonic()
client = client_hash(request, self.cfg.trust_proxy, self.cfg.proxy_hops, self.salt)
rec = RequestRecord(request.state.request_id, 0, variant=NO_REPLICA, client=client, arrival_unix=time.time())
wait = self.limiter.check(client, arrival)
if wait:
return self.error(rec, arrival, 429, "rate limit exceeded", "rate_limit_error",
{"Retry-After": str(math.ceil(wait))})
try:
body = await _read_body(request, self.cfg.max_body_bytes)
except _BodyTooLarge:
return self.error(rec, arrival, 413, "request body is too large", "invalid_request_error")
except ClientDisconnect:
return self.gone(rec, arrival)
rec.stream = _wants_stream(body)
try:
await self.traffic(request)
except _ClientGone:
return self.gone(rec, arrival)
return await self._route(request, rec, arrival, body)
async def _route(self, request: HTTPRequest, rec: RequestRecord, arrival: float, body: bytes) -> Response:
tried: tuple[Replica, ...] = ()
busy = False
try:
for _ in range(MAX_ATTEMPTS):
replica = self.picker.pick(exclude=tried)
if replica is None:
break
tried += (replica,)
rec.variant = replica.name
verdict, resp, data = await self._send(request, replica, body, rec.client)
if verdict not in RETRYABLE:
return await self._answer(request, rec, arrival, replica, verdict, resp, data)
replica.refused(verdict)
busy = busy or verdict is Verdict.BUSY
except _ClientGone:
return self.gone(rec, arrival)
except _NoAnswer:
return self.error(rec, arrival, 504, "the replica did not answer in time", "server_error")
if busy or not any(r.ready for r in self.replicas):
self.wake_asleep(time.monotonic())
if not tried:
rec.variant = NO_REPLICA
if busy:
return self.error(rec, arrival, 429, "server busy: queue full, retry later", "server_busy",
{"Retry-After": "1"})
return self.error(rec, arrival, 503, "no replica is ready: waking up, retry shortly", "waking_up",
{"Retry-After": str(self.cfg.waking_retry_s)})
def _outgoing(self, request: HTTPRequest, client: str) -> dict:
"""Built from nothing, so nothing else the visitor sent (cookies, Authorization) reaches a replica."""
headers = {"content-type": request.headers.get("content-type", "application/json"),
"accept-encoding": "identity", "x-request-id": request.state.request_id, CLIENT_HEADER: client}
return {**headers, **self._auth()}
async def _send(self, request: HTTPRequest, replica: Replica, body: bytes,
client: str) -> tuple[Verdict, httpx.Response | None, bytes]:
"""Until the reply's headers (and its body, unless it's a 2xx). The exception text names the
private URL, so only its type is logged."""
req = self.http.build_request("POST", f"{replica.url}/v1/completions", content=body,
headers=self._outgoing(request, client))
try:
resp = await _unless_gone(request, self.http.send(req, stream=True))
content_type = resp.headers.get("content-type", "")
if classify(Reply(resp.status_code, content_type)) is Verdict.OK:
return Verdict.OK, resp, b""
data = await _unless_gone(request, resp.aread())
except httpx.ReadTimeout as e:
raise _NoAnswer from e
except httpx.TransportError as e:
log.warning("replica unreachable", extra={"fields": {"replica": replica.name, "error": type(e).__name__}})
return classify(Reply(None)), None, b""
return classify(Reply(resp.status_code, content_type, data.decode("utf-8", "replace"))), resp, data
async def _answer(self, request: HTTPRequest, rec: RequestRecord, arrival: float, replica: Replica,
verdict: Verdict, resp: httpx.Response, data: bytes) -> Response:
rec.model_version = resp.headers.get("x-model-version", "")
if rec.model_version:
request.state.model_version = rec.model_version
headers = {k: resp.headers[k] for k in _PASSED_HEADERS if k in resp.headers}
headers["X-Replica"] = replica.name
if verdict is Verdict.OK and "text/event-stream" in resp.headers.get("content-type", ""):
return StreamingResponse(self._relay(resp, rec, arrival), status_code=resp.status_code, headers=headers)
if verdict is Verdict.OK:
try:
data = await _unless_gone(request, resp.aread())
except httpx.TransportError:
return self.error(rec, arrival, 502, "the replica failed", "server_error")
d = _json(data)
rec.status = resp.status_code
if verdict is Verdict.OK:
# Without streaming, the visitor's first token arrives with the whole response.
rec.ttft_s = time.monotonic() - arrival
usage, choices = d.get("usage") or {}, d.get("choices") or [{}]
rec.prompt_tokens = usage.get("prompt_tokens", 0)
rec.completion_tokens = usage.get("completion_tokens", 0)
rec.finish_reason = choices[0].get("finish_reason")
else:
err = d.get("error")
rec.error = err.get("message") if isinstance(err, dict) else None
rec.finish_reason = "timeout" if rec.status == 504 else "error"
self.record(rec, arrival)
return Response(data, status_code=resp.status_code, headers=headers)
async def _relay(self, resp: httpx.Response, rec: RequestRecord, arrival: float) -> AsyncIterator[bytes]:
"""Pass a stream through. Nothing can be retried now: the visitor already has part of the story."""
meter, finished = _StreamMeter(), False
try:
try:
async for chunk in resp.aiter_bytes():
meter.feed(chunk, time.monotonic())
yield chunk
except httpx.TransportError as e:
log.warning("replica stream failed", extra={"fields": {"replica": rec.variant, "error": type(e).__name__}})
if meter.done:
rec.status = meter.error.get("code", 500) if meter.error else 200
rec.error = meter.error.get("message") if meter.error else None
rec.finish_reason = ("timeout" if rec.status == 504 else "error") if meter.error else meter.finish
else:
rec.status, rec.error, rec.finish_reason = 502, "the replica failed", "error"
finished = True
yield (b"\n\n" if meter.buffer else b"") + _STREAM_ERROR
finished = True
finally:
if not finished:
rec.status, rec.finish_reason = 499, "abort"
if meter.times:
rec.ttft_s = meter.times[0] - arrival
rec.itl_p50_s, rec.itl_p95_s = itl_stats(meter.times)
rec.prompt_tokens, rec.completion_tokens = meter.prompt_tokens, meter.completion_tokens
self.record(rec, arrival)
await resp.aclose()
def hf_wake(token: str | None) -> Callable[[Replica], None]:
"""A wake function that restarts the replica's Space on the Hub. It blocks: run it in a thread."""
def wake(replica: Replica) -> None:
from huggingface_hub import HfApi
HfApi(token=token).restart_space(replica.space_id)
return wake
def create_router_app(replicas: list[Replica], cfg: RouterConfig | None = None, token: str | None = None,
metrics: Metrics | None = None, wake: Callable[[Replica], None] | None = None,
shipper=None) -> FastAPI:
"""Only the routes listed here exist, so no other path of a replica can be reached through the router."""
cfg = cfg or RouterConfig()
router = ReplicaRouter(replicas, cfg, token, metrics, wake)
@asynccontextmanager
async def lifespan(app: FastAPI):
timeout = httpx.Timeout(connect=cfg.connect_timeout_s, read=cfg.read_timeout_s, write=10.0,
pool=cfg.connect_timeout_s)
# trust_env off: a proxy set in the environment must never receive the token
async with httpx.AsyncClient(timeout=timeout, trust_env=False, follow_redirects=False) as client:
router.http = client
log.info("server ready", extra={"fields": {"replicas": [r.name for r in replicas], "wake": wake is not None,
"slo_ttft_s": router.metrics.slo_ttft_s, "salt": router.salt_source,
"proxy_hops": cfg.proxy_hops if cfg.trust_proxy else 0}})
yield
await router.stop()
log.info("stopped")
if shipper is not None:
await asyncio.to_thread(shipper.close, cfg.drain_s)
app = FastAPI(title="tinyllm router", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
app.state.router = router
app.add_middleware(HeadersMiddleware, default_version="router")
# All of these only report what the router already knows: they never poll, wake or count as traffic.
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/ready")
async def ready():
return {"ready": True}
@app.get("/v1/models")
async def models():
return router.models()
@app.get("/stats")
async def stats():
return router.describe(router.metrics.stats())
history_cache: dict = {}
@app.get("/stats/history")
async def stats_history():
"""For the status page: computed at most once a second, however many pages poll it."""
now = time.monotonic()
if history_cache.get("t", -1.0) < now - 1.0:
history_cache.update(t=now, out=router.describe(router.metrics.history()))
return history_cache["out"]
@app.get("/", response_class=HTMLResponse)
async def demo_page():
return HTMLResponse(read_page("demo.html"))
@app.get("/status", response_class=HTMLResponse)
async def status_page():
return HTMLResponse(read_page("status.html"))
@app.post("/v1/completions")
async def completions(request: HTTPRequest):
return await router.completions(request)
return app
# -- command line -----------------------------------------------------------------------
REPLICA_TOKEN_ENV = "TINYLLM_REPLICA_TOKEN" # never a flag: arguments are visible to other processes
_REPLICA_NAME = re.compile(r"[A-Za-z0-9_.-]{1,32}")
_SPACE_ID = re.compile(r"[\w.-]+/[\w.-]+")
def parse_replica(spec: str) -> Replica:
"""`name=url` or `name=url,owner/space`; the Space id is what waking needs."""
name, sep, rest = spec.partition("=")
url, _, space = rest.partition(",")
url = url.rstrip("/")
if not (sep and _REPLICA_NAME.fullmatch(name) and url.startswith(("http://", "https://"))
and (not space or _SPACE_ID.fullmatch(space))):
raise ValueError(f"expected name=url[,owner/space], got {spec!r}")
return Replica(name, url, space_id=space)
def main(argv: list[str] | None = None) -> None:
p = argparse.ArgumentParser(description="tinyllm replica router (D-031)")
p.add_argument("--replica", action="append", required=True, type=parse_replica, metavar="NAME=URL[,OWNER/SPACE]",
help="one per replica; waking needs the Space id")
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8000)
p.add_argument("--rate-limit", type=float, default=20.0, help="requests/min per visitor; 0 = off")
p.add_argument("--rate-burst", type=int, default=5)
p.add_argument("--trust-proxy", action="store_true", help="visitor address from X-Forwarded-For")
p.add_argument("--proxy-hops", type=int, default=1,
help="with --trust-proxy: trusted proxies in front (visitor = that many entries from the right)")
p.add_argument("--slo-ttft-s", type=float, default=1.0, help="TTFT SLO threshold on /status")
p.add_argument("--model-label", default=None, help="shown as the model until a replica has been polled")
p.add_argument("--drain-s", type=float, default=30.0)
p.add_argument("--log-level", default="info", choices=["debug", "info", "warning", "error"])
p.add_argument("--log-file", default=None, help="also write JSON lines here")
p.add_argument("--log-dir", default=None, help="write the shipped log lines as hourly JSONL here (logship.py)")
p.add_argument("--log-dataset", default=None, metavar="REPO_ID", help="with --log-dir: upload to this private HF dataset")
p.add_argument("--log-replica", default=None, help="name in the log file names; default $SPACE_ID or local")
args = p.parse_args(argv)
if len({r.name for r in args.replica}) != len(args.replica):
p.error("--replica names must be different")
if args.log_dataset and not args.log_dir:
p.error("--log-dataset needs --log-dir")
if args.proxy_hops < 1:
p.error("--proxy-hops must be at least 1")
token = os.environ.get(REPLICA_TOKEN_ENV) or None
if token and not all(r.space_id for r in args.replica):
p.error(f"with ${REPLICA_TOKEN_ENV} set, every --replica needs its Space id, so it can be woken")
setup_logging(args.log_level, args.log_file)
shipper = None
if args.log_dir:
from tinyllm.logship import LogShipper
shipper = LogShipper(args.log_dir, args.log_dataset, args.log_replica).attach()
shipper.start_upload()
cfg = RouterConfig(rate_per_min=args.rate_limit, rate_burst=args.rate_burst, trust_proxy=args.trust_proxy,
proxy_hops=args.proxy_hops, model_label=args.model_label, drain_s=args.drain_s)
app = create_router_app(args.replica, cfg, token, Metrics(slo_ttft_s=args.slo_ttft_s),
hf_wake(token) if token else None, shipper)
import uvicorn
# proxy_headers off: uvicorn would believe X-Forwarded-For from loopback; client_hash does its own parsing.
uvicorn.run(app, host=args.host, port=args.port, log_level="warning", access_log=False,
proxy_headers=False, timeout_graceful_shutdown=int(args.drain_s))
if __name__ == "__main__":
main()
|