Skip to main content

cortiq_server/
streaming.rs

1//! SSE (Server-Sent Events) streaming for OpenAI-compatible chat completions.
2
3use axum::response::sse::{Event, Sse};
4use futures::stream::Stream;
5use serde::Serialize;
6use std::convert::Infallible;
7use std::pin::Pin;
8use std::task::{Context, Poll};
9use tokio::sync::mpsc;
10
11/// A streaming chat completion response.
12pub struct ChatStream {
13    rx: mpsc::Receiver<StreamChunk>,
14    state: StreamState,
15}
16
17/// SSE termination protocol: after the finish_reason chunk (or channel
18/// close) exactly one `data: [DONE]` is emitted, then the stream ends.
19#[derive(PartialEq)]
20enum StreamState {
21    Open,
22    Finishing,
23    Done,
24}
25
26/// A single chunk in the SSE stream.
27#[derive(Debug, Clone, Serialize)]
28pub struct StreamChunk {
29    pub id: String,
30    pub object: String,
31    pub created: u64,
32    pub model: String,
33    pub choices: Vec<StreamChoice>,
34    /// Real token counts, emitted once ahead of the finish chunk (the OpenAI
35    /// `include_usage` shape) — clients get exact numbers, not estimates.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub usage: Option<StreamUsage>,
38}
39
40#[derive(Debug, Clone, Serialize)]
41pub struct StreamUsage {
42    pub prompt_tokens: u32,
43    pub completion_tokens: u32,
44    pub total_tokens: u32,
45}
46
47#[derive(Debug, Clone, Serialize)]
48pub struct StreamChoice {
49    pub index: u32,
50    pub delta: StreamDelta,
51    pub finish_reason: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize)]
55pub struct StreamDelta {
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub role: Option<String>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub content: Option<String>,
60    /// Tool calls arrive as one aggregated delta right before the
61    /// finish chunk — the shape every OpenAI client accumulates anyway.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub tool_calls: Option<serde_json::Value>,
64}
65
66impl ChatStream {
67    /// Create a new stream pair (sender, SSE response).
68    pub fn new(buffer: usize) -> (mpsc::Sender<StreamChunk>, Self) {
69        let (tx, rx) = mpsc::channel(buffer);
70        (
71            tx,
72            Self {
73                rx,
74                state: StreamState::Open,
75            },
76        )
77    }
78
79    /// Convert to axum SSE response.
80    pub fn into_sse(self) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
81        Sse::new(SseStream { inner: self })
82    }
83}
84
85struct SseStream {
86    inner: ChatStream,
87}
88
89impl Stream for SseStream {
90    type Item = Result<Event, Infallible>;
91
92    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
93        match self.inner.state {
94            StreamState::Done => return Poll::Ready(None),
95            StreamState::Finishing => {
96                self.inner.state = StreamState::Done;
97                return Poll::Ready(Some(Ok(Event::default().data("[DONE]"))));
98            }
99            StreamState::Open => {}
100        }
101
102        match self.inner.rx.poll_recv(cx) {
103            Poll::Ready(Some(chunk)) => {
104                let is_finish = chunk
105                    .choices
106                    .first()
107                    .and_then(|c| c.finish_reason.as_deref())
108                    .is_some();
109                if is_finish {
110                    self.inner.state = StreamState::Finishing;
111                }
112                let json = serde_json::to_string(&chunk).unwrap_or_default();
113                Poll::Ready(Some(Ok(Event::default().data(json))))
114            }
115            Poll::Ready(None) => {
116                // Channel closed without a finish chunk — still terminate
117                // the protocol correctly.
118                self.inner.state = StreamState::Done;
119                Poll::Ready(Some(Ok(Event::default().data("[DONE]"))))
120            }
121            Poll::Pending => Poll::Pending,
122        }
123    }
124}
125
126/// A content-delta chunk (built synchronously from the generation thread).
127
128/// One delta carrying the aggregated tool calls, indexed for clients
129/// that merge by `index`.
130pub fn tool_calls_chunk(
131    id: &str,
132    model: &str,
133    calls: serde_json::Value,
134    created: u64,
135) -> StreamChunk {
136    StreamChunk {
137        id: id.to_string(),
138        object: "chat.completion.chunk".to_string(),
139        created,
140        model: model.to_string(),
141        choices: vec![StreamChoice {
142            index: 0,
143            delta: StreamDelta {
144                role: None,
145                content: None,
146                tool_calls: Some(calls),
147            },
148            finish_reason: None,
149        }],
150        usage: None,
151    }
152}
153
154pub fn token_chunk(id: &str, model: &str, token: &str, created: u64) -> StreamChunk {
155    StreamChunk {
156        id: id.to_string(),
157        object: "chat.completion.chunk".to_string(),
158        created,
159        model: model.to_string(),
160        choices: vec![StreamChoice {
161            index: 0,
162            delta: StreamDelta {
163                role: None,
164                content: Some(token.to_string()),
165                tool_calls: None,
166            },
167            finish_reason: None,
168        }],
169        usage: None,
170    }
171}
172
173/// The usage chunk (empty `choices`, real counts) sent just before the finish
174/// chunk, exactly as OpenAI's `stream_options.include_usage` does.
175pub fn usage_chunk(
176    id: &str,
177    model: &str,
178    created: u64,
179    prompt_tokens: u32,
180    completion_tokens: u32,
181) -> StreamChunk {
182    StreamChunk {
183        id: id.to_string(),
184        object: "chat.completion.chunk".to_string(),
185        created,
186        model: model.to_string(),
187        choices: Vec::new(),
188        usage: Some(StreamUsage {
189            prompt_tokens,
190            completion_tokens,
191            total_tokens: prompt_tokens + completion_tokens,
192        }),
193    }
194}
195
196/// The terminal chunk carrying the real finish_reason.
197pub fn finish_chunk(id: &str, model: &str, reason: &str, created: u64) -> StreamChunk {
198    StreamChunk {
199        id: id.to_string(),
200        object: "chat.completion.chunk".to_string(),
201        created,
202        model: model.to_string(),
203        choices: vec![StreamChoice {
204            index: 0,
205            delta: StreamDelta {
206                role: None,
207                content: None,
208                tool_calls: None,
209            },
210            finish_reason: Some(reason.to_string()),
211        }],
212        usage: None,
213    }
214}