ADR-062: Streaming Request Lifecycle
- Status
-
Accepted
- Date
-
2026-07
- Authors
-
Netresearch DTT GmbH
Context
Every non-streaming provider call goes through the middleware pipeline
(ADR-026: Provider Middleware Pipeline) and so inherits budget pre-flight (ADR-025: Per-User AI Budgets), usage
accounting, and telemetry (ADR-058: Telemetry Middleware). Streaming did not: both
Llm and
stream called the provider's
stream generator directly and returned it to the caller.
The consequence was a live budget hole. A streamed chat:
- ran no budget pre-flight — an over-budget user could stream freely;
- produced no usage row in
tx_, so streamed tokens were invisible to cost dashboards and to the very budget aggregate that is supposed to gate the next call;nrllm_ service_ usage - produced no telemetry row in
tx_, so a streamed call had no latency, no outcome, and no correlation id on record;nrllm_ telemetry - had no fallback, not even in the window where one is still possible.
The pipeline cannot simply be reused. A PHP generator is lazy: calling
stream returns a suspended generator without running a
line of it. Wrapping that as the pipeline terminal makes every middleware run
against a stream that has not started — Budget would gate nothing meaningful,
Usage (which records after the terminal returns) would record
zero tokens, and Telemetry would measure a near-zero latency.
This laziness is exactly why ADR-026: Provider Middleware Pipeline sanctioned streaming as a documented
pipeline bypass.
Decision
Introduce a dedicated streaming lifecycle rather than forcing streams through
the response pipeline. A single private collaborator,
Netresearch, owns it; the
manager's two streaming methods build an opener closure and hand off to it.
The dispatcher is a wrapping generator — it never changes the public
Generator<int, string, mixed, void> contract the seven providers and
their consumers rely on.
The lifecycle has four stages:
- Budget pre-flight — eager.
Streamingruns the sameDispatcher:: stream () Budgetgate asService:: check () Budgetbefore it returns a generator, so an over-budget caller is rejected at call time with a typedMiddleware Budget— not lazily on first iteration. This is the one part that must be eager: a caller that never drains the stream is never charged, but a caller that is already over budget must never receive a stream to drain.Exceeded Exception - Provider selection with fallback — before the first chunk only. The
dispatcher walks the primary configuration's fallback chain (shallow, exactly
as
Fallbackdoes) and primes each candidate generator (Middleware rewind()) inside a try/catch. Priming runs the provider up to its first yield — the HTTP request and first delta — which is the last moment a provider can still be swapped. A retryable failure here (Provider, or aConnection Exception 429Provider) moves to the next candidate; a non-retryable failure bubbles up unchanged. Once a chunk has been handed to the caller a swap is impossible, so fallback stops at the first chunk. That single asymmetry with the non-streaming pipeline is intrinsic to streaming, not a shortcut.Response Exception - Drain accounting — lazy. As the caller drains, the wrapper re-yields each chunk, appends it to a completion buffer, and stamps the time-to-first-token on the first chunk delivered.
- Settlement — in a ``finally``. Usage and telemetry are written in the
drain generator's
finallyblock, so they land on every exit path: normal completion, a mid-stream exception, and an abandoned generator (client disconnect or a consumerbreak). PHP runs a suspended generator'sfinallywhen the generator is destroyed, which is what makes early-break accounting work without a caller callback. An abandoned stream therefore records the partial tokens actually produced, never zero and never the full amount.
Token counts are estimated
The seven streaming adapters yield only text deltas and return void — none
emits a usage frame at stream end. Real per-token usage is therefore not
available on this path today. The dispatcher estimates it with the ≈4
chars/token heuristic already used by
Rendered: the caller passes the prompt character
count on the context metadata (it holds the messages; the dispatcher never sees
the payload, honouring the ADR-026: Provider Middleware Pipeline "context carries no payload" rule) and
the dispatcher counts the drained completion text.
Recording an estimate is a deliberate improvement over the previous state, which
recorded nothing at all — for budget enforcement an approximate figure is far
better than a silent zero. Exact stream usage would require enabling
provider-level usage frames (OpenAI stream_options.include_usage, Anthropic
message_delta usage, …) and threading them out of the generator without
breaking its string yield type — a follow-up, tracked separately, that would
replace the estimate with the reported figure where a provider supports it.
Attribution
Mirroring the non-streaming split (ADR-058: Telemetry Middleware):
- Telemetry names the requested primary configuration; a pre-first-chunk
swap shows as
fallback_attempts > 0.cache_hitis alwaysfalse— streaming never caches. A new nullabletime_to_first_token_mscolumn carries the TTFT; it isNULLfor every non-streaming row (there is no partial-response milestone to measure), deliberately distinct from a real0 ms. - Usage attributes to the configuration that actually served — after a fallback swap that is the fallback's provider/model/uid, not the primary's. For an ad-hoc stream (a pinned provider with no configuration entity) the served provider comes from the context metadata the manager sets.
Scope
- New
Streaming(private service, autowired) and theDispatcher Llmwiring for both streaming entry points.Service Manager streamgains a trailingChat With Configuration () array $metadata = []parameter so streamed calls can carry budget attribution; it is additive and the three-argument callers stay source-compatible.Llm— a Category-1 public API — keeps itsService Manager Interface Generatorreturn contract, so this is backward compatible.tx_gains the nullablenrllm_ telemetry time_to_first_token_mscolumn andTelemetrya matching trailing nullable field.Record - ADR-009: Streaming Implementation and ADR-026: Provider Middleware Pipeline updated: streaming is no longer a documented bypass.
Alternatives considered
- Route the generator through the existing pipeline. Rejected: generator laziness makes every middleware fire against a not-yet-started stream (see Context). Budget, usage, and telemetry would all be wrong.
- Change the provider yield type to carry a final usage object
(
Generator<int, string|UsageStatistics, …>). Rejected: thestringyield type is part of the public capability contract; every consumer would have to defend against a non-string chunk. Real usage belongs on the generator return value or a side channel, tracked as the follow-up above. - Eager drain inside the manager, then hand back a plain array. Rejected:
it defeats the entire point of streaming (memory efficiency, real-time
output) and would change the public
Generatorreturn type.
References
- ADR-009: Streaming Implementation — Streaming Implementation (the generator mechanism this lifecycle wraps).
- ADR-025: Per-User AI Budgets — Per-User AI Budgets (the pre-flight gate).
- ADR-026: Provider Middleware Pipeline — Provider Middleware Pipeline (why streaming could not use it, now no longer a sanctioned bypass).
- ADR-058: Telemetry Middleware — Telemetry Middleware (the row shape and attribution model streaming reuses).