kopitiam_ai/stream.rs
1//! Token-by-token streaming for [`crate::ModelAdapter`].
2//!
3//! `complete()` is the blocking, whole-reply-at-once call. Streaming is the
4//! *other* shape a chat UI needs: on a phone doing a few tokens per second
5//! (see `temp_ai_design.md` §10.4), you cannot freeze the screen on
6//! `complete()` while the model grinds — you show each token the moment it
7//! lands. [`crate::ModelAdapter::stream`] is that shape.
8//!
9//! # The concurrency discipline — one channel, one background actor
10//!
11//! `stream` hands back a [`std::sync::mpsc::Receiver`] and the *producing*
12//! work runs on a **background thread** that owns its side of the channel —
13//! plain std threads + `mpsc`, **no async runtime**. This is exactly the
14//! actor shape KOPITIAM already committed to in **AID-0028** (the async LSP
15//! session actor): a worker owns a resource, streams results out over a
16//! channel, and never blocks the foreground. We reuse that discipline here
17//! rather than inventing a second concurrency model — the caller's UI thread
18//! stays free to render while the model thread produces.
19//!
20//! The contract the caller can rely on, for *every* adapter:
21//!
22//! * chunks arrive **in order**;
23//! * a run ends with **exactly one terminal chunk** — either
24//! [`StreamChunk::Done`] (clean finish) or [`StreamChunk::Error`] (gave up
25//! partway) — and **nothing follows it**;
26//! * when the producer thread finishes, its `Sender` drops, so iterating the
27//! `Receiver` (`for chunk in rx`) ends naturally after the terminal chunk.
28//!
29//! Because the terminal chunk is always present, a consumer never has to
30//! distinguish "stream ended cleanly" from "sender was dropped mid-reply" by
31//! guesswork — the [`StreamChunk::Done`] vs [`StreamChunk::Error`] tells it
32//! outright.
33
34use std::sync::mpsc::Receiver;
35
36use crate::{CompletionRequest, ModelAdapter};
37
38/// One piece of a streamed model reply, delivered in order over the
39/// [`Receiver`] that [`ModelAdapter::stream`] returns.
40///
41/// **This enum is a frozen contract** — every adapter (local, echo, cloud,
42/// and anything built later) emits exactly these three variants, and every
43/// consumer (the `kopitiam ai chat` loop today, a ratatui pane tomorrow)
44/// matches on exactly these three. Don't add or rename variants without
45/// treating it as the breaking change it is.
46///
47/// A well-formed stream is: zero or more [`StreamChunk::Token`], then
48/// **one** terminal chunk — [`StreamChunk::Done`] on success or
49/// [`StreamChunk::Error`] on failure — then nothing (the sender drops,
50/// closing the channel).
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum StreamChunk {
53 /// One decoded increment of the reply — in practice one model token's
54 /// text (e.g. `"Hel"`, `"lo"`, `" world"`). Concatenating every
55 /// `Token` in arrival order rebuilds the full reply.
56 ///
57 /// Honest caveat, inherited from `kopitiam_runtime::generate`'s
58 /// token-by-token decode: a single multi-byte Unicode character that a
59 /// tokenizer splits across two tokens can arrive split across two
60 /// `Token`s. That's the normal, accepted tradeoff of *any* streaming
61 /// LLM UI — the blocking `complete()` path decodes the whole sequence
62 /// together and never splits — so a caller that needs
63 /// character-perfect text mid-stream should buffer, but a chat UI that
64 /// just appends each `Token` to a pane is fine.
65 Token(String),
66
67 /// The stream finished cleanly. **Always the last chunk on a successful
68 /// run**; nothing follows it. Its arrival is how a consumer knows the
69 /// reply is complete (as opposed to the model still thinking).
70 Done,
71
72 /// Generation failed partway. **Always the last chunk on a failed run**;
73 /// carries a human-readable reason (rendered from the underlying
74 /// `anyhow` error). Nothing follows it. Any `Token`s that arrived before
75 /// it are still valid partial output — the failure just means the reply
76 /// is truncated, not that what came before is wrong.
77 Error(String),
78}
79
80/// Runs `adapter.complete(request)` to completion and *then* pushes the whole
81/// reply into a fresh channel as a single [`StreamChunk::Token`] followed by
82/// [`StreamChunk::Done`] (or a lone [`StreamChunk::Error`] on failure),
83/// returning the already-populated [`Receiver`].
84///
85/// This is the **eager, non-streaming fallback** [`ModelAdapter::stream`]'s
86/// default body uses: it honours the [`StreamChunk`] contract exactly (a
87/// `Token` then a terminal chunk, in order) so a caller can treat *any*
88/// adapter uniformly as a stream — but it does **not** deliver tokens as they
89/// are produced and it does **not** run on a background thread, because a
90/// blocking-only backend has nothing to stream incrementally. Adapters that
91/// *can* produce tokens one at a time (see [`crate::EchoAdapter`],
92/// [`crate::LocalAdapter`]) override `stream` with a real background actor
93/// instead of leaning on this.
94///
95/// It's generic over `A: ModelAdapter + ?Sized` so the trait's default
96/// `stream` body can pass `self` (a `&Self`, which is not `Sized` inside a
97/// default method) straight in, *and* a `&dyn ModelAdapter` still works —
98/// while [`ModelAdapter`] stays object-safe (the default body references no
99/// type parameter of its own).
100pub(crate) fn complete_then_stream<A: ModelAdapter + ?Sized>(
101 adapter: &A,
102 request: &CompletionRequest,
103) -> Receiver<StreamChunk> {
104 let (tx, rx) = std::sync::mpsc::channel();
105 match adapter.complete(request) {
106 Ok(response) => {
107 // A send only fails if the receiver was already dropped (caller
108 // gave up). We're about to drop `tx` anyway, so ignoring the
109 // error is correct — no one left to tell.
110 let _ = tx.send(StreamChunk::Token(response.content));
111 let _ = tx.send(StreamChunk::Done);
112 }
113 Err(error) => {
114 let _ = tx.send(StreamChunk::Error(format!("{error:#}")));
115 }
116 }
117 rx
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use crate::{CompletionResponse, EchoAdapter, Message};
124
125 /// The default (eager) path must still honour the terminal-chunk
126 /// contract: exactly `[Token, Done]` for a successful adapter.
127 #[test]
128 fn complete_then_stream_emits_token_then_done() {
129 let request = CompletionRequest::new([Message::user("oi")]);
130 let rx = complete_then_stream(&EchoAdapter, &request);
131 let chunks: Vec<StreamChunk> = rx.iter().collect();
132 assert_eq!(chunks, vec![StreamChunk::Token("oi".to_string()), StreamChunk::Done]);
133 }
134
135 /// A failing adapter yields exactly one terminal `Error` chunk and no
136 /// `Token`/`Done`.
137 #[test]
138 fn complete_then_stream_emits_error_on_failure() {
139 struct AlwaysFails;
140 impl ModelAdapter for AlwaysFails {
141 fn name(&self) -> &str {
142 "always-fails"
143 }
144 fn complete(&self, _request: &CompletionRequest) -> anyhow::Result<CompletionResponse> {
145 anyhow::bail!("kaput lah")
146 }
147 }
148
149 let rx = complete_then_stream(&AlwaysFails, &CompletionRequest::new([Message::user("x")]));
150 let chunks: Vec<StreamChunk> = rx.iter().collect();
151 assert_eq!(chunks.len(), 1);
152 match &chunks[0] {
153 StreamChunk::Error(message) => assert!(message.contains("kaput lah")),
154 other => panic!("expected a single Error chunk, got {other:?}"),
155 }
156 }
157}