Download src/tinyllm/router.py from CoolDip00/tiny-llm: direct link, hf CLI and curl.
- Browser
- Download file 34.3 kB
-
https://huggingface.co/spaces/CoolDip00/tiny-llm/resolve/main/src/tinyllm/router.py
- Command line
-
hf download hf://spaces/CoolDip00/tiny-llm/src/tinyllm/router.py
-
curl -L -o router.py https://huggingface.co/spaces/CoolDip00/tiny-llm/resolve/main/src/tinyllm/router.py
34.3 kB
| """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}) | |
| 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 | |
| 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 | |
| def ready(self) -> bool: | |
| return self.verdict is Verdict.OK | |
| 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") | |
| 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) | |
| 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. | |
| async def health(): | |
| return {"status": "ok"} | |
| async def ready(): | |
| return {"ready": True} | |
| async def models(): | |
| return router.models() | |
| async def stats(): | |
| return router.describe(router.metrics.stats()) | |
| history_cache: dict = {} | |
| 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"] | |
| async def demo_page(): | |
| return HTMLResponse(read_page("demo.html")) | |
| async def status_page(): | |
| return HTMLResponse(read_page("status.html")) | |
| 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() | |