Skip to main content

embacle_server/
streaming.rs

1// ABOUTME: Bridges embacle ChatStream to OpenAI-compatible Server-Sent Events format
2// ABOUTME: Converts StreamChunk items to "data: {json}\n\n" SSE with [DONE] terminator
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7use std::convert::Infallible;
8
9use axum::response::sse::{Event, KeepAlive, Sse};
10use axum::response::{IntoResponse, Response};
11use embacle::types::ChatStream;
12use futures::stream;
13use futures::StreamExt;
14
15use crate::completions::{generate_id, unix_timestamp};
16use crate::openai_types::{ChatCompletionChunk, ChunkChoice, Delta, ResponseMessage};
17
18/// Convert a `ChatStream` into an SSE response in `OpenAI` streaming format
19///
20/// Emits:
21/// 1. An initial chunk with role="assistant" and empty content
22/// 2. Content delta chunks as they arrive from the provider
23/// 3. A final chunk with `finish_reason`
24/// 4. `data: [DONE]` terminator
25pub fn sse_response(stream: ChatStream, model: &str) -> Response {
26    let completion_id = generate_id();
27    let created = unix_timestamp();
28    let model = model.to_owned();
29
30    let sse_stream = {
31        let mut sent_role = false;
32
33        stream.map(move |chunk_result| {
34            match chunk_result {
35                Ok(chunk) => {
36                    let (role, content, finish_reason) = if !sent_role {
37                        sent_role = true;
38                        if chunk.delta.is_empty() && !chunk.is_final {
39                            // First chunk: role announcement only
40                            (Some("assistant"), None, None)
41                        } else {
42                            // First chunk has content: send role + content
43                            (Some("assistant"), Some(chunk.delta), chunk.finish_reason)
44                        }
45                    } else if chunk.is_final {
46                        (
47                            None,
48                            if chunk.delta.is_empty() {
49                                None
50                            } else {
51                                Some(chunk.delta)
52                            },
53                            Some(chunk.finish_reason.unwrap_or_else(|| "stop".to_owned())),
54                        )
55                    } else {
56                        (None, Some(chunk.delta), None)
57                    };
58
59                    // LinesStream strips trailing \n from each line. Restore it
60                    // so concatenated SSE deltas preserve original line breaks.
61                    let content = content.map(|c| {
62                        if !c.is_empty() && !c.ends_with('\n') {
63                            let mut normalized = c;
64                            normalized.push('\n');
65                            normalized
66                        } else {
67                            c
68                        }
69                    });
70
71                    let data = ChatCompletionChunk {
72                        id: completion_id.clone(),
73                        object: "chat.completion.chunk",
74                        created,
75                        model: model.clone(),
76                        choices: vec![ChunkChoice {
77                            index: 0,
78                            delta: Delta {
79                                role,
80                                content,
81                                tool_calls: None,
82                            },
83                            finish_reason,
84                        }],
85                    };
86
87                    let json = serde_json::to_string(&data).unwrap_or_default();
88                    Ok::<_, Infallible>(Event::default().data(json))
89                }
90                Err(e) => {
91                    let error_json = serde_json::json!({
92                        "error": {
93                            "message": e.message,
94                            "type": "stream_error"
95                        }
96                    });
97                    Ok(Event::default().data(error_json.to_string()))
98                }
99            }
100        })
101    };
102
103    // Append the [DONE] sentinel after the stream completes
104    let done_stream = stream::once(async { Ok::<_, Infallible>(Event::default().data("[DONE]")) });
105
106    let combined = sse_stream.chain(done_stream);
107
108    Sse::new(combined)
109        .keep_alive(KeepAlive::default())
110        .into_response()
111}
112
113/// Convert a `ChatStream` into an SSE response, stripping markdown code fences
114///
115/// Used when `response_format` requests JSON. CLI runners often wrap JSON in
116/// `` ```json ... ``` `` fences that arrive as separate stream chunks. This
117/// variant filters those fence lines out so the client receives clean JSON.
118pub fn sse_response_strip_fences(stream: ChatStream, model: &str) -> Response {
119    let filtered = strip_fence_chunks(stream);
120    sse_response(filtered, model)
121}
122
123/// Wrap a `ChatStream` to remove chunks that are markdown code fences
124///
125/// Fence-only chunks (`` ```json ``, `` ``` ``) are dropped entirely.
126/// Final chunks with fence content have their delta cleared so the
127/// finish signal still propagates.
128fn strip_fence_chunks(stream: ChatStream) -> ChatStream {
129    use embacle::types::StreamChunk;
130
131    Box::pin(stream.filter_map(|result| async move {
132        match result {
133            Ok(chunk) => {
134                if is_markdown_fence(&chunk.delta) {
135                    if chunk.is_final {
136                        // Preserve the final signal with empty content
137                        Some(Ok(StreamChunk {
138                            delta: String::new(),
139                            is_final: true,
140                            finish_reason: chunk.finish_reason,
141                        }))
142                    } else {
143                        None
144                    }
145                } else {
146                    Some(Ok(chunk))
147                }
148            }
149            Err(e) => Some(Err(e)),
150        }
151    }))
152}
153
154/// Check if a stream chunk is a markdown code fence line (e.g. `` ```json `` or `` ``` ``)
155fn is_markdown_fence(text: &str) -> bool {
156    let trimmed = text.trim();
157    trimmed.starts_with("```") && trimmed.bytes().skip(3).all(|b| b.is_ascii_alphanumeric())
158}
159
160/// Emit a complete non-streaming response as an SSE event sequence
161///
162/// Used when the caller requested `stream: true` but the backend performed a
163/// non-streaming `complete()` (e.g. for tool-calling downgrade). Produces:
164/// 1. Role announcement chunk with content and/or `tool_calls`
165/// 2. Final chunk with `finish_reason`
166/// 3. `[DONE]` sentinel
167pub fn sse_single_response(message: ResponseMessage, finish_reason: &str, model: &str) -> Response {
168    let completion_id = generate_id();
169    let created = unix_timestamp();
170
171    let content_chunk = ChatCompletionChunk {
172        id: completion_id.clone(),
173        object: "chat.completion.chunk",
174        created,
175        model: model.to_owned(),
176        choices: vec![ChunkChoice {
177            index: 0,
178            delta: Delta {
179                role: Some("assistant"),
180                content: message.content,
181                tool_calls: message.tool_calls,
182            },
183            finish_reason: None,
184        }],
185    };
186
187    let final_chunk = ChatCompletionChunk {
188        id: completion_id,
189        object: "chat.completion.chunk",
190        created,
191        model: model.to_owned(),
192        choices: vec![ChunkChoice {
193            index: 0,
194            delta: Delta {
195                role: None,
196                content: None,
197                tool_calls: None,
198            },
199            finish_reason: Some(finish_reason.to_owned()),
200        }],
201    };
202
203    let events = vec![
204        serde_json::to_string(&content_chunk).unwrap_or_default(),
205        serde_json::to_string(&final_chunk).unwrap_or_default(),
206    ];
207
208    let event_stream = stream::iter(
209        events
210            .into_iter()
211            .map(|json| Ok::<_, Infallible>(Event::default().data(json))),
212    );
213    let done_stream = stream::once(async { Ok::<_, Infallible>(Event::default().data("[DONE]")) });
214
215    let combined = event_stream.chain(done_stream);
216
217    Sse::new(combined)
218        .keep_alive(KeepAlive::default())
219        .into_response()
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn is_markdown_fence_detects_fences() {
228        assert!(is_markdown_fence("```json\n"));
229        assert!(is_markdown_fence("```\n"));
230        assert!(is_markdown_fence("```json"));
231        assert!(is_markdown_fence("```"));
232        assert!(is_markdown_fence("  ```json  "));
233    }
234
235    #[test]
236    fn is_markdown_fence_rejects_non_fences() {
237        assert!(!is_markdown_fence("{\"key\": \"value\"}"));
238        assert!(!is_markdown_fence("some text"));
239        assert!(!is_markdown_fence(""));
240        assert!(!is_markdown_fence("```json is cool```"));
241        assert!(!is_markdown_fence("``` code here"));
242    }
243
244    #[tokio::test]
245    async fn strip_fence_chunks_removes_fences() {
246        use embacle::types::StreamChunk;
247
248        let chunks = vec![
249            Ok(StreamChunk {
250                delta: "```json\n".to_owned(),
251                is_final: false,
252                finish_reason: None,
253            }),
254            Ok(StreamChunk {
255                delta: "{\"key\":\"value\"}\n".to_owned(),
256                is_final: false,
257                finish_reason: None,
258            }),
259            Ok(StreamChunk {
260                delta: "```\n".to_owned(),
261                is_final: true,
262                finish_reason: Some("stop".to_owned()),
263            }),
264        ];
265
266        let input: ChatStream = Box::pin(stream::iter(chunks));
267        let filtered = strip_fence_chunks(input);
268
269        let results: Vec<_> = filtered.collect().await;
270        assert_eq!(results.len(), 2);
271
272        // First result is the actual JSON content
273        let first = results[0].as_ref().unwrap(); // Safe: test assertion
274        assert_eq!(first.delta, "{\"key\":\"value\"}\n");
275        assert!(!first.is_final);
276
277        // Second result is the final signal with empty delta (fence stripped)
278        let second = results[1].as_ref().unwrap(); // Safe: test assertion
279        assert!(second.delta.is_empty());
280        assert!(second.is_final);
281        assert_eq!(second.finish_reason.as_deref(), Some("stop"));
282    }
283}