Skip to content

Async & streaming

AsyncBroker is the primary engine; Broker is its blocking wrapper. The methods are the same, just with await:

async with llmbroker.AsyncBroker() as broker:
    reply = await broker.ask("Hello")
    print(reply.text)

The async tool loop is await llmbroker.arun_tool_loop(...), see Tools & agents.

Streaming

Streaming is async-only — the pool yields deltas as they arrive, with routing and failover intact:

stream = broker.stream("Write a haiku about brokers", operation="write")
async for delta in stream:
    print(delta, end="", flush=True)

print(stream.llm_name, stream.usage)   # who answered, and what it cost
await stream.record_quality(0.9)       # rate it without naming the call yourself

stream(...) hands back a handle you iterate for the deltas; it also names the model that answered and, once the answer is over, what it cost.

Failover works normally right up to the first delta — a rate-limited or broken model is cooled down and the next one takes over, invisibly. A model whose answer ends without ever producing a delta is broken in that same sense: nothing reached you, so it is failed over too rather than handed to you as an empty stream. Once text has started arriving there is nothing left to fail over to, so a stream that dies mid-answer raises StreamInterruptedError; the deltas you already received stand.

The budget covers the whole answer

wait bounds the answer, not its first token, and it counts only the time the library spends waiting on the provider:

stream = broker.stream("Write a long answer", wait=20.0)
try:
    async for delta in stream:
        print(delta, end="", flush=True)
except llmbroker.LLMTimeoutError as exc:
    print(f"\ngave up: {exc}")

Taking your time between deltas is yours to take: the clock is disarmed the moment a delta is handed to you and picks up where it left off when you ask for the next one, so a slow reader can never spend the budget. A model that opens at once and then dribbles is therefore not inside a budget it is busy overrunning — which is the whole point of bounding the answer rather than its opening.

Unset means unbounded, which is the default. Before the first delta an exhausted budget ends the call with NoLLMAvailableError; after it, with LLMTimeoutError, and the deltas already delivered stand. Where the difference shows is what it costs the model: nothing at all by the deadline is silence, and the model is set aside briefly like any other that failed you, while a model already writing when your clock ran out is left alone — it answered, you simply stopped waiting. Both ways the pool remembers the budget it did not finish within, so equally tight callers are handed a sibling first. A call cut short this way cannot be rated: it never settled, so record_quality on its handle raises.

That is also why the handle says nothing before the first delta: llm_name and call_id are None until then, because the call may still move to another model. usage fills in later still, when the answer is over.

Rating waits for the same moment the counts do — the end of the answer, when the call reaches the journal. Ask earlier and you get a ValueError rather than a score that quietly goes nowhere.

Stopping early is fine, but what hands the model's slot back is closing the stream: a break does not close the handle by itself — abandoned, it frees the slot only once Python collects it. Close it yourself; that is also the only way to score what you did receive, since closing is what ends the call.

stream = broker.stream("Write a haiku about brokers")
async for delta in stream:
    if looks_wrong(delta):
        break

await stream.aclose()
await stream.record_quality(0.0)

Or let a context manager close it — where there is nothing to score:

async with contextlib.aclosing(broker.stream("...")) as stream:
    async for delta in stream:
        ...

Streaming one named model, with no pool and no failover, is what direct does — see Direct model calls.

One process, one file, no init step

sqlite holds the models, the keys and the journal in one file — enough for a single-process service — and the curated model list fills it on the first call:

async with llmbroker.AsyncBroker("broker.db") as broker:
    print((await broker.ask("Hello")).text)

The database starts empty and is filled before the pool is provisioned, so there is no separate init step to remember, and it is kept current from then on. It is best-effort: an unreachable catalog logs a warning and the process starts on whatever the file already holds — and, where the file is empty, on the copy of the preset shipped inside the package.

If that file is shared with your application, WAL and the file lock are worth knowing about — see SQLite: sharing and WAL. For several processes or hosts, fill the database once in the deploy job instead — see Servers & clusters.