Spaces:
Running
Running
File size: 6,571 Bytes
024efc3 fcdba1d 024efc3 620acd3 024efc3 fcdba1d 024efc3 fcdba1d 024efc3 | 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 | """Bridge the synchronous pipeline to an async SSE stream.
`pipeline.answer()` is ordinary blocking Python β it makes network calls, and
`rerank.rerank` can sleep for up to 90 s backing off a Cohere 429. Running that on the
event loop would freeze every other connection, so it runs in a worker thread and pushes
trace events back through an asyncio queue.
Concurrency is deliberately capped at one run at a time. Two independent reasons:
* Cohere trial keys allow 10 rerank calls/minute and a single complex-route run makes up
to 3. Serialising keeps a burst of visitors from tripping the provider's limit β which
would show up as a 90 s stall, not a clean error.
* The free HF Spaces CPU is 2 vCPU. The 16576x1536 dense matmul plus BM25 scoring is
comfortable serially and thrashes in parallel.
"""
from __future__ import annotations
import asyncio
import json
import sys
import time
from typing import AsyncIterator
from starlette.concurrency import run_in_threadpool
from src import pipeline, trace
from . import limits
# One live pipeline run at a time; a few may wait, the rest are told to come back.
_semaphore = asyncio.Semaphore(1)
_waiting = 0
MAX_WAITING = 3
HEARTBEAT_SECONDS = 15
RUN_TIMEOUT_SECONDS = 120
VALID_MODES = ("dense", "hybrid", "hybrid_rerank")
VALID_ROUTES = ("auto", "simple", "complex")
def sse(kind: str, payload: dict) -> str:
"""Format one Server-Sent Event frame."""
return f"event: {kind}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
def count_api_calls(events: list[dict]) -> dict:
"""Derive the paid-API call counts for a finished run from its own event stream.
Every paid call announces itself: `llm_call` per generation, `retrieval_start` per query
embedding, `shortlist` per Cohere rerank (only emitted on the hybrid_rerank path). So
the accounting is exact rather than estimated, and it stays correct automatically if the
pipeline's shape ever changes.
"""
counts = {"llm": 0, "embed": 0, "rerank": 0}
for event in events:
limits.tally(counts, event)
return counts
def try_admit() -> bool:
"""Claim a slot in the run queue, or return False if it is already full.
Admission is synchronous and separate from `stream()` because an async generator does
not execute a single line until it is first iterated β which happens after the endpoint
has already returned its response. Deciding here lets the endpoint answer a busy demo
with a clean 503 instead of an error buried mid-stream.
"""
global _waiting
if _waiting >= MAX_WAITING:
return False
_waiting += 1
return True
async def stream(question: str, mode: str, route: str) -> AsyncIterator[str]:
"""Run the pipeline and yield SSE frames as its trace events arrive.
Yields the raw event stream first (so the UI animates in real time), then an `answer`
and a `done` frame. Call `try_admit()` first β this releases the slot it claimed.
"""
global _waiting
try:
async with _semaphore:
async for frame in _run(question, mode, route):
yield frame
finally:
_waiting -= 1
async def _run(question: str, mode: str, route: str) -> AsyncIterator[str]:
loop = asyncio.get_running_loop()
queue: asyncio.Queue = asyncio.Queue()
captured: list[dict] = []
started = time.perf_counter()
def emit(ev: dict) -> None:
# Called from the worker thread β hop back onto the loop to touch the queue.
captured.append(ev)
loop.call_soon_threadsafe(queue.put_nowait, ev)
def work() -> dict:
# The sink is bound *inside* the worker thread, so the ContextVar is set on the
# thread that actually runs the pipeline. No context propagation to reason about.
try:
with trace.collect(emit):
return pipeline.answer(
question, mode=mode, route=None if route == "auto" else route
)
finally:
# Billed here, in the worker thread, rather than beside the streaming loop:
# the paid calls happen whether or not the visitor is still connected, and a
# client that disconnects mid-run closes the generator without draining the
# queue. Recording at the source is the only place the count is complete.
counts = count_api_calls(captured)
if any(counts.values()):
limits.record(**counts)
task = asyncio.ensure_future(run_in_threadpool(work))
deadline = loop.time() + RUN_TIMEOUT_SECONDS
yield sse("accepted", {"question": question, "mode": mode, "route": route})
try:
while not (task.done() and queue.empty()):
if loop.time() > deadline:
yield sse("error", {"message": "That took too long, so it was stopped."})
return
try:
event = await asyncio.wait_for(queue.get(), timeout=HEARTBEAT_SECONDS)
except asyncio.TimeoutError:
# Keeps intermediaries from dropping a connection that is legitimately
# waiting on a slow model call.
yield ": heartbeat\n\n"
continue
yield sse(event["kind"], event)
result = await task
except Exception as exc: # noqa: BLE001 - the stream must report, never crash
# The detail goes to the server log, not the visitor: provider exceptions can carry
# request context, and a public demo has no reason to hand that out.
print(f"[demo] run failed: {type(exc).__name__}: {exc}", file=sys.stderr)
yield sse("error", {"message": "That run did not finish. Try again, or pick "
"one of the saved runs."})
return
finally:
# A worker thread cannot be cancelled β on timeout or client disconnect it keeps
# running to completion. Waiting for it here means the caller's semaphore is not
# released while that thread is still calling Cohere, which is what makes the
# one-run-at-a-time limit actually hold.
if not task.done():
await asyncio.gather(task, return_exceptions=True)
yield sse("answer", {"text": result["answer"], "route": result["route"]})
yield sse(
"done",
{
"route": result["route"],
"excerpts": len(result["results"]),
"ms": round((time.perf_counter() - started) * 1000),
"api_calls": count_api_calls(captured),
},
)
|