embacle_server/
streaming.rs1use 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
18pub 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 (Some("assistant"), None, None)
41 } else {
42 (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 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 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
113pub fn sse_response_strip_fences(stream: ChatStream, model: &str) -> Response {
119 let filtered = strip_fence_chunks(stream);
120 sse_response(filtered, model)
121}
122
123fn 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 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
154fn 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
160pub 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 let first = results[0].as_ref().unwrap(); assert_eq!(first.delta, "{\"key\":\"value\"}\n");
275 assert!(!first.is_final);
276
277 let second = results[1].as_ref().unwrap(); assert!(second.delta.is_empty());
280 assert!(second.is_final);
281 assert_eq!(second.finish_reason.as_deref(), Some("stop"));
282 }
283}