0005 — LLM streaming engine: SSE adapter process architecture

Context

Phase 2 replaces the synthetic producer with a real OpenAI-compatible SSE client. The req 0.7.5 / finch 0.24 surface was read from the dependency source before deciding:

Decision

Process architecture — the producer slot, no new processes

DashFloat.LLM.ChatStream.run(ui, provider, prompt, opts \\ []) is the Task body started by the TUI on submit under DashFloat.StreamSupervisor (restart: :temporary, monitored by the UI) — exactly the producer slot ADR 0002 defined. The UI process is untouched: it still performs zero I/O, still understands only the ADR-0002 message contract ({:token, _} / {:phase, _} / {:entry, _, _} / {:DOWN, …}).

UI process (ExRatatui.Server)          DashFloat.LLM.ChatStream (Task, temporary)
  submit ──Task.Supervisor.start_child──▶ build request
                                          Req.post(into: :self, retry: false)
  ◀── {:token, delta} ──────────────────  receive loop (parse_message + SSE)
  ◀── {:entry, :system, …} ─────────────  error/cancel paths
  ◀── {:phase, :idle} ─────────────────  terminal message, then exit :normal

The stream handler always sends its own terminal {:phase, _} before exiting (the ADR-0002 rule); the UI's monitor stays a safety net, never the reporter of record.

No retries, no redirects, no bad inputs at the door. ChatStream sets retry: false and redirect: false: a retried or followed streaming completions POST duplicates generation and billing, so both are refused. It also validates the endpoint and key before calling Req — base_url must be an http(s) URL with an ASCII host, no embedded whitespace, and a port in range; api_key must be printable ASCII. Unguarded, such a config would kill the producer before its terminal message, surfacing as [stream failed: …] instead of the [llm: …] taxonomy. Validated, each case degrades to a [llm: connection failed: …] entry naming the problem (never the value) and the terminal idle phase, exactly like a transport failure. A non-ASCII key is refused outright rather than scrubbed after the fact: Mint quotes header values in its error, and the quoting form varies, so no replace list is reliable.

Token granularity and backpressure — no drop policy

One {:token, text} message per SSE data frame (the provider-native delta granularity) — not per socket chunk, not per line, no batching, no flushing timer, no drops. Rationale: token messages are pure data appended in O(1); the render loop coalesces queued messages into single frames (ADR 0002), so a burst of N tokens costs N appends and one render, bounded by the viewport. The coalescer is the backpressure valve; the Phase-1 responsiveness test already proves keystroke round-trips stay under 100 ms through a 300-token burst at cadence, and a Phase-2 test re-proves it through the real engine. Dropping or sampling tokens in the producer would corrupt replies to defend a render loop that needs no defense.

Cancellation semantics

Esc while the phase is :streaming cancels. Order of events:

  1. The TUI sends :cancel to the task pid (a no-op if the task already died).
  2. The task calls Req.cancel_async_response/1 — the upstream connection closes; the provider stops generating (and billing).
  3. The task sends {:entry, :system, "[cancelled]"} and {:phase, :idle} (which finalizes the partial reply as an assistant entry), then exits :normal.
  4. The later {:DOWN, _, :process, _, :normal} is a no-op safety net.

What dies: the HTTP request first, then the task. What the UI shows: the partial reply kept, a gray [cancelled] line, status Idle, input usable immediately. If the task is wedged and ignores :cancel, the existing monitor/kill safety net from ADR 0002 still applies — and finch's owner-monitor closes the socket the moment the task dies.

Error taxonomy — every path degrades to a chat entry

Failures never crash the node and never bypass the terminal message. Partial content is always finalized. The API key never appears in any message — error text carries status codes and provider-supplied text only.

Failure Detection Chat log entry
Connect refused / DNS / TLS / header timeout Req.post returns {:error, e} [llm: connection failed: <message>]
HTTP non-2xx initial status; drain body ≤ 64 KiB, ≤ 5 s [llm: HTTP 401 — <provider error.message>] (fallback [llm: HTTP 502])
Malformed frame (data: payload is not valid JSON) decode error mid-loop cancel + [llm: malformed SSE data frame — stream aborted]
Valid JSON frame without choices[0].delta.content (role-only, usage, keepalive frames) shape miss skipped silently — not an error. (Amended by ADR 0006: usage frames are consumed and forwarded as {:usage, _} messages.)
Connection dropped mid-stream {ref, {:error, _}} [llm: connection lost mid-stream]
Stream ended without [DONE] {ref, :done} before [DONE] [llm: stream ended without completion]
No data for 60 s (configurable via :idle_timeout) socket receive_timeout, with the receive after as backstop cancel + [llm: timed out waiting for stream data]
Single SSE frame larger than 1 MiB (a stream that never delimits a frame) buffer cap in ChatStream cancel + [llm: SSE frame exceeded 1 MiB — stream aborted]
SSE comment / unknown field lines parser ignored per the SSE spec (keepalives)

SSE parsing — a pure incremental parser

DashFloat.LLM.SSE is pure: new/0, feed/2 → {[%{data: binary}], parser}, holding back partial frames in a buffer until a blank line completes them. Line endings: LF and CRLF (the OpenAI-compatible ecosystem; lone CR is not handled). Only data: lines are extracted — event:/id:/comments are dropped per spec. [DONE] is interpreted by ChatStream, not the parser. The parser is unbounded by design; ChatStream caps the held-back buffer at 1 MiB and aborts a stream that never delimits a frame.

Provider config — one endpoint, file + env

DashFloat.Config.load/0 reads JSON at :filename.basedir(:user_config, "dashfloat") → config.json (honors XDG_CONFIG_HOME; fields base_url, model, api_key), then overlays per-field env overrides DASHFLOAT_BASE_URL, DASHFLOAT_MODEL, DASHFLOAT_API_KEY. Missing/blank fields after the merge yield {:error, message} naming only the missing fields — never the values present. Loading happens once in Application.start/1 and rides into the TUI as mount opts (provider:), keeping file I/O out of the UI process. Without a usable provider the TUI still runs; submitting degrades to a system entry with setup instructions. The endpoint is one OpenAI-compatible URL: base_url <> "/chat/completions", stream: true, Authorization: Bearer.

(This section's file layout and precedence are superseded by ADR 0008 — octofriend-style JSON5 files with profiles. The load-once-in-Application contract, the mount-opt handoff, and the never-echo-values error rule stand, as restated there.)

Testing — recorded bytes and a loopback server

The SSE parser is tested on recorded byte-stream fixtures (fed whole and byte-by-byte). ChatStream and the TUI integration run against a one-shot :gen_tcp loopback server (127.0.0.1, ephemeral port) scripted per test with exact bytes, sleeps, and closes — deterministic, no external network, no new dependencies. Live-key smoke is a documented manual step.

Alternatives considered

Consequences