Streaming reference

stream: true in a /v1/chat/completions (or other dispatched) request body triggers
hal0’s streaming forward path in the dispatcher (hal0.dispatcher.router).

Behavior

  • The upstream connection is opened eagerly (httpx.AsyncClient.send(req, stream=True))
    specifically so a connect failure surfaces as a clean 502 UpstreamUnavailable
    rather than failing partway through an already-started response generator.
  • The client response is a Starlette StreamingResponse whose body iterator does a
    raw byte passthrough — every upstream chunk is yielded unmodified. hal0
    does not parse or re-emit SSE events itself; the OpenAI-standard
    data: {...}\n\ndata: [DONE]\n\n framing and the text/event-stream
    content-type come from whatever upstream is serving the slot (llama-server, FLM, or
    a remote provider), copied through verbatim. The one exception is the stall guard
    below, which may append a terminal chunk after cutting a stream off.
  • Status code and content-type on the client response are copied verbatim from the
    upstream response.

Timeouts

Stall guard

httpx’s per-read timeout is no defense against a chatty pathological upstream
that never terminates (finish_reason forever null): every chunk resets the
read timer, so the stream would be relayed forever, leaving the client — Hermes
--cli, the dashboard, any OpenAI-compatible caller — waiting on a socket that
never closes, with no output and no diagnostic. And against a silent upstream a
bare transport timeout tears the stream without any terminal frame. Two
guard-owned bounds prevent both:

Key Default Meaning
[dispatcher].stream_total_timeout_s 900.0 Max wall clock for one relayed stream
[dispatcher].stream_idle_timeout_s 300.0 Max gap between two upstream chunks

Either bound tripping closes the upstream stream and logs
dispatch.stream_stall_guard_tripped (with reason, elapsed_s, and the upstream
name). reason is total, idle, or read (transport read timeout — only
possible with the guard fully disabled). On a text/event-stream response hal0
then appends a final chunk before data: [DONE], preceded by a blank line so it
parses as its own SSE event even when the upstream stalled midway through a
data: line. For /chat/completions (and any other chat-shaped SSE stream) it is
a chat.completion.chunk; for the legacy /v1/completions it is a
text_completion chunk carrying the notice in choices[].text instead of
choices[].delta:

{
  "id": "hal0-stall-guard",
  "object": "chat.completion.chunk",
  "choices": [
    {
      "index": 0,
      "delta": { "role": "assistant", "content": "\n\n[hal0] stall guard: ..." },
      "finish_reason": "length"
    }
  ],
  "x_hal0_stall": { "reason": "total", "elapsed_s": 900.0, "upstream": "llm" }
}

The notice is rendered as ordinary assistant content so the operator sees the cutoff,
finish_reason is the standard "length" so strict clients terminate cleanly, and
the precise cause lives in the x_hal0_stall extension. Non-SSE streaming bodies
(audio, images) are cut off without any injected frame. Set either key to 0 to
disable that bound.

Slot state during a stream

A slot dispatching a streaming request is held in the SERVING state until the client
has fully drained the stream, not just until the first byte is sent — released via a
wrapped async generator. See Slot lifecycle for the
full state machine.

Observability piggybacked on the stream

hal0 wraps the streaming byte iterator purely for metrics — this does not alter what’s
sent to the client:

  • Approximate token count: counts "delta": substring occurrences per chunk, fed
    into a per-slot tokens/sec gauge (GET /api/slots/metrics).
  • Time-to-first-token (TTFT): recorded at the first chunk containing a "delta":
    marker, deliberately skipping llama-server’s initial role-only chunk — stored in a
    per-slot TTFT event deque.

The synthetic stall-guard frame is recognized by its x_hal0_stall marker and
excluded from both: it never counts as a generated token or a TTFT sample, and the
request-metrics row for a guard-tripped stream is recorded as a failure
(ok = 0, error_code = stream_stall_<reason>) rather than a clean success.

Non-streaming responses get an analogous hook that pulls usage.completion_tokens
(and, for FLM/NPU responses, the hal0-specific usage.decoding_speed_tps /
usage.kv_token_occupancy_rate_percentage fields) out of the JSON body.